fix : too high fade
CI / Verify (push) Successful in 1m21s
CI / Build immutable images and deploy (push) Successful in 2m2s

This commit is contained in:
2026-09-06 03:06:03 +07:00 Unverified
parent d3e5e6c04f
commit 2808a27406
2 changed files with 232 additions and 25 deletions
+225 -18
View File
@@ -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 (
<div
ref={setNodeRef}
className={className}
style={{
...style,
transform: `translate(${offset.x}%, ${offset.y}%)${temporaryTransform}`,
}}
{...dragProps}
>
{children}
</div>
);
}
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<string, string | null>;
captureRef?: Ref<HTMLDivElement>;
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<HTMLParagraphElement>(null);
const disclaimerRef = useRef<HTMLParagraphElement>(null);
const posterRef = useRef<HTMLDivElement>(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,11 +275,12 @@ function OverviewPoster({
cancelled = true;
observer.disconnect();
};
}, [character?.name]);
}, [character?.name, disclaimerFontSize]);
return (
<DndContext sensors={posterSensors} onDragEnd={handlePosterDragEnd}>
<div
ref={captureRef}
ref={posterRef}
className="relative isolate aspect-square size-full overflow-hidden bg-black text-white [container-type:inline-size]"
style={{ fontFamily: '"Genshin Overview", sans-serif' }}
>
@@ -248,7 +323,16 @@ function OverviewPoster({
)
) : null}
{artworkUrl ? (
<div className="absolute inset-0" style={{ zIndex: z.character }}>
<DraggablePosterLayer
id="character"
offset={composition.characterOffset}
interactive={interactive && activeLayer === "character"}
className={cn(
"absolute inset-0 cursor-grab active:cursor-grabbing",
interactive && "touch-none",
)}
style={{ zIndex: z.character }}
>
<Image
src={artworkUrl}
alt=""
@@ -274,15 +358,23 @@ function OverviewPoster({
}}
/>
))}
</div>
</DraggablePosterLayer>
) : (
<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"
<DraggablePosterLayer
id="fade"
offset={composition.fadeOffset}
interactive={interactive && activeLayer === "fade"}
className={cn(
"absolute inset-x-0 -top-[3cqw] bottom-0",
activeLayer === "fade"
? "touch-none cursor-grab active:cursor-grabbing"
: "pointer-events-none",
)}
style={{ zIndex: z.fade }}
>
<Image
@@ -304,7 +396,7 @@ function OverviewPoster({
WebkitMask: "url('/img/fade.png') center / 100% 100% no-repeat",
}}
/>
</div>
</DraggablePosterLayer>
<div
className="absolute inset-0 pointer-events-none"
@@ -458,13 +550,14 @@ function OverviewPoster({
</div>
<p
ref={disclaimerRef}
className="mt-[1.5cqw] whitespace-pre text-[2.15cqw] leading-[1.15]"
className="mt-[1.5cqw] whitespace-pre text-center text-[2.15cqw] leading-[1.15]"
>
{DISCLAIMER}
</p>
</div>
) : null}
</div>
</DndContext>
);
}
@@ -624,6 +717,7 @@ export function OverviewImageGenerator({
const [nameCardCropOpen, setNameCardCropOpen] = useState(false);
const [nameCardZoom, setNameCardZoom] = useState(1);
const [role, setRole] = useState<OverviewRole>("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<Position>({
x: 0,
y: 0,
});
const [fadeOffset, setFadeOffset] = useState<Position>({ x: 0, y: 0 });
const [activePosterLayer, setActivePosterLayer] =
useState<PosterLayer>("character");
const [disclaimerFontSize, setDisclaimerFontSize] = useState<number | null>(
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({
}
/>
</Field>
<Field>
<FieldLabel>ตำแหน่งภาพ</FieldLabel>
<Button
type="button"
variant="outline"
onClick={resetPosterPositions}
>
<RotateCcwIcon data-icon="inline-start" />
รีเซ็ตตำแหน่งตัวละครและ Fade
</Button>
<FieldDescription>
เลือกเลเยอร์ใต้ตัวอย่าง แล้วลากในตัวอย่างเพื่อเลื่อนตำแหน่ง
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="overview-disclaimer-size">
ขนาด Disclaimer {disclaimerFontSize === null ? "ตามชื่ออัตโนมัติ" : `${disclaimerFontSize}cqw`}
</FieldLabel>
<div className="flex items-center gap-3">
<Input
id="overview-disclaimer-size"
type="range"
min={0.5}
max={5}
step={0.05}
value={disclaimerFontSize ?? 2.15}
onChange={(event) =>
setDisclaimerFontSize(Number(event.target.value))
}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={disclaimerFontSize === null}
onClick={() => setDisclaimerFontSize(null)}
>
อัตโนมัติ
</Button>
</div>
<FieldDescription>
ค่าเริ่มต้นจะปรับให้พอดีกับความกว้างชื่ออัตโนมัติ
</FieldDescription>
</Field>
<Field>
<FieldLabel>Role</FieldLabel>
<ToggleGroup
@@ -1124,6 +1290,20 @@ export function OverviewImageGenerator({
))}
</ToggleGroup>
</Field>
{role === "Custom" ? (
<Field>
<FieldLabel htmlFor="overview-custom-role">
ข้อความ Custom Role
</FieldLabel>
<Input
id="overview-custom-role"
value={customRoleText}
maxLength={32}
placeholder="เช่น Buffer"
onChange={(event) => setCustomRoleText(event.target.value)}
/>
</Field>
) : null}
<Field>
<FieldLabel>Blend mode</FieldLabel>
<DndContext
@@ -1189,7 +1369,7 @@ export function OverviewImageGenerator({
composition={composition}
artworkUrl={artworkUrl}
character={character}
role={role}
role={displayRole}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
elementColors={[elementColorStart, elementColorEnd]}
@@ -1198,8 +1378,34 @@ export function OverviewImageGenerator({
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
disclaimerFontSize={disclaimerFontSize}
interactive
activeLayer={activePosterLayer}
onLayerMove={movePosterLayer}
/>
</div>
<Field className="mt-4">
<FieldLabel>เลือกเลเยอร์ที่จะเลื่อน</FieldLabel>
<ToggleGroup
value={[activePosterLayer]}
onValueChange={(value) => {
const selected = value[0] as PosterLayer | undefined;
if (selected) setActivePosterLayer(selected);
}}
variant="outline"
className="w-full"
>
<ToggleGroupItem value="character" className="flex-1">
Char
</ToggleGroupItem>
<ToggleGroupItem value="fade" className="flex-1">
Fade
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
เลือกเลเยอร์แล้วลากตรงบริเวณตัวอย่างเพื่อเลื่อนตำแหน่ง
</FieldDescription>
</Field>
</CardContent>
</Card>
@@ -1269,6 +1475,7 @@ export function OverviewImageGenerator({
</Card>
<div
ref={captureRef}
aria-hidden="true"
className="pointer-events-none fixed top-0 -left-[10000px] order-5 size-[1000px]"
>
@@ -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}
/>
</div>
+2 -2
View File
@@ -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`;