1452 lines
48 KiB
TypeScript
1452 lines
48 KiB
TypeScript
"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<string, string | null>;
|
||
captureRef?: Ref<HTMLDivElement>;
|
||
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<HTMLParagraphElement>(null);
|
||
const disclaimerRef = useRef<HTMLParagraphElement>(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 (
|
||
<div
|
||
ref={captureRef}
|
||
className="relative isolate aspect-square size-full overflow-hidden bg-black text-white [container-type:inline-size]"
|
||
style={{ fontFamily: '"Genshin Overview", sans-serif' }}
|
||
>
|
||
{composition.backgroundUrl ? (
|
||
<Image
|
||
src={composition.backgroundUrl}
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
className="object-cover"
|
||
/>
|
||
) : character?.nameCardImageUrl ? (
|
||
nameCardCrop && nameCardSize ? (
|
||
<Image
|
||
src={character.nameCardImageUrl}
|
||
alt=""
|
||
width={nameCardSize.width}
|
||
height={nameCardSize.height}
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="absolute max-w-none"
|
||
style={{
|
||
width: `${(nameCardSize.width / nameCardCrop.width) * 100}%`,
|
||
height: `${(nameCardSize.height / nameCardCrop.height) * 100}%`,
|
||
left: `${(-nameCardCrop.x / nameCardCrop.width) * 100}%`,
|
||
top: `${(-nameCardCrop.y / nameCardCrop.height) * 100}%`,
|
||
opacity: 0.7,
|
||
}}
|
||
/>
|
||
) : (
|
||
<Image
|
||
src={character.nameCardImageUrl}
|
||
alt=""
|
||
fill
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-cover opacity-70"
|
||
/>
|
||
)
|
||
) : null}
|
||
{artworkUrl ? (
|
||
<div className="absolute inset-0" style={{ zIndex: z.character }}>
|
||
<Image
|
||
src={artworkUrl}
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-cover"
|
||
/>
|
||
{[...blendLayers].reverse().map((layer) => (
|
||
<Image
|
||
key={layer.id}
|
||
src={artworkUrl}
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-cover"
|
||
style={{
|
||
mixBlendMode: layer.mode,
|
||
opacity: layer.amount / 100,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="absolute inset-0 grid place-items-center bg-muted text-muted-foreground">
|
||
<ImageIcon aria-hidden="true" className="size-[12cqw]" />
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className="absolute inset-x-0 -top-[3cqw] bottom-0"
|
||
style={{ zIndex: z.fade }}
|
||
>
|
||
<Image
|
||
src="/img/fade.png"
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-fill"
|
||
/>
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundColor: normalizeHex(composition.fadeColor) ?? "#0E0200",
|
||
opacity: composition.fadeTint / 100,
|
||
mask: "url('/img/fade.png') center / 100% 100% no-repeat",
|
||
maskMode: "alpha",
|
||
WebkitMask: "url('/img/fade.png') center / 100% 100% no-repeat",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className="absolute inset-0 pointer-events-none"
|
||
style={{ zIndex: z.aura }}
|
||
>
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundImage: accentGradient(...topColors),
|
||
transform: `translateY(-${composition.auraUpOffset}%)`,
|
||
maskImage: "url('/img/aura-up.png')",
|
||
maskMode: "alpha",
|
||
maskPosition: "center",
|
||
maskRepeat: "no-repeat",
|
||
maskSize: "100% 100%",
|
||
WebkitMaskImage: "url('/img/aura-up.png')",
|
||
WebkitMaskPosition: "center",
|
||
WebkitMaskRepeat: "no-repeat",
|
||
WebkitMaskSize: "100% 100%",
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundImage: accentGradient(...bottomColors),
|
||
maskImage: "url('/img/aura-down.png')",
|
||
maskMode: "alpha",
|
||
maskPosition: "center",
|
||
maskRepeat: "no-repeat",
|
||
maskSize: "100% 100%",
|
||
WebkitMaskImage: "url('/img/aura-down.png')",
|
||
WebkitMaskPosition: "center",
|
||
WebkitMaskRepeat: "no-repeat",
|
||
WebkitMaskSize: "100% 100%",
|
||
}}
|
||
/>
|
||
|
||
<div
|
||
className="absolute inset-x-0 top-0 h-[1.35cqw]"
|
||
style={{ backgroundImage: accentGradient(...topColors) }}
|
||
/>
|
||
<div
|
||
className="absolute inset-x-0 bottom-0 h-[1.35cqw]"
|
||
style={{ backgroundImage: accentGradient(...bottomColors) }}
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className="absolute inset-0 pointer-events-none"
|
||
style={{ zIndex: z.badge }}
|
||
>
|
||
<Image
|
||
src="/img/badge.png"
|
||
alt=""
|
||
fill
|
||
unoptimized
|
||
loading="eager"
|
||
sizes="1024px"
|
||
className="object-fill"
|
||
/>
|
||
<div
|
||
className="absolute top-[4.43cqw] left-0 flex h-[6.2cqw] items-center gap-[0.45cqw] text-[4.4cqw] leading-none font-semibold"
|
||
style={{ fontFamily: '"Kanit Overview", sans-serif' }}
|
||
>
|
||
{weaponIcon ? (
|
||
<Image
|
||
src={weaponIcon}
|
||
alt=""
|
||
width={128}
|
||
height={128}
|
||
loading="eager"
|
||
className="ml-[0.3cqw] size-[5.65cqw] object-contain brightness-0 invert"
|
||
/>
|
||
) : null}
|
||
<span>{role}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{character ? (
|
||
<div
|
||
className="absolute inset-x-[5cqw] top-[71cqw] flex flex-col items-center text-center"
|
||
style={{ zIndex: z.text }}
|
||
>
|
||
<p
|
||
ref={titleRef}
|
||
className="w-fit max-w-full whitespace-nowrap leading-none font-normal tracking-[0.04em]"
|
||
style={{ fontSize: "9cqw" }}
|
||
>
|
||
{character.name.toLocaleUpperCase("en")}
|
||
</p>
|
||
<div className="mt-[0.8cqw] flex items-center justify-center gap-[0.65cqw]">
|
||
{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 (
|
||
<span
|
||
key={element}
|
||
className="relative size-[3.5cqw] shrink-0"
|
||
style={{
|
||
filter: `drop-shadow(0 0 0.43cqw ${elementGlowColor(element)}80)`,
|
||
}}
|
||
>
|
||
<Image
|
||
src={url}
|
||
alt=""
|
||
fill
|
||
loading="eager"
|
||
sizes="5cqw"
|
||
className="object-contain"
|
||
style={{ filter: elementIconFilter(element) }}
|
||
/>
|
||
<span
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundImage: `linear-gradient(${elementGradientAngle}deg, ${normalizeHex(elementColors[0]) ?? "#FFFFFF"}, ${normalizeHex(elementColors[1]) ?? "#FFFFFF"})`,
|
||
maskImage: `url(${JSON.stringify(maskImageProps.src)})`,
|
||
maskMode: "alpha",
|
||
maskPosition: "center",
|
||
maskRepeat: "no-repeat",
|
||
maskSize: "contain",
|
||
WebkitMaskImage: `url(${JSON.stringify(maskImageProps.src)})`,
|
||
WebkitMaskPosition: "center",
|
||
WebkitMaskRepeat: "no-repeat",
|
||
WebkitMaskSize: "contain",
|
||
}}
|
||
/>
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<Image
|
||
key={element}
|
||
src={url}
|
||
alt=""
|
||
width={72}
|
||
height={72}
|
||
loading="eager"
|
||
sizes="5cqw"
|
||
className="size-[3.5cqw] object-contain brightness-0 invert opacity-30"
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
<p
|
||
ref={disclaimerRef}
|
||
className="mt-[1.5cqw] whitespace-pre text-[2.15cqw] leading-[1.15]"
|
||
>
|
||
{DISCLAIMER}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ColorField({
|
||
id,
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
id: string;
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
const invalid = !normalizeHex(value);
|
||
return (
|
||
<Field data-invalid={invalid}>
|
||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
aria-label={`${label} picker`}
|
||
type="color"
|
||
value={normalizeHex(value) ?? "#000000"}
|
||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||
className="w-14 shrink-0 p-1"
|
||
/>
|
||
<Input
|
||
id={id}
|
||
value={value}
|
||
maxLength={7}
|
||
aria-invalid={invalid}
|
||
placeholder="#E9A51A"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
/>
|
||
</div>
|
||
{invalid ? <FieldError>กรุณาใส่สีรูปแบบ #RRGGBB</FieldError> : null}
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
function SortableBlendLayerRow({
|
||
layer,
|
||
onUpdate,
|
||
onRemove,
|
||
}: {
|
||
layer: BlendLayer;
|
||
onUpdate: (patch: Partial<Omit<BlendLayer, "id">>) => void;
|
||
onRemove: () => void;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: layer.id });
|
||
const selectedMode = blendModeItems.find((mode) => mode.value === layer.mode);
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
className={cn(
|
||
"flex items-start gap-2 rounded-lg border bg-background p-3",
|
||
isDragging && "relative z-10 opacity-60 shadow-md",
|
||
)}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="touch-none cursor-grab active:cursor-grabbing"
|
||
aria-label="ลากเพื่อจัดลำดับ Blend mode"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVerticalIcon data-icon="inline-start" />
|
||
</Button>
|
||
<div className="min-w-0 flex-1">
|
||
<Combobox
|
||
items={blendModeItems}
|
||
value={selectedMode}
|
||
onValueChange={(value) => {
|
||
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"))
|
||
}
|
||
>
|
||
<ComboboxInput className="w-full" placeholder="เลือก Blend mode…" />
|
||
<ComboboxContent>
|
||
<ComboboxEmpty>ไม่พบ Blend mode</ComboboxEmpty>
|
||
<ComboboxList>
|
||
{(item) => (
|
||
<ComboboxItem key={item.value} value={item}>
|
||
{item.label}
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</Combobox>
|
||
<Field className="mt-3">
|
||
<FieldLabel htmlFor={`overview-blend-amount-${layer.id}`}>
|
||
ความเข้ม {layer.amount}%
|
||
</FieldLabel>
|
||
<Input
|
||
id={`overview-blend-amount-${layer.id}`}
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={layer.amount}
|
||
onChange={(event) => onUpdate({ amount: Number(event.target.value) })}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="ลบ Blend mode"
|
||
onClick={onRemove}
|
||
>
|
||
<Trash2Icon data-icon="inline-start" />
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OverviewImageGenerator({
|
||
characters,
|
||
elementIconUrls,
|
||
}: {
|
||
characters: Character[];
|
||
elementIconUrls: Record<string, string | null>;
|
||
}) {
|
||
const captureRef = useRef<HTMLDivElement>(null);
|
||
const [character, setCharacter] = useState<Character | null>(null);
|
||
const [artworkUrl, setArtworkUrl] = useState<string | null>(null);
|
||
const [artworkError, setArtworkError] = useState<string | null>(null);
|
||
const [pendingCrop, setPendingCrop] = useState<PendingCrop | null>(null);
|
||
const [cropArea, setCropArea] = useState<CropArea | null>(null);
|
||
const [cropZoom, setCropZoom] = useState(1);
|
||
const [nameCardCrop, setNameCardCrop] = useState<CropArea | null>(null);
|
||
const [nameCardDraftCrop, setNameCardDraftCrop] = useState<CropArea | null>(
|
||
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<OverviewRole>("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<BlendLayer[]>([
|
||
{ 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<string | null>(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<Omit<BlendLayer, "id">>) {
|
||
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<HTMLInputElement>,
|
||
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<HTMLImageElement>((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<HTMLImageElement>((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<Blob>((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<HTMLImageElement>((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<Blob>((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 (
|
||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(16rem,1fr)_minmax(16rem,1fr)_minmax(20rem,1.3fr)]">
|
||
<div className="contents">
|
||
<Card className="order-1 lg:col-start-1 lg:row-start-1">
|
||
<CardHeader>
|
||
<CardTitle>ตั้งค่าภาพ</CardTitle>
|
||
<CardDescription>ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<Field>
|
||
<FieldLabel>ตัวละคร</FieldLabel>
|
||
<Combobox
|
||
items={characters}
|
||
value={character}
|
||
onValueChange={(value) => {
|
||
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
|
||
>
|
||
<ComboboxInput className="w-full" placeholder="ค้นหาตัวละคร…" />
|
||
<ComboboxContent>
|
||
<ComboboxEmpty>ไม่พบตัวละคร</ComboboxEmpty>
|
||
<ComboboxList>
|
||
{(item) => (
|
||
<ComboboxItem key={item.key} value={item}>
|
||
<span
|
||
className={cn(
|
||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||
rarityGradientClass(item.rarity),
|
||
)}
|
||
>
|
||
<Image
|
||
src={item.imageUrl}
|
||
alt=""
|
||
fill
|
||
sizes="32px"
|
||
className="object-contain"
|
||
/>
|
||
</span>
|
||
<span>{item.name}</span>
|
||
</ComboboxItem>
|
||
)}
|
||
</ComboboxList>
|
||
</ComboboxContent>
|
||
</Combobox>
|
||
<FieldDescription>
|
||
ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog
|
||
</FieldDescription>
|
||
</Field>
|
||
|
||
<Field>
|
||
<FieldLabel>พื้นหลัง Namecard</FieldLabel>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={!character?.nameCardImageUrl || !nameCardSize}
|
||
onClick={() => {
|
||
setNameCardDraftCrop(null);
|
||
setNameCardZoom(1);
|
||
setNameCardCropOpen(true);
|
||
}}
|
||
>
|
||
<CropIcon data-icon="inline-start" />
|
||
เลือกตำแหน่งพื้นหลัง
|
||
</Button>
|
||
<FieldDescription>
|
||
ใช้ Namecard ของตัวละครใต้ภาพโปร่งใส
|
||
</FieldDescription>
|
||
</Field>
|
||
|
||
<Field data-invalid={Boolean(artworkError)}>
|
||
<FieldLabel htmlFor="overview-artwork">ภาพตัวละคร 1:1</FieldLabel>
|
||
<Input
|
||
id="overview-artwork"
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
aria-invalid={Boolean(artworkError)}
|
||
onChange={(event) => selectArtwork(event)}
|
||
/>
|
||
<FieldDescription>
|
||
ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน
|
||
</FieldDescription>
|
||
{artworkError ? <FieldError>{artworkError}</FieldError> : null}
|
||
</Field>
|
||
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-background">
|
||
อัปโหลดพื้นหลัง
|
||
</FieldLabel>
|
||
<Input
|
||
id="overview-background"
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={(event) => selectArtwork(event, "background")}
|
||
/>
|
||
{backgroundUrl ? (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setBackgroundUrl(null)}
|
||
>
|
||
กลับไปใช้ Namecard
|
||
</Button>
|
||
) : null}
|
||
<FieldDescription>
|
||
ครอปและซูมพื้นหลังเป็นสี่เหลี่ยมก่อนใช้งาน
|
||
</FieldDescription>
|
||
</Field>
|
||
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="order-3 lg:col-start-3 lg:row-span-2 lg:row-start-1">
|
||
<CardHeader>
|
||
<CardTitle>ธีมและเอฟเฟกต์</CardTitle>
|
||
<CardDescription>
|
||
สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup>
|
||
<ColorField
|
||
id="overview-fade-color"
|
||
label="สี Fade"
|
||
value={fadeColor}
|
||
onChange={setFadeColor}
|
||
/>
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-fade-tint">
|
||
ความเข้มสี Fade {fadeTint}%
|
||
</FieldLabel>
|
||
<Input
|
||
id="overview-fade-tint"
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
step={1}
|
||
value={fadeTint}
|
||
onChange={(event) => setFadeTint(Number(event.target.value))}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-aura-up">
|
||
เลื่อน Aura ด้านบนขึ้น {auraUpOffset}%
|
||
</FieldLabel>
|
||
<Input
|
||
id="overview-aura-up"
|
||
type="range"
|
||
min={0}
|
||
max={7}
|
||
step={0.1}
|
||
value={auraUpOffset}
|
||
onChange={(event) =>
|
||
setAuraUpOffset(Number(event.target.value))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel>Role</FieldLabel>
|
||
<ToggleGroup
|
||
value={[role]}
|
||
onValueChange={(value) => {
|
||
const selected = value[0] as OverviewRole | undefined;
|
||
if (selected) setRole(selected);
|
||
}}
|
||
variant="outline"
|
||
className="flex-wrap justify-start"
|
||
>
|
||
{overviewRoles.map((value) => (
|
||
<ToggleGroupItem key={value} value={value}>
|
||
{value}
|
||
</ToggleGroupItem>
|
||
))}
|
||
</ToggleGroup>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel>Blend mode</FieldLabel>
|
||
<DndContext
|
||
sensors={blendLayerSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleBlendLayerDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={blendLayers.map((layer) => layer.id)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<div className="flex flex-col gap-3">
|
||
{blendLayers.map((layer) => (
|
||
<SortableBlendLayerRow
|
||
key={layer.id}
|
||
layer={layer}
|
||
onUpdate={(patch) => updateBlendLayer(layer.id, patch)}
|
||
onRemove={() => removeBlendLayer(layer.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
<div className="mt-3">
|
||
<Button type="button" variant="outline" onClick={addBlendLayer}>
|
||
<PlusIcon data-icon="inline-start" />
|
||
เพิ่ม Blend mode
|
||
</Button>
|
||
</div>
|
||
<FieldDescription>
|
||
เพิ่มได้หลายแบบ และลากที่ไอคอนเพื่อจัดลำดับการผสมสี
|
||
</FieldDescription>
|
||
</Field>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
<CardFooter>
|
||
<Button
|
||
className="w-full"
|
||
disabled={!ready || exporting}
|
||
onClick={download}
|
||
>
|
||
{exporting ? (
|
||
<Spinner data-icon="inline-start" />
|
||
) : (
|
||
<DownloadIcon data-icon="inline-start" />
|
||
)}
|
||
{exporting ? "กำลังสร้าง PNG…" : "ดาวน์โหลด PNG 3000×3000"}
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="order-2 min-w-0 lg:col-start-2 lg:row-start-1">
|
||
<CardHeader>
|
||
<CardTitle>ตัวอย่าง</CardTitle>
|
||
<CardDescription>
|
||
ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-lg border">
|
||
<OverviewPoster
|
||
composition={composition}
|
||
artworkUrl={artworkUrl}
|
||
character={character}
|
||
role={role}
|
||
topColors={[topColorStart, topColorEnd]}
|
||
bottomColors={[bottomColorStart, bottomColorEnd]}
|
||
elementColors={[elementColorStart, elementColorEnd]}
|
||
elementGradientAngle={elementGradientAngle}
|
||
blendLayers={blendLayers}
|
||
nameCardCrop={nameCardCrop}
|
||
nameCardSize={nameCardSize}
|
||
elementIconUrls={elementIconUrls}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="order-4 lg:col-span-2 lg:col-start-1 lg:row-start-2">
|
||
<CardHeader>
|
||
<CardTitle>สีธีม</CardTitle>
|
||
<CardDescription>
|
||
สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด และแก้ไขเองได้
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FieldGroup className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
<ColorField
|
||
id="overview-top-color-start"
|
||
label="สีขอบบน เริ่มต้น"
|
||
value={topColorStart}
|
||
onChange={setTopColorStart}
|
||
/>
|
||
<ColorField
|
||
id="overview-top-color-end"
|
||
label="สีขอบบน สิ้นสุด"
|
||
value={topColorEnd}
|
||
onChange={setTopColorEnd}
|
||
/>
|
||
<ColorField
|
||
id="overview-bottom-color-start"
|
||
label="สีขอบล่าง เริ่มต้น"
|
||
value={bottomColorStart}
|
||
onChange={setBottomColorStart}
|
||
/>
|
||
<ColorField
|
||
id="overview-bottom-color-end"
|
||
label="สีขอบล่าง สิ้นสุด"
|
||
value={bottomColorEnd}
|
||
onChange={setBottomColorEnd}
|
||
/>
|
||
<ColorField
|
||
id="overview-element-color-start"
|
||
label="สีไล่ระดับธาตุ เริ่มต้น"
|
||
value={elementColorStart}
|
||
onChange={setElementColorStart}
|
||
/>
|
||
<ColorField
|
||
id="overview-element-color-end"
|
||
label="สีไล่ระดับธาตุ สิ้นสุด"
|
||
value={elementColorEnd}
|
||
onChange={setElementColorEnd}
|
||
/>
|
||
<Field>
|
||
<FieldLabel htmlFor="overview-element-gradient-angle">
|
||
มุมสีไล่ระดับธาตุ {elementGradientAngle}°
|
||
</FieldLabel>
|
||
<Input
|
||
id="overview-element-gradient-angle"
|
||
type="range"
|
||
min={0}
|
||
max={360}
|
||
step={1}
|
||
value={elementGradientAngle}
|
||
onChange={(event) =>
|
||
setElementGradientAngle(Number(event.target.value))
|
||
}
|
||
/>
|
||
</Field>
|
||
</FieldGroup>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div
|
||
aria-hidden="true"
|
||
className="pointer-events-none fixed top-0 -left-[10000px] order-5 size-[1000px]"
|
||
>
|
||
<OverviewPoster
|
||
composition={composition}
|
||
artworkUrl={artworkUrl}
|
||
character={character}
|
||
role={role}
|
||
topColors={[topColorStart, topColorEnd]}
|
||
bottomColors={[bottomColorStart, bottomColorEnd]}
|
||
elementColors={[elementColorStart, elementColorEnd]}
|
||
elementGradientAngle={elementGradientAngle}
|
||
blendLayers={blendLayers}
|
||
nameCardCrop={nameCardCrop}
|
||
nameCardSize={nameCardSize}
|
||
elementIconUrls={elementIconUrls}
|
||
captureRef={captureRef}
|
||
/>
|
||
</div>
|
||
|
||
<Dialog
|
||
open={Boolean(pendingCrop)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setPendingCrop(null);
|
||
}}
|
||
>
|
||
<DialogContent className="sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>ครอปภาพเป็น 1:1</DialogTitle>
|
||
<DialogDescription>
|
||
เลื่อนตำแหน่งภาพให้ส่วนที่ต้องการอยู่ภายในกรอบสี่เหลี่ยม
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{pendingCrop ? (
|
||
<div className="flex flex-col gap-5">
|
||
<Cropper
|
||
key={pendingCrop.url}
|
||
image={pendingCrop.url}
|
||
aspectRatio={1}
|
||
zoom={cropZoom}
|
||
onCropChange={setCropArea}
|
||
onZoomChange={setCropZoom}
|
||
className="h-96 rounded-lg bg-black sm:h-120"
|
||
>
|
||
<CropperDescription>
|
||
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม
|
||
</CropperDescription>
|
||
<CropperImage />
|
||
<CropperCropArea />
|
||
</Cropper>
|
||
<div className="flex justify-center gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => rotateArtwork(-90)}
|
||
>
|
||
<RotateCcwIcon data-icon="inline-start" />
|
||
หมุนซ้าย
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => rotateArtwork(90)}
|
||
>
|
||
<RotateCwIcon data-icon="inline-start" />
|
||
หมุนขวา
|
||
</Button>
|
||
</div>
|
||
<Field>
|
||
<FieldLabel htmlFor="crop-zoom">ซูม</FieldLabel>
|
||
<div className="flex items-center gap-3">
|
||
<ZoomOutIcon
|
||
aria-hidden="true"
|
||
className="shrink-0 opacity-60"
|
||
/>
|
||
<Input
|
||
id="crop-zoom"
|
||
type="range"
|
||
min={1}
|
||
max={3}
|
||
step={0.1}
|
||
value={cropZoom}
|
||
onChange={(event) =>
|
||
setCropZoom(Number(event.target.value))
|
||
}
|
||
/>
|
||
<ZoomInIcon
|
||
aria-hidden="true"
|
||
className="shrink-0 opacity-60"
|
||
/>
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
) : null}
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setPendingCrop(null)}>
|
||
ยกเลิก
|
||
</Button>
|
||
<Button disabled={!cropArea} onClick={applyCrop}>
|
||
<CropIcon data-icon="inline-start" />
|
||
ใช้ภาพที่ครอป
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={nameCardCropOpen} onOpenChange={setNameCardCropOpen}>
|
||
<DialogContent className="sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>ครอปพื้นหลัง Namecard</DialogTitle>
|
||
<DialogDescription>
|
||
ลากและซูม Namecard เพื่อเลือกพื้นที่ด้านหลังตัวละคร
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{character?.nameCardImageUrl ? (
|
||
<div className="flex flex-col gap-5">
|
||
<Cropper
|
||
image={character.nameCardImageUrl}
|
||
aspectRatio={1}
|
||
zoom={nameCardZoom}
|
||
onCropChange={setNameCardDraftCrop}
|
||
onZoomChange={setNameCardZoom}
|
||
className="h-96 rounded-lg bg-black sm:h-120"
|
||
>
|
||
<CropperDescription>
|
||
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นหลังสี่เหลี่ยม
|
||
</CropperDescription>
|
||
<CropperImage />
|
||
<CropperCropArea />
|
||
</Cropper>
|
||
<Field>
|
||
<FieldLabel htmlFor="namecard-zoom">ซูม</FieldLabel>
|
||
<div className="flex items-center gap-3">
|
||
<ZoomOutIcon
|
||
aria-hidden="true"
|
||
className="shrink-0 opacity-60"
|
||
/>
|
||
<Input
|
||
id="namecard-zoom"
|
||
type="range"
|
||
min={1}
|
||
max={3}
|
||
step={0.1}
|
||
value={nameCardZoom}
|
||
onChange={(event) =>
|
||
setNameCardZoom(Number(event.target.value))
|
||
}
|
||
/>
|
||
<ZoomInIcon
|
||
aria-hidden="true"
|
||
className="shrink-0 opacity-60"
|
||
/>
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
) : null}
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setNameCardCropOpen(false)}
|
||
>
|
||
ยกเลิก
|
||
</Button>
|
||
<Button
|
||
disabled={!nameCardDraftCrop}
|
||
onClick={() => {
|
||
if (!nameCardDraftCrop) return;
|
||
setNameCardCrop(nameCardDraftCrop);
|
||
setNameCardCropOpen(false);
|
||
}}
|
||
>
|
||
<CropIcon data-icon="inline-start" />
|
||
ใช้ตำแหน่งนี้
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|