2312 lines
85 KiB
TypeScript
2312 lines
85 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Image as ImageIcon,
|
||
Loader2,
|
||
Move,
|
||
RefreshCcw,
|
||
Sparkles,
|
||
Type,
|
||
Upload,
|
||
ZoomIn,
|
||
ZoomOut,
|
||
} from "lucide-react";
|
||
import {
|
||
type ChangeEvent,
|
||
type CSSProperties,
|
||
type PointerEvent as ReactPointerEvent,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
import useSWR from "swr";
|
||
import { AgentPsdTemplateExportPreview } from "@/components/agents/agent-psd-template-export-preview";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectGroup,
|
||
SelectItem,
|
||
SelectLabel,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
type AgentPsdImagePlacement,
|
||
type AgentPsdImageSize,
|
||
getDefaultAgentPsdImagePlacement,
|
||
normalizeAgentPsdImagePlacement,
|
||
resolveAgentPsdImageBaseFrame,
|
||
} from "@/lib/agents/psd-image-placement";
|
||
import {
|
||
type AgentPsdTemplate,
|
||
type AgentPsdTemplateImageLayerSlot,
|
||
type AgentPsdTemplateLayerSlot,
|
||
type AgentPsdTemplateListResponse,
|
||
type AgentPsdTemplateMode,
|
||
type AgentPsdTemplateRenderResponse,
|
||
type AgentPsdTemplateTextLayerSlot,
|
||
isAgentPsdImageLayerSlot,
|
||
isAgentPsdTextLayerSlot,
|
||
parseAgentPsdTemplates,
|
||
} from "@/lib/agents/psd-template-config";
|
||
import {
|
||
defaultAgentPsdTemplateSecondaryCategoryConfig,
|
||
normalizeAgentPsdTemplateSecondaryCategoryConfig,
|
||
} from "@/lib/agents/psd-template-secondary-category-config";
|
||
import {
|
||
defaultAgentPsdTextColorConfig,
|
||
normalizeAgentPsdTextColorConfig,
|
||
} from "@/lib/agents/psd-text-color-config";
|
||
import {
|
||
normalizeHexColor,
|
||
resolveTextLayout,
|
||
} from "@/lib/agents/psd-text-layout";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
|
||
const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
|
||
try {
|
||
const response = await fetch(url, { cache: "no-store" });
|
||
const payload = (await response
|
||
.json()
|
||
.catch(() => null)) as Partial<AgentPsdTemplateListResponse> | null;
|
||
|
||
if (
|
||
!response.ok ||
|
||
!payload ||
|
||
payload.success !== true ||
|
||
!Array.isArray(payload.templates)
|
||
) {
|
||
return {
|
||
success: false,
|
||
templates: [],
|
||
textColorConfig: defaultAgentPsdTextColorConfig,
|
||
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
|
||
error:
|
||
typeof payload?.error === "string"
|
||
? payload.error
|
||
: "PSD 模板加载失败",
|
||
};
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
templates: parseAgentPsdTemplates(payload.templates),
|
||
textColorConfig: normalizeAgentPsdTextColorConfig(
|
||
payload.textColorConfig
|
||
),
|
||
secondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
|
||
payload.secondaryCategoryConfig
|
||
),
|
||
};
|
||
} catch {
|
||
return {
|
||
success: false,
|
||
templates: [],
|
||
textColorConfig: defaultAgentPsdTextColorConfig,
|
||
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
|
||
error: "PSD 模板加载失败",
|
||
};
|
||
}
|
||
};
|
||
const PREVIEW_VIEWPORT_PADDING = 56;
|
||
const PREVIEW_STAGE_MAX_SCALE = 0.82;
|
||
const PREVIEW_STAGE_TARGET_WIDTH = 760;
|
||
const PREVIEW_STAGE_TARGET_HEIGHT = 760;
|
||
const MIN_IMAGE_SCALE = 0.4;
|
||
const MAX_IMAGE_SCALE = 3;
|
||
|
||
function getTemplateMetaLabel(
|
||
template: AgentPsdTemplate,
|
||
showSecondaryCategory: boolean
|
||
) {
|
||
const metaParts = [
|
||
`${template.width} x ${template.height}`,
|
||
showSecondaryCategory
|
||
? String(template.secondaryCategory || "").trim()
|
||
: "",
|
||
].filter(Boolean);
|
||
return metaParts.join(" · ");
|
||
}
|
||
|
||
function renderTemplateSelectContent(
|
||
template: AgentPsdTemplate,
|
||
params?: {
|
||
showSecondaryCategory?: boolean;
|
||
compact?: boolean;
|
||
}
|
||
) {
|
||
const showSecondaryCategory = params?.showSecondaryCategory === true;
|
||
const compact = params?.compact === true;
|
||
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory);
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
compact
|
||
? "grid min-w-0 flex-1 grid-cols-[48px_minmax(0,1fr)] items-center gap-3"
|
||
: "grid min-w-0 flex-1 grid-cols-[46px_minmax(0,1fr)] items-center gap-2.5"
|
||
}
|
||
>
|
||
<div
|
||
className={
|
||
compact
|
||
? "flex h-12 w-12 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5"
|
||
: "flex h-[46px] w-[46px] shrink-0 overflow-hidden rounded-lg border border-white/10 bg-white/5"
|
||
}
|
||
>
|
||
<img
|
||
alt={template.name}
|
||
className="h-full w-full object-cover"
|
||
src={template.previewUrl}
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 text-left">
|
||
<div
|
||
className={
|
||
compact
|
||
? "truncate font-medium text-sm leading-tight"
|
||
: "truncate font-semibold text-[15px] leading-tight"
|
||
}
|
||
>
|
||
{template.name}
|
||
</div>
|
||
{metaLabel ? (
|
||
<div
|
||
className={
|
||
compact
|
||
? "mt-1 truncate text-[11px] text-white/45"
|
||
: "mt-0.5 truncate text-[11px] text-white/45"
|
||
}
|
||
>
|
||
{metaLabel}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function renderTemplatePickerCardContent(
|
||
template: AgentPsdTemplate,
|
||
params?: {
|
||
showSecondaryCategory?: boolean;
|
||
emphasize?: boolean;
|
||
}
|
||
) {
|
||
const showSecondaryCategory = params?.showSecondaryCategory === true;
|
||
const emphasize = params?.emphasize === true;
|
||
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory);
|
||
|
||
return (
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5">
|
||
<img
|
||
alt={template.name}
|
||
className="h-full w-full object-cover"
|
||
src={template.previewUrl}
|
||
/>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div
|
||
className={
|
||
emphasize
|
||
? "truncate font-semibold text-sm text-white"
|
||
: "truncate font-medium text-sm text-white"
|
||
}
|
||
>
|
||
{template.name}
|
||
</div>
|
||
{metaLabel ? (
|
||
<div className="mt-1 truncate text-[11px] text-white/45">
|
||
{metaLabel}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function clampImageScale(value: number) {
|
||
if (!Number.isFinite(value)) {
|
||
return 1;
|
||
}
|
||
return Math.max(MIN_IMAGE_SCALE, Math.min(MAX_IMAGE_SCALE, value));
|
||
}
|
||
|
||
function createInitialState(
|
||
template: AgentPsdTemplate | null,
|
||
sourceImageUrl: string
|
||
) {
|
||
const imageAssignments: Record<string, string> = {};
|
||
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
|
||
const textAssignments: Record<string, string> = {};
|
||
|
||
if (!template) {
|
||
return { imageAssignments, imagePlacements, textAssignments };
|
||
}
|
||
|
||
for (const layer of template.layers) {
|
||
if (
|
||
isAgentPsdImageLayerSlot(layer) &&
|
||
layer.isDefaultResultSource &&
|
||
sourceImageUrl
|
||
) {
|
||
imageAssignments[layer.id] = sourceImageUrl;
|
||
imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement();
|
||
}
|
||
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
|
||
textAssignments[layer.id] = layer.defaultText;
|
||
}
|
||
}
|
||
|
||
return { imageAssignments, imagePlacements, textAssignments };
|
||
}
|
||
|
||
function getLayerLabel(layer: AgentPsdTemplateLayerSlot) {
|
||
return isAgentPsdImageLayerSlot(layer) ? "可替换图片" : "可编辑文字";
|
||
}
|
||
|
||
function getLayerOutlineColor(layer: AgentPsdTemplateLayerSlot) {
|
||
return isAgentPsdImageLayerSlot(layer)
|
||
? "rgba(249,115,22,0.95)"
|
||
: "rgba(56,189,248,0.95)";
|
||
}
|
||
|
||
function buildPreviewFontFaces(template: AgentPsdTemplate | null) {
|
||
if (!template) return "";
|
||
|
||
const rules = new Map<string, string>();
|
||
for (const layer of template.layers) {
|
||
if (
|
||
!isAgentPsdTextLayerSlot(layer) ||
|
||
!layer.fontFamily ||
|
||
!layer.fontFileUrl
|
||
) {
|
||
continue;
|
||
}
|
||
const familyAliases = Array.from(
|
||
new Set(
|
||
[
|
||
layer.fontFamily,
|
||
...(layer.detectedFontNames || []),
|
||
layer.detectedFontName,
|
||
]
|
||
.map((family) => String(family || "").trim())
|
||
.filter(Boolean)
|
||
)
|
||
);
|
||
|
||
for (const familyAlias of familyAliases) {
|
||
if (rules.has(familyAlias)) {
|
||
continue;
|
||
}
|
||
|
||
rules.set(
|
||
familyAlias,
|
||
`@font-face { font-family: "${familyAlias}"; src: url("${layer.fontFileUrl}") format("${layer.fontContentType?.includes("woff2") ? "woff2" : layer.fontContentType?.includes("woff") ? "woff" : layer.fontContentType?.includes("otf") ? "opentype" : "truetype"}"); font-display: swap; }`
|
||
);
|
||
}
|
||
}
|
||
|
||
return Array.from(rules.values()).join("\n");
|
||
}
|
||
|
||
function buildTextLayerFontFamily(layer: AgentPsdTemplateTextLayerSlot) {
|
||
const families = Array.from(
|
||
new Set(
|
||
[
|
||
layer.fontFamily,
|
||
...(layer.detectedFontNames || []),
|
||
layer.detectedFontName,
|
||
"PingFang SC",
|
||
"Microsoft YaHei",
|
||
"Noto Sans SC",
|
||
"Source Han Sans SC",
|
||
"sans-serif",
|
||
]
|
||
.map((family) => String(family || "").trim())
|
||
.filter(Boolean)
|
||
)
|
||
);
|
||
|
||
return families
|
||
.map((family) =>
|
||
family === "sans-serif" ? family : `"${family.replace(/"/g, '\\"')}"`
|
||
)
|
||
.join(", ");
|
||
}
|
||
|
||
function getPreferredSelectedLayerId(template: AgentPsdTemplate | null) {
|
||
if (!template || template.layers.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
const editableLayers = template.layers.filter((layer) => layer.isEditable);
|
||
if (editableLayers.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
const preferredImage = editableLayers.find(
|
||
(layer) => isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource
|
||
);
|
||
if (preferredImage) {
|
||
return preferredImage.id;
|
||
}
|
||
|
||
return editableLayers[0]?.id || "";
|
||
}
|
||
|
||
function getImageFit(
|
||
replaceMode: AgentPsdTemplateImageLayerSlot["replaceMode"]
|
||
): CSSProperties["objectFit"] {
|
||
if (replaceMode === "contain") return "contain";
|
||
if (replaceMode === "stretch") return "fill";
|
||
return "cover";
|
||
}
|
||
|
||
function getLayerFrameStyle(
|
||
layer: AgentPsdTemplateLayerSlot,
|
||
isSelected: boolean
|
||
): CSSProperties {
|
||
return {
|
||
left: layer.x,
|
||
top: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
border: `2px solid ${getLayerOutlineColor(layer)}`,
|
||
boxShadow: isSelected
|
||
? `0 0 0 3px rgba(255,255,255,0.16), 0 0 0 6px ${getLayerOutlineColor(layer)}55`
|
||
: `0 0 0 1px ${getLayerOutlineColor(layer)}66 inset`,
|
||
};
|
||
}
|
||
|
||
function getResolvedTextPreview(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string
|
||
) {
|
||
return value || layer.placeholder || layer.layerName;
|
||
}
|
||
|
||
function getResolvedTextLayerColor(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value?: string
|
||
) {
|
||
return normalizeHexColor(value || layer.textColor, layer.textColor);
|
||
}
|
||
|
||
function getTextLayerRenderFrame(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string
|
||
) {
|
||
const resolvedLayout = resolveTextLayout({
|
||
text: getResolvedTextPreview(layer, value),
|
||
width: layer.width,
|
||
height: layer.height,
|
||
fontSize: layer.fontSize,
|
||
lineHeight: layer.lineHeight,
|
||
letterSpacing: layer.letterSpacing,
|
||
paddingX: layer.paddingX,
|
||
paddingY: layer.paddingY,
|
||
maxLines: layer.maxLines,
|
||
autoFit: false,
|
||
preferNoWrap: true,
|
||
});
|
||
const width = Math.max(layer.width, resolvedLayout.contentWidth);
|
||
const height = Math.max(layer.height, resolvedLayout.contentHeight);
|
||
const offsetX =
|
||
layer.textAlign === "right"
|
||
? layer.width - width
|
||
: layer.textAlign === "center"
|
||
? (layer.width - width) / 2
|
||
: 0;
|
||
|
||
return {
|
||
...resolvedLayout,
|
||
width,
|
||
height,
|
||
offsetX,
|
||
offsetY: -resolvedLayout.visualOffsetY,
|
||
};
|
||
}
|
||
|
||
function getTextLayerContentStyle(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string,
|
||
color: string
|
||
): CSSProperties {
|
||
const resolvedLayout = getTextLayerRenderFrame(layer, value);
|
||
|
||
return {
|
||
width: resolvedLayout.width,
|
||
height: resolvedLayout.height,
|
||
fontFamily: buildTextLayerFontFamily(layer),
|
||
fontSize: resolvedLayout.fontSize,
|
||
lineHeight: layer.lineHeight,
|
||
letterSpacing: `${layer.letterSpacing}px`,
|
||
color,
|
||
fontWeight: layer.fontWeight,
|
||
textAlign: layer.textAlign,
|
||
paddingLeft: layer.paddingX,
|
||
paddingRight: layer.paddingX,
|
||
paddingTop: layer.paddingY,
|
||
paddingBottom: layer.paddingY,
|
||
whiteSpace: "pre",
|
||
wordBreak: "keep-all",
|
||
overflowWrap: "normal",
|
||
};
|
||
}
|
||
|
||
function getAssignedImageMaskStyle(
|
||
layer: AgentPsdTemplateImageLayerSlot
|
||
): CSSProperties {
|
||
const hasMask = Boolean(layer.maskUrl);
|
||
return {
|
||
WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||
maskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||
WebkitMaskSize: hasMask ? "100% 100%" : undefined,
|
||
maskSize: hasMask ? "100% 100%" : undefined,
|
||
WebkitMaskRepeat: hasMask ? "no-repeat" : undefined,
|
||
maskRepeat: hasMask ? "no-repeat" : undefined,
|
||
WebkitMaskPosition: hasMask ? "center" : undefined,
|
||
maskPosition: hasMask ? "center" : undefined,
|
||
};
|
||
}
|
||
|
||
function getPlacedAssignedImageStyle(params: {
|
||
layer: AgentPsdTemplateImageLayerSlot;
|
||
imageSize: AgentPsdImageSize;
|
||
placement: AgentPsdImagePlacement;
|
||
}): CSSProperties {
|
||
const { layer, imageSize, placement } = params;
|
||
const normalizedPlacement = normalizeAgentPsdImagePlacement(placement);
|
||
const baseFrame = resolveAgentPsdImageBaseFrame({
|
||
containerWidth: layer.width,
|
||
containerHeight: layer.height,
|
||
imageWidth: imageSize.width,
|
||
imageHeight: imageSize.height,
|
||
replaceMode: layer.replaceMode,
|
||
contentBounds: layer.contentBounds,
|
||
});
|
||
|
||
return {
|
||
position: "absolute",
|
||
left: baseFrame.centerX + normalizedPlacement.offsetX,
|
||
top: baseFrame.centerY + normalizedPlacement.offsetY,
|
||
width: baseFrame.width,
|
||
height: baseFrame.height,
|
||
transform: `translate(-50%, -50%) scale(${normalizedPlacement.scale})`,
|
||
transformOrigin: "center center",
|
||
objectFit: getImageFit(layer.replaceMode),
|
||
maxWidth: "none",
|
||
maxHeight: "none",
|
||
willChange: "transform",
|
||
};
|
||
}
|
||
|
||
function hasEditedTextValue(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string
|
||
) {
|
||
return (
|
||
String(value || "").replace(/\r\n/g, "\n") !==
|
||
String(layer.defaultText || "").replace(/\r\n/g, "\n")
|
||
);
|
||
}
|
||
|
||
function hasCustomizedTextLayerAppearance(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string,
|
||
color: string
|
||
) {
|
||
return (
|
||
hasEditedTextValue(layer, value) ||
|
||
getResolvedTextLayerColor(layer, color) !== getResolvedTextLayerColor(layer)
|
||
);
|
||
}
|
||
|
||
function getInlineTextEditorStyle(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
preserveSourceAppearance: boolean,
|
||
value: string,
|
||
color: string
|
||
): CSSProperties {
|
||
const baseStyle = getTextLayerContentStyle(layer, value, color);
|
||
return {
|
||
...baseStyle,
|
||
background: "transparent",
|
||
boxSizing: "border-box",
|
||
overflow: "visible",
|
||
color: preserveSourceAppearance ? "transparent" : color,
|
||
caretColor: color,
|
||
WebkitTextFillColor: preserveSourceAppearance ? "transparent" : color,
|
||
};
|
||
}
|
||
|
||
function getTextLayerNameBadgeClassName() {
|
||
return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/78 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg";
|
||
}
|
||
|
||
function getTextLayerHintBadgeClassName() {
|
||
return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg";
|
||
}
|
||
|
||
export function AgentPsdTemplateEditorDialog({
|
||
open,
|
||
onOpenChange,
|
||
sourceImageUrl,
|
||
brandName,
|
||
sizeInfoText,
|
||
templateMode,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
sourceImageUrl: string | null;
|
||
brandName?: string | null;
|
||
sizeInfoText?: string | null;
|
||
templateMode?: AgentPsdTemplateMode | null;
|
||
}) {
|
||
const normalizedBrandName = String(brandName || "").trim();
|
||
const normalizedTemplateMode =
|
||
templateMode === "sku" ? "sku" : templateMode === "main" ? "main" : "";
|
||
const templateListRequestUrl = useMemo(() => {
|
||
if (!open || !normalizedBrandName) {
|
||
return null;
|
||
}
|
||
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.set("brand", normalizedBrandName);
|
||
return `/api/psd-templates?${searchParams.toString()}`;
|
||
}, [open, normalizedBrandName]);
|
||
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
|
||
templateListRequestUrl,
|
||
fetcher
|
||
);
|
||
const templates = useMemo(
|
||
() => (data?.success ? data.templates : []),
|
||
[data]
|
||
);
|
||
const textColorConfig = useMemo(
|
||
() => normalizeAgentPsdTextColorConfig(data?.textColorConfig),
|
||
[data?.textColorConfig]
|
||
);
|
||
const secondaryCategoryConfig = useMemo(
|
||
() =>
|
||
normalizeAgentPsdTemplateSecondaryCategoryConfig(
|
||
data?.secondaryCategoryConfig
|
||
),
|
||
[data?.secondaryCategoryConfig]
|
||
);
|
||
|
||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
|
||
const [imageAssignments, setImageAssignments] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [imagePlacements, setImagePlacements] = useState<
|
||
Record<string, AgentPsdImagePlacement>
|
||
>({});
|
||
const [imageSizes, setImageSizes] = useState<
|
||
Record<string, AgentPsdImageSize>
|
||
>({});
|
||
const [textAssignments, setTextAssignments] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [textColorAssignments, setTextColorAssignments] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
|
||
const [isRendering, setIsRendering] = useState(false);
|
||
const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
|
||
const [rightPanelView, setRightPanelView] = useState<"editor" | "export">(
|
||
"editor"
|
||
);
|
||
const [stageScale, setStageScale] = useState(1);
|
||
|
||
const previewViewportRef = useRef<HTMLDivElement | null>(null);
|
||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
const inlineTextEditorRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const imageDragStateRef = useRef<{
|
||
layerId: string;
|
||
pointerId: number;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startPlacement: AgentPsdImagePlacement;
|
||
} | null>(null);
|
||
|
||
const selectedTemplate = useMemo(
|
||
() =>
|
||
templates.find((template) => template.id === selectedTemplateId) || null,
|
||
[selectedTemplateId, templates]
|
||
);
|
||
const shouldGroupTemplatesBySecondaryCategory = useMemo(
|
||
() =>
|
||
secondaryCategoryConfig.enabled &&
|
||
templates.some((template) =>
|
||
Boolean(String(template.secondaryCategory || "").trim())
|
||
),
|
||
[secondaryCategoryConfig.enabled, templates]
|
||
);
|
||
const mainModeTemplates = useMemo(
|
||
() => templates.filter((template) => template.mode === "main"),
|
||
[templates]
|
||
);
|
||
const skuModeTemplates = useMemo(
|
||
() => templates.filter((template) => template.mode === "sku"),
|
||
[templates]
|
||
);
|
||
const selectedTemplateLayers = useMemo(() => {
|
||
if (!selectedTemplate || !Array.isArray(selectedTemplate.layers)) {
|
||
return [] as AgentPsdTemplateLayerSlot[];
|
||
}
|
||
return selectedTemplate.layers;
|
||
}, [selectedTemplate]);
|
||
const selectedTemplateMode: AgentPsdTemplateMode =
|
||
selectedTemplate?.mode === "sku" ? "sku" : "main";
|
||
const selectedModeTemplates = useMemo(
|
||
() =>
|
||
selectedTemplateMode === "sku" ? skuModeTemplates : mainModeTemplates,
|
||
[mainModeTemplates, selectedTemplateMode, skuModeTemplates]
|
||
);
|
||
const groupedSelectedModeTemplates = useMemo(() => {
|
||
if (!shouldGroupTemplatesBySecondaryCategory) {
|
||
return [] as Array<{ label: string; templates: AgentPsdTemplate[] }>;
|
||
}
|
||
|
||
const groups = new Map<string, AgentPsdTemplate[]>();
|
||
for (const template of selectedModeTemplates) {
|
||
const groupLabel =
|
||
String(template.secondaryCategory || "").trim() || "未分组";
|
||
const currentGroup = groups.get(groupLabel) || [];
|
||
currentGroup.push(template);
|
||
groups.set(groupLabel, currentGroup);
|
||
}
|
||
|
||
return Array.from(groups.entries()).map(([label, grouped]) => ({
|
||
label,
|
||
templates: grouped,
|
||
}));
|
||
}, [selectedModeTemplates, shouldGroupTemplatesBySecondaryCategory]);
|
||
|
||
const editableImageLayers = useMemo(
|
||
() =>
|
||
selectedTemplateLayers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
layer.isEditable && isAgentPsdImageLayerSlot(layer)
|
||
),
|
||
[selectedTemplateLayers]
|
||
);
|
||
const editableTextLayers = useMemo(
|
||
() =>
|
||
selectedTemplateLayers.filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
layer.isEditable && isAgentPsdTextLayerSlot(layer)
|
||
),
|
||
[selectedTemplateLayers]
|
||
);
|
||
const orderedPreviewLayers = useMemo(
|
||
() => [...selectedTemplateLayers].reverse(),
|
||
[selectedTemplateLayers]
|
||
);
|
||
const orderedEditableLayers = useMemo(
|
||
() => orderedPreviewLayers.filter((layer) => layer.isEditable),
|
||
[orderedPreviewLayers]
|
||
);
|
||
const orderedEditableImageLayers = useMemo(
|
||
() =>
|
||
orderedEditableLayers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
isAgentPsdImageLayerSlot(layer)
|
||
),
|
||
[orderedEditableLayers]
|
||
);
|
||
const orderedEditableTextLayers = useMemo(
|
||
() =>
|
||
orderedEditableLayers.filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
isAgentPsdTextLayerSlot(layer)
|
||
),
|
||
[orderedEditableLayers]
|
||
);
|
||
const shouldShowCompositeFallback = useMemo(
|
||
() =>
|
||
orderedPreviewLayers.some(
|
||
(layer) => !String(layer.sourceUrl || "").trim()
|
||
),
|
||
[orderedPreviewLayers]
|
||
);
|
||
|
||
const effectiveImageAssignments = useMemo(() => {
|
||
const nextAssignments: Record<string, string> = {};
|
||
if (!selectedTemplate) {
|
||
return nextAssignments;
|
||
}
|
||
|
||
for (const layer of editableImageLayers) {
|
||
const explicit = String(imageAssignments[layer.id] || "").trim();
|
||
if (explicit) {
|
||
nextAssignments[layer.id] = explicit;
|
||
continue;
|
||
}
|
||
|
||
if (layer.isDefaultResultSource && sourceImageUrl?.trim()) {
|
||
nextAssignments[layer.id] = sourceImageUrl.trim();
|
||
}
|
||
}
|
||
|
||
return nextAssignments;
|
||
}, [editableImageLayers, imageAssignments, selectedTemplate, sourceImageUrl]);
|
||
|
||
const effectiveTextAssignments = useMemo(() => {
|
||
const nextAssignments: Record<string, string> = {};
|
||
if (!selectedTemplate) {
|
||
return nextAssignments;
|
||
}
|
||
|
||
for (const layer of editableTextLayers) {
|
||
const explicit = textAssignments[layer.id];
|
||
nextAssignments[layer.id] =
|
||
typeof explicit === "string"
|
||
? explicit
|
||
: String(layer.defaultText || "");
|
||
}
|
||
|
||
return nextAssignments;
|
||
}, [editableTextLayers, selectedTemplate, textAssignments]);
|
||
const effectiveTextColorAssignments = useMemo(() => {
|
||
const nextAssignments: Record<string, string> = {};
|
||
if (!selectedTemplate) {
|
||
return nextAssignments;
|
||
}
|
||
|
||
for (const layer of editableTextLayers) {
|
||
nextAssignments[layer.id] = getResolvedTextLayerColor(
|
||
layer,
|
||
textColorAssignments[layer.id]
|
||
);
|
||
}
|
||
|
||
return nextAssignments;
|
||
}, [editableTextLayers, selectedTemplate, textColorAssignments]);
|
||
|
||
const effectiveImagePlacements = useMemo(() => {
|
||
const nextPlacements: Record<string, AgentPsdImagePlacement> = {};
|
||
if (!selectedTemplate) {
|
||
return nextPlacements;
|
||
}
|
||
|
||
for (const layer of editableImageLayers) {
|
||
if (!String(effectiveImageAssignments[layer.id] || "").trim()) {
|
||
continue;
|
||
}
|
||
nextPlacements[layer.id] = normalizeAgentPsdImagePlacement(
|
||
imagePlacements[layer.id]
|
||
);
|
||
}
|
||
|
||
return nextPlacements;
|
||
}, [
|
||
editableImageLayers,
|
||
effectiveImageAssignments,
|
||
imagePlacements,
|
||
selectedTemplate,
|
||
]);
|
||
|
||
const selectedLayer = useMemo(
|
||
() =>
|
||
selectedTemplateLayers.find((layer) => layer.id === selectedLayerId) ||
|
||
null,
|
||
[selectedLayerId, selectedTemplateLayers]
|
||
);
|
||
const selectedImageLayer =
|
||
selectedLayer && isAgentPsdImageLayerSlot(selectedLayer)
|
||
? selectedLayer
|
||
: null;
|
||
const selectedTextLayer =
|
||
selectedLayer && isAgentPsdTextLayerSlot(selectedLayer)
|
||
? selectedLayer
|
||
: null;
|
||
const selectedImageAssignmentUrl = selectedImageLayer
|
||
? String(effectiveImageAssignments[selectedImageLayer.id] || "").trim()
|
||
: "";
|
||
const selectedImagePlacement = selectedImageLayer
|
||
? normalizeAgentPsdImagePlacement(
|
||
effectiveImagePlacements[selectedImageLayer.id]
|
||
)
|
||
: getDefaultAgentPsdImagePlacement();
|
||
const selectedTextColor = selectedTextLayer
|
||
? effectiveTextColorAssignments[selectedTextLayer.id] ||
|
||
selectedTextLayer.textColor
|
||
: "#111111";
|
||
const selectedImageAdjustmentPanel = selectedImageLayer ? (
|
||
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3.5">
|
||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
||
{sourceImageUrl ? (
|
||
<Button
|
||
className="h-11 w-full min-w-0 justify-center gap-1.5 px-3 text-[13px]"
|
||
onClick={() => {
|
||
setImageAssignments((current) => ({
|
||
...current,
|
||
[selectedImageLayer.id]: sourceImageUrl,
|
||
}));
|
||
setImagePlacements((current) => ({
|
||
...current,
|
||
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
|
||
}));
|
||
setImageSizes((current) => {
|
||
const next = { ...current };
|
||
delete next[selectedImageLayer.id];
|
||
return next;
|
||
});
|
||
}}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<span className="min-w-0 truncate">使用当前图片</span>
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
className="h-11 w-full min-w-0 justify-center gap-1.5 px-3 text-[13px]"
|
||
disabled={uploadingSlotId === selectedImageLayer.id}
|
||
onClick={() => openImageUpload(selectedImageLayer.id)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{uploadingSlotId === selectedImageLayer.id ? (
|
||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||
) : (
|
||
<Upload className="h-4 w-4 shrink-0" />
|
||
)}
|
||
<span className="min-w-0 truncate">上传替换图片</span>
|
||
</Button>
|
||
</div>
|
||
|
||
{selectedImageAssignmentUrl ? (
|
||
<>
|
||
<div className="flex justify-end">
|
||
<Button
|
||
className="px-2 text-white/70 hover:text-white"
|
||
onClick={() => {
|
||
setImageAssignments((current) => {
|
||
const nextAssignments = { ...current };
|
||
delete nextAssignments[selectedImageLayer.id];
|
||
return nextAssignments;
|
||
});
|
||
setImagePlacements((current) => {
|
||
const next = { ...current };
|
||
delete next[selectedImageLayer.id];
|
||
return next;
|
||
});
|
||
setImageSizes((current) => {
|
||
const next = { ...current };
|
||
delete next[selectedImageLayer.id];
|
||
return next;
|
||
});
|
||
}}
|
||
size="sm"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
清空当前替换图
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="h-px bg-white/10" />
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between text-muted-foreground text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<ZoomIn className="h-3.5 w-3.5" />
|
||
缩放
|
||
</div>
|
||
<span className="rounded-full border border-emerald-300/35 bg-emerald-500/15 px-2 py-0.5 font-medium text-emerald-100">
|
||
{Math.round(selectedImagePlacement.scale * 100)}%
|
||
</span>
|
||
</div>
|
||
<div className="rounded-xl border border-white/12 bg-black/45 p-2.5">
|
||
<div className="grid grid-cols-[34px_minmax(0,1fr)_34px] items-center gap-2">
|
||
<button
|
||
aria-label="缩小图片"
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
|
||
onClick={() => nudgeImageScale(selectedImageLayer.id, -0.05)}
|
||
title="缩小"
|
||
type="button"
|
||
>
|
||
<ZoomOut className="h-4 w-4" />
|
||
</button>
|
||
<Slider
|
||
className="py-1"
|
||
max={MAX_IMAGE_SCALE}
|
||
min={MIN_IMAGE_SCALE}
|
||
onValueChange={(value) => {
|
||
const newScale = clampImageScale(value[0] || 1);
|
||
updateImagePlacement(selectedImageLayer.id, (current) => ({
|
||
...current,
|
||
scale: newScale,
|
||
}));
|
||
}}
|
||
rangeClassName="bg-gradient-to-r from-emerald-300 via-green-400 to-teal-400 shadow-[0_0_12px_rgba(16,185,129,0.38)]"
|
||
step={0.01}
|
||
thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]"
|
||
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
|
||
value={[selectedImagePlacement.scale]}
|
||
/>
|
||
<button
|
||
aria-label="放大图片"
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
|
||
onClick={() => nudgeImageScale(selectedImageLayer.id, 0.05)}
|
||
title="放大"
|
||
type="button"
|
||
>
|
||
<ZoomIn className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="mt-2 flex items-center justify-between px-0.5 text-[10px] text-white/45">
|
||
<span>40%</span>
|
||
<span className="text-emerald-200">100%</span>
|
||
<span>300%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end">
|
||
<Button
|
||
className="px-2 text-white/70 hover:text-white"
|
||
onClick={() => resetImagePlacement(selectedImageLayer.id)}
|
||
size="sm"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
重置位置与缩放
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null;
|
||
const fontFaceCss = useMemo(
|
||
() => buildPreviewFontFaces(selectedTemplate),
|
||
[selectedTemplate]
|
||
);
|
||
const selectedTemplateSelectValue = selectedTemplate?.id;
|
||
const hasTemplateOptions = templates.length > 0;
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
return;
|
||
}
|
||
|
||
setSelectedTemplateId("");
|
||
setSelectedLayerId("");
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
|
||
if (templates.length === 0) {
|
||
if (selectedTemplateId) {
|
||
setSelectedTemplateId("");
|
||
}
|
||
setSelectedLayerId("");
|
||
return;
|
||
}
|
||
|
||
if (!templates.some((template) => template.id === selectedTemplateId)) {
|
||
setSelectedTemplateId("");
|
||
}
|
||
}, [open, selectedTemplateId, templates]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setRenderedResultUrl("");
|
||
setRightPanelView("editor");
|
||
const initialState = createInitialState(
|
||
selectedTemplate,
|
||
sourceImageUrl || ""
|
||
);
|
||
setImageAssignments(initialState.imageAssignments);
|
||
setImagePlacements(initialState.imagePlacements);
|
||
setTextAssignments(initialState.textAssignments);
|
||
setTextColorAssignments({});
|
||
setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate));
|
||
|
||
if (!selectedTemplate || typeof window === "undefined") {
|
||
return;
|
||
}
|
||
|
||
// 自动适配缩放比例(延迟执行确保 DOM 已渲染)
|
||
const timeoutId = window.setTimeout(() => {
|
||
if (previewViewportRef.current) {
|
||
const viewport = previewViewportRef.current;
|
||
const padding = 32;
|
||
const availableWidth = viewport.clientWidth - padding;
|
||
const availableHeight = viewport.clientHeight - padding;
|
||
|
||
if (availableWidth > 0 && availableHeight > 0) {
|
||
const scaleX = availableWidth / selectedTemplate.width;
|
||
const scaleY = availableHeight / selectedTemplate.height;
|
||
const fitScale = Math.min(scaleX, scaleY, 1);
|
||
setStageScale(fitScale);
|
||
}
|
||
}
|
||
}, 100);
|
||
|
||
return () => {
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [open, selectedTemplate?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedTemplate) {
|
||
setImageSizes((current) => {
|
||
if (Object.keys(current).length === 0) {
|
||
return current;
|
||
}
|
||
return {};
|
||
});
|
||
return;
|
||
}
|
||
if (typeof window === "undefined" || typeof window.Image !== "function") {
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
for (const layer of editableImageLayers) {
|
||
const assignedUrl = String(
|
||
effectiveImageAssignments[layer.id] || ""
|
||
).trim();
|
||
if (!assignedUrl) {
|
||
continue;
|
||
}
|
||
|
||
const currentSize = imageSizes[layer.id];
|
||
if (currentSize && currentSize.width > 0 && currentSize.height > 0) {
|
||
continue;
|
||
}
|
||
|
||
const image = new window.Image();
|
||
image.onload = () => {
|
||
if (cancelled) return;
|
||
const nextWidth = Math.max(1, image.naturalWidth || layer.width);
|
||
const nextHeight = Math.max(1, image.naturalHeight || layer.height);
|
||
setImageSizes((current) => {
|
||
if (
|
||
current[layer.id]?.width === nextWidth &&
|
||
current[layer.id]?.height === nextHeight
|
||
) {
|
||
return current;
|
||
}
|
||
|
||
return {
|
||
...current,
|
||
[layer.id]: {
|
||
width: nextWidth,
|
||
height: nextHeight,
|
||
},
|
||
};
|
||
});
|
||
};
|
||
image.src = assignedUrl;
|
||
}
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [
|
||
editableImageLayers,
|
||
effectiveImageAssignments,
|
||
imageSizes,
|
||
selectedTemplate,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedTemplate) {
|
||
setStageScale(1);
|
||
return;
|
||
}
|
||
|
||
const element = previewViewportRef.current;
|
||
if (!element) return;
|
||
|
||
const updateScale = () => {
|
||
const rect = element.getBoundingClientRect();
|
||
const availableWidth = Math.max(
|
||
240,
|
||
Math.min(
|
||
PREVIEW_STAGE_TARGET_WIDTH,
|
||
rect.width - PREVIEW_VIEWPORT_PADDING
|
||
)
|
||
);
|
||
const availableHeight = Math.max(
|
||
240,
|
||
Math.min(
|
||
PREVIEW_STAGE_TARGET_HEIGHT,
|
||
rect.height - PREVIEW_VIEWPORT_PADDING
|
||
)
|
||
);
|
||
const nextScale = Math.min(
|
||
availableWidth / Math.max(1, selectedTemplate.width),
|
||
availableHeight / Math.max(1, selectedTemplate.height),
|
||
PREVIEW_STAGE_MAX_SCALE
|
||
);
|
||
setStageScale(
|
||
Number.isFinite(nextScale) && nextScale > 0 ? nextScale : 1
|
||
);
|
||
};
|
||
|
||
updateScale();
|
||
let animationFrameId: number | null = null;
|
||
const scheduleScaleUpdate = () => {
|
||
if (
|
||
typeof window !== "undefined" &&
|
||
typeof window.requestAnimationFrame === "function"
|
||
) {
|
||
if (animationFrameId !== null) {
|
||
window.cancelAnimationFrame(animationFrameId);
|
||
}
|
||
animationFrameId = window.requestAnimationFrame(() => {
|
||
animationFrameId = null;
|
||
updateScale();
|
||
});
|
||
return;
|
||
}
|
||
|
||
updateScale();
|
||
};
|
||
|
||
if (typeof ResizeObserver !== "undefined") {
|
||
const observer = new ResizeObserver(scheduleScaleUpdate);
|
||
observer.observe(element);
|
||
return () => {
|
||
observer.disconnect();
|
||
if (animationFrameId !== null && typeof window !== "undefined") {
|
||
window.cancelAnimationFrame(animationFrameId);
|
||
}
|
||
};
|
||
}
|
||
|
||
if (typeof window !== "undefined") {
|
||
window.addEventListener("resize", scheduleScaleUpdate);
|
||
}
|
||
|
||
return () => {
|
||
if (typeof window !== "undefined") {
|
||
window.removeEventListener("resize", scheduleScaleUpdate);
|
||
if (animationFrameId !== null) {
|
||
window.cancelAnimationFrame(animationFrameId);
|
||
}
|
||
}
|
||
};
|
||
}, [selectedTemplate]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedTextLayer) {
|
||
return;
|
||
}
|
||
|
||
const textarea = inlineTextEditorRef.current;
|
||
if (!textarea) {
|
||
return;
|
||
}
|
||
|
||
const focusTextarea = () => {
|
||
textarea.focus();
|
||
const valueLength = textarea.value.length;
|
||
try {
|
||
textarea.setSelectionRange(valueLength, valueLength);
|
||
} catch (error) {
|
||
console.warn(
|
||
"[AgentPsdTemplateEditorDialog] setSelectionRange failed:",
|
||
error
|
||
);
|
||
}
|
||
};
|
||
|
||
if (
|
||
typeof window !== "undefined" &&
|
||
typeof window.requestAnimationFrame === "function"
|
||
) {
|
||
const frameId = window.requestAnimationFrame(focusTextarea);
|
||
return () => {
|
||
window.cancelAnimationFrame(frameId);
|
||
};
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(focusTextarea, 0);
|
||
return () => {
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [selectedTextLayer?.id]);
|
||
|
||
async function handleSlotUpload(
|
||
slotId: string,
|
||
event: ChangeEvent<HTMLInputElement>
|
||
) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
try {
|
||
setUploadingSlotId(slotId);
|
||
const formData = new FormData();
|
||
formData.set("file", file);
|
||
const response = await fetchUploadWithRetry(formData);
|
||
const payload = await response.json();
|
||
|
||
if (!response.ok || !payload.url) {
|
||
throw new Error(payload.error || "图片上传失败");
|
||
}
|
||
|
||
setImageAssignments((current) => ({
|
||
...current,
|
||
[slotId]: payload.url,
|
||
}));
|
||
setImagePlacements((current) => ({
|
||
...current,
|
||
[slotId]: getDefaultAgentPsdImagePlacement(),
|
||
}));
|
||
setImageSizes((current) => {
|
||
const next = { ...current };
|
||
delete next[slotId];
|
||
return next;
|
||
});
|
||
toast.success("替换图片已上传");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "素材上传失败");
|
||
} finally {
|
||
setUploadingSlotId(null);
|
||
event.target.value = "";
|
||
}
|
||
}
|
||
|
||
function openImageUpload(slotId: string) {
|
||
setSelectedLayerId(slotId);
|
||
if (uploadInputRef.current) {
|
||
uploadInputRef.current.value = "";
|
||
uploadInputRef.current.click();
|
||
}
|
||
}
|
||
|
||
function updateImagePlacement(
|
||
layerId: string,
|
||
updater: (current: AgentPsdImagePlacement) => AgentPsdImagePlacement
|
||
) {
|
||
setImagePlacements((current) => ({
|
||
...current,
|
||
[layerId]: normalizeAgentPsdImagePlacement(
|
||
updater(normalizeAgentPsdImagePlacement(current[layerId]))
|
||
),
|
||
}));
|
||
}
|
||
|
||
function nudgeImageScale(layerId: string, delta: number) {
|
||
updateImagePlacement(layerId, (current) => ({
|
||
...current,
|
||
scale: Number(clampImageScale(current.scale + delta).toFixed(2)),
|
||
}));
|
||
}
|
||
|
||
function resetImagePlacement(layerId: string) {
|
||
setImagePlacements((current) => ({
|
||
...current,
|
||
[layerId]: getDefaultAgentPsdImagePlacement(),
|
||
}));
|
||
}
|
||
|
||
function handleImageDragStart(
|
||
layer: AgentPsdTemplateImageLayerSlot,
|
||
event: ReactPointerEvent<HTMLDivElement>
|
||
) {
|
||
if (!String(effectiveImageAssignments[layer.id] || "").trim()) {
|
||
return;
|
||
}
|
||
|
||
imageDragStateRef.current = {
|
||
layerId: layer.id,
|
||
pointerId: event.pointerId,
|
||
startClientX: event.clientX,
|
||
startClientY: event.clientY,
|
||
startPlacement: normalizeAgentPsdImagePlacement(
|
||
effectiveImagePlacements[layer.id]
|
||
),
|
||
};
|
||
|
||
const nextTarget = event.currentTarget;
|
||
try {
|
||
nextTarget.setPointerCapture?.(event.pointerId);
|
||
} catch (error) {
|
||
console.warn(
|
||
"[AgentPsdTemplateEditorDialog] setPointerCapture failed:",
|
||
error
|
||
);
|
||
}
|
||
|
||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||
const dragState = imageDragStateRef.current;
|
||
if (!dragState || dragState.layerId !== layer.id) {
|
||
return;
|
||
}
|
||
|
||
updateImagePlacement(layer.id, () => ({
|
||
...dragState.startPlacement,
|
||
offsetX:
|
||
dragState.startPlacement.offsetX +
|
||
(moveEvent.clientX - dragState.startClientX) / stageScale,
|
||
offsetY:
|
||
dragState.startPlacement.offsetY +
|
||
(moveEvent.clientY - dragState.startClientY) / stageScale,
|
||
}));
|
||
};
|
||
|
||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||
if (imageDragStateRef.current?.pointerId === upEvent.pointerId) {
|
||
imageDragStateRef.current = null;
|
||
}
|
||
window.removeEventListener("pointermove", handlePointerMove);
|
||
window.removeEventListener("pointerup", handlePointerUp);
|
||
window.removeEventListener("pointercancel", handlePointerUp);
|
||
};
|
||
|
||
window.addEventListener("pointermove", handlePointerMove);
|
||
window.addEventListener("pointerup", handlePointerUp);
|
||
window.addEventListener("pointercancel", handlePointerUp);
|
||
}
|
||
|
||
async function handleRender() {
|
||
if (!selectedTemplate) {
|
||
toast.error("请先选择 PSD 模板");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsRendering(true);
|
||
const response = await fetch("/api/psd-templates/render", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
templateId: selectedTemplate.id,
|
||
sourceImageUrl: sourceImageUrl || "",
|
||
imageAssignments: effectiveImageAssignments,
|
||
imagePlacements: effectiveImagePlacements,
|
||
textAssignments: effectiveTextAssignments,
|
||
textColorAssignments: effectiveTextColorAssignments,
|
||
}),
|
||
});
|
||
|
||
const payload = (await response.json()) as AgentPsdTemplateRenderResponse;
|
||
if (!response.ok || !payload.success || !payload.result?.url) {
|
||
throw new Error(payload.error || "PSD 模板渲染失败");
|
||
}
|
||
|
||
setRenderedResultUrl(payload.result.url);
|
||
setRightPanelView("export");
|
||
toast.success("模板替换结果已生成");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
|
||
} finally {
|
||
setIsRendering(false);
|
||
}
|
||
}
|
||
|
||
function handleSwitchSelectedTemplateMode(nextMode: AgentPsdTemplateMode) {
|
||
if (selectedTemplateMode === nextMode) {
|
||
return;
|
||
}
|
||
|
||
const nextTemplates =
|
||
nextMode === "sku" ? skuModeTemplates : mainModeTemplates;
|
||
if (nextTemplates.length === 0) {
|
||
toast.error(
|
||
nextMode === "sku"
|
||
? "当前品牌下暂无 SKU 图模式模板"
|
||
: "当前品牌下暂无主图模式模板"
|
||
);
|
||
return;
|
||
}
|
||
|
||
setSelectedTemplateId(nextTemplates[0].id);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<style
|
||
dangerouslySetInnerHTML={{
|
||
__html: `
|
||
.psd-preview-viewport::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
.psd-preview-viewport::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
.psd-preview-viewport::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 4px;
|
||
}
|
||
.psd-preview-viewport::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
}
|
||
`,
|
||
}}
|
||
/>
|
||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||
<DialogContent
|
||
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden border border-white/10 bg-[#09090b] p-0 text-white shadow-[0_30px_120px_rgba(0,0,0,0.65)]"
|
||
overlayClassName="bg-black/85 backdrop-blur-sm"
|
||
>
|
||
{fontFaceCss ? <style>{fontFaceCss}</style> : null}
|
||
|
||
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[300px_minmax(0,1fr)_380px]">
|
||
<div className="min-h-0 border-white/10 border-b bg-[#09090b] p-5 xl:border-r xl:border-b-0">
|
||
<ScrollArea className="h-full pr-4">
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>
|
||
{selectedTemplate ? "选择模板" : "主图模式模板"}
|
||
</Label>
|
||
{selectedTemplate && hasTemplateOptions ? (
|
||
<div className="space-y-2.5">
|
||
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.03] p-1">
|
||
<button
|
||
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
|
||
selectedTemplateMode === "main"
|
||
? "bg-emerald-500/[0.16] text-emerald-100"
|
||
: "text-white/60 hover:text-white"
|
||
}`}
|
||
onClick={() =>
|
||
handleSwitchSelectedTemplateMode("main")
|
||
}
|
||
type="button"
|
||
>
|
||
主图模式
|
||
</button>
|
||
<button
|
||
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
|
||
selectedTemplateMode === "sku"
|
||
? "bg-emerald-500/[0.16] text-emerald-100"
|
||
: "text-white/60 hover:text-white"
|
||
}`}
|
||
onClick={() =>
|
||
handleSwitchSelectedTemplateMode("sku")
|
||
}
|
||
type="button"
|
||
>
|
||
SKU图模式
|
||
</button>
|
||
</div>
|
||
|
||
<Select
|
||
onValueChange={setSelectedTemplateId}
|
||
value={selectedTemplateSelectValue}
|
||
>
|
||
<SelectTrigger className="h-auto min-h-[58px] items-center px-2.5 py-2 [&>span]:contents">
|
||
{selectedTemplate ? (
|
||
<div className="min-w-0 flex-1 pr-3">
|
||
{renderTemplateSelectContent(selectedTemplate, {
|
||
showSecondaryCategory:
|
||
shouldGroupTemplatesBySecondaryCategory,
|
||
})}
|
||
</div>
|
||
) : (
|
||
<SelectValue
|
||
placeholder={
|
||
isLoading ? "加载模板中..." : "请选择模板"
|
||
}
|
||
/>
|
||
)}
|
||
</SelectTrigger>
|
||
<SelectContent
|
||
className="border-white/10 bg-[#101114] text-white shadow-2xl"
|
||
style={{
|
||
backgroundColor: "#101114",
|
||
color: "#ffffff",
|
||
}}
|
||
>
|
||
{shouldGroupTemplatesBySecondaryCategory
|
||
? groupedSelectedModeTemplates.map((group) => (
|
||
<SelectGroup
|
||
key={`psd-template-group-${group.label}`}
|
||
>
|
||
<SelectLabel className="text-white/50">
|
||
{group.label}
|
||
</SelectLabel>
|
||
{group.templates.map((template) => (
|
||
<SelectItem
|
||
className="py-2.5 pr-3 text-white focus:bg-white/10 focus:text-white data-[state=checked]:bg-white/10 data-[state=checked]:text-white"
|
||
key={template.id}
|
||
value={template.id}
|
||
>
|
||
{renderTemplateSelectContent(template, {
|
||
showSecondaryCategory:
|
||
shouldGroupTemplatesBySecondaryCategory,
|
||
compact: true,
|
||
})}
|
||
</SelectItem>
|
||
))}
|
||
</SelectGroup>
|
||
))
|
||
: selectedModeTemplates.map((template) => (
|
||
<SelectItem
|
||
className="py-2.5 pr-3 text-white focus:bg-white/10 focus:text-white data-[state=checked]:bg-white/10 data-[state=checked]:text-white"
|
||
key={template.id}
|
||
value={template.id}
|
||
>
|
||
{renderTemplateSelectContent(template, {
|
||
compact: true,
|
||
})}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{isLoading ? (
|
||
<>
|
||
{Array.from({ length: 2 }).map((_, index) => (
|
||
<div
|
||
className="rounded-2xl border border-white/15 border-dashed bg-white/[0.02] px-4 py-4 text-muted-foreground text-sm"
|
||
key={`main-template-loading-${index}`}
|
||
>
|
||
正在加载主图模式模板...
|
||
</div>
|
||
))}
|
||
</>
|
||
) : mainModeTemplates.length > 0 ? (
|
||
mainModeTemplates.map((template) => (
|
||
<button
|
||
className={`w-full rounded-2xl border p-3 text-left transition ${
|
||
selectedTemplateId === template.id
|
||
? "border-emerald-400/45 bg-emerald-500/10"
|
||
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"
|
||
}`}
|
||
key={template.id}
|
||
onClick={() => setSelectedTemplateId(template.id)}
|
||
type="button"
|
||
>
|
||
{renderTemplatePickerCardContent(template, {
|
||
showSecondaryCategory:
|
||
shouldGroupTemplatesBySecondaryCategory,
|
||
emphasize: true,
|
||
})}
|
||
</button>
|
||
))
|
||
) : (
|
||
<div className="rounded-xl border border-white/15 border-dashed bg-white/[0.02] px-3 py-3 text-muted-foreground text-sm">
|
||
{normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下暂无主图模式模板`
|
||
: "请先在工作流中选择品牌后再打开 PSD 模板。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedTemplate ? (
|
||
<>
|
||
<div className="mx-auto w-full max-w-[220px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
|
||
<div
|
||
style={{
|
||
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
|
||
}}
|
||
>
|
||
<img
|
||
alt={selectedTemplate.name}
|
||
className="h-full w-full object-contain"
|
||
src={selectedTemplate.previewUrl}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
className="w-full bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
|
||
disabled={isRendering}
|
||
onClick={handleRender}
|
||
>
|
||
{isRendering ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-4 w-4" />
|
||
)}
|
||
应用模板并导出
|
||
</Button>
|
||
|
||
{selectedImageAdjustmentPanel}
|
||
</>
|
||
) : (
|
||
<div className="rounded-2xl border border-white/15 border-dashed p-5 text-muted-foreground text-sm">
|
||
{isLoading
|
||
? "正在加载可用模板..."
|
||
: hasTemplateOptions
|
||
? "请从左侧主图模式模板或右侧 SKU 图模式模板中选择一个模板开始编辑。"
|
||
: normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
|
||
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-col border-white/10 border-b bg-[#09090b] p-5 xl:border-r xl:border-b-0">
|
||
<div
|
||
className="psd-preview-viewport relative flex min-h-0 flex-1 items-center justify-center overflow-auto rounded-2xl border border-white/10 bg-[#111114] p-4"
|
||
ref={previewViewportRef}
|
||
>
|
||
{selectedTemplate ? (
|
||
<div
|
||
className="relative shrink-0"
|
||
style={{
|
||
width: selectedTemplate.width * stageScale,
|
||
height: selectedTemplate.height * stageScale,
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute top-0 left-0 overflow-hidden rounded-[20px] shadow-2xl"
|
||
style={{
|
||
width: selectedTemplate.width,
|
||
height: selectedTemplate.height,
|
||
transform: `scale(${stageScale})`,
|
||
transformOrigin: "top left",
|
||
}}
|
||
>
|
||
{shouldShowCompositeFallback ? (
|
||
<img
|
||
alt={`${selectedTemplate.name} 编辑底图`}
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
src={
|
||
selectedTemplate.renderBaseUrl ||
|
||
selectedTemplate.previewUrl
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
<div className="pointer-events-none absolute inset-0">
|
||
{orderedPreviewLayers.map((layer) => {
|
||
const layerBoxStyle = {
|
||
left: layer.x,
|
||
top: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
} satisfies CSSProperties;
|
||
|
||
if (isAgentPsdImageLayerSlot(layer)) {
|
||
const assignedUrl =
|
||
effectiveImageAssignments[layer.id];
|
||
const layerImageUrl =
|
||
assignedUrl || layer.sourceUrl;
|
||
if (!layerImageUrl) {
|
||
return null;
|
||
}
|
||
const assignedImageContainerStyle = assignedUrl
|
||
? {
|
||
...layerBoxStyle,
|
||
...getAssignedImageMaskStyle(layer),
|
||
}
|
||
: layerBoxStyle;
|
||
|
||
return (
|
||
<div
|
||
className="absolute overflow-hidden"
|
||
key={layer.id}
|
||
style={assignedImageContainerStyle}
|
||
>
|
||
<img
|
||
alt={layer.layerName}
|
||
className={assignedUrl ? "" : "h-full w-full"}
|
||
src={layerImageUrl}
|
||
style={
|
||
assignedUrl && imageSizes[layer.id]
|
||
? getPlacedAssignedImageStyle({
|
||
layer,
|
||
imageSize: imageSizes[layer.id],
|
||
placement:
|
||
effectiveImagePlacements[layer.id],
|
||
})
|
||
: assignedUrl
|
||
? {
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: getImageFit(
|
||
layer.replaceMode
|
||
),
|
||
}
|
||
: {
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: "fill",
|
||
}
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isSelected = selectedLayerId === layer.id;
|
||
if (isSelected) {
|
||
return null;
|
||
}
|
||
|
||
const value =
|
||
effectiveTextAssignments[layer.id] || "";
|
||
const resolvedTextColor =
|
||
effectiveTextColorAssignments[layer.id] ||
|
||
layer.textColor;
|
||
const shouldUseEditedPreview =
|
||
hasCustomizedTextLayerAppearance(
|
||
layer,
|
||
value,
|
||
resolvedTextColor
|
||
);
|
||
|
||
if (!shouldUseEditedPreview && layer.sourceUrl) {
|
||
return (
|
||
<div
|
||
className="absolute overflow-hidden"
|
||
key={layer.id}
|
||
style={layerBoxStyle}
|
||
>
|
||
<img
|
||
alt={layer.layerName}
|
||
className="h-full w-full object-fill"
|
||
src={layer.sourceUrl}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!value && !layer.placeholder) {
|
||
return null;
|
||
}
|
||
|
||
const textFrame = getTextLayerRenderFrame(
|
||
layer,
|
||
value || layer.placeholder || layer.layerName
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className="absolute overflow-visible"
|
||
key={layer.id}
|
||
style={{
|
||
left: layer.x + textFrame.offsetX,
|
||
top: layer.y + textFrame.offsetY,
|
||
width: textFrame.width,
|
||
height: textFrame.height,
|
||
}}
|
||
>
|
||
<div
|
||
className="whitespace-pre"
|
||
style={getTextLayerContentStyle(
|
||
layer,
|
||
value || layer.placeholder || layer.layerName,
|
||
resolvedTextColor
|
||
)}
|
||
>
|
||
{value || layer.placeholder || layer.layerName}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="absolute inset-0">
|
||
{orderedEditableImageLayers.map((layer) => {
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const frameStyle = getLayerFrameStyle(
|
||
layer,
|
||
isSelected
|
||
);
|
||
const hasAssignedImage = Boolean(
|
||
String(
|
||
effectiveImageAssignments[layer.id] || ""
|
||
).trim()
|
||
);
|
||
|
||
return isSelected && hasAssignedImage ? (
|
||
<div
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
className="absolute overflow-hidden rounded-lg transition-all"
|
||
key={layer.id}
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
onPointerDown={(event) =>
|
||
handleImageDragStart(layer, event)
|
||
}
|
||
role="button"
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: 20,
|
||
background: "rgba(249,115,22,0.08)",
|
||
cursor:
|
||
imageDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: "none",
|
||
}}
|
||
tabIndex={0}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
>
|
||
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
|
||
{layer.layerName}
|
||
</div>
|
||
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-orange-500/85 px-2 py-1 text-[11px] text-white">
|
||
<Move className="h-3 w-3" />
|
||
拖动调整
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<button
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
className="absolute overflow-hidden rounded-lg transition-all"
|
||
key={layer.id}
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: isSelected ? 20 : 10,
|
||
background: isSelected
|
||
? "rgba(249,115,22,0.08)"
|
||
: "transparent",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
type="button"
|
||
>
|
||
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
|
||
{layer.layerName}
|
||
</div>
|
||
|
||
{hasAssignedImage ? null : (
|
||
<div className="pointer-events-none absolute right-2 bottom-2 rounded-full bg-orange-500/85 px-2 py-1 text-[11px] text-white">
|
||
点击替换图片
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
{orderedEditableImageLayers.map((layer) => {
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const hasAssignedImage = Boolean(
|
||
String(
|
||
effectiveImageAssignments[layer.id] || ""
|
||
).trim()
|
||
);
|
||
if (!isSelected || !hasAssignedImage) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="absolute flex items-center gap-1 rounded-full bg-black/72 p-1 text-white shadow-lg"
|
||
key={`${layer.id}-controls`}
|
||
style={{
|
||
left: layer.x + layer.width - 112,
|
||
top: Math.max(8, layer.y - 40),
|
||
zIndex: 25,
|
||
}}
|
||
>
|
||
<button
|
||
aria-label="缩小图片"
|
||
className="rounded-full p-1.5 transition hover:bg-white/10"
|
||
onClick={() => nudgeImageScale(layer.id, -0.1)}
|
||
title="缩小"
|
||
type="button"
|
||
>
|
||
<ZoomOut className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
aria-label="放大图片"
|
||
className="rounded-full p-1.5 transition hover:bg-white/10"
|
||
onClick={() => nudgeImageScale(layer.id, 0.1)}
|
||
title="放大"
|
||
type="button"
|
||
>
|
||
<ZoomIn className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
aria-label="重置图片位置和缩放"
|
||
className="rounded-full p-1.5 transition hover:bg-white/10"
|
||
onClick={() => resetImagePlacement(layer.id)}
|
||
title="重置"
|
||
type="button"
|
||
>
|
||
<RefreshCcw className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{orderedEditableTextLayers.map((layer) => {
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const frameStyle = getLayerFrameStyle(
|
||
layer,
|
||
isSelected
|
||
);
|
||
const currentValue =
|
||
effectiveTextAssignments[layer.id] || "";
|
||
const resolvedTextColor =
|
||
effectiveTextColorAssignments[layer.id] ||
|
||
layer.textColor;
|
||
const preserveSourceAppearance =
|
||
!!layer.sourceUrl &&
|
||
!hasCustomizedTextLayerAppearance(
|
||
layer,
|
||
currentValue,
|
||
resolvedTextColor
|
||
);
|
||
const editedTextFrame = getTextLayerRenderFrame(
|
||
layer,
|
||
currentValue
|
||
);
|
||
|
||
if (isSelected) {
|
||
return (
|
||
<div
|
||
className="absolute overflow-visible rounded-lg"
|
||
key={layer.id}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: 40,
|
||
background: preserveSourceAppearance
|
||
? "transparent"
|
||
: "rgba(56,189,248,0.08)",
|
||
}}
|
||
>
|
||
{preserveSourceAppearance && layer.sourceUrl ? (
|
||
<img
|
||
alt={layer.layerName}
|
||
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
|
||
src={layer.sourceUrl}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className="absolute"
|
||
style={
|
||
preserveSourceAppearance
|
||
? { inset: 0 }
|
||
: {
|
||
left: editedTextFrame.offsetX,
|
||
top: editedTextFrame.offsetY,
|
||
width: editedTextFrame.width,
|
||
height: editedTextFrame.height,
|
||
}
|
||
}
|
||
>
|
||
<textarea
|
||
className="resize-none border-none bg-transparent outline-none"
|
||
onChange={(event) =>
|
||
setTextAssignments((current) => ({
|
||
...current,
|
||
[layer.id]: event.target.value,
|
||
}))
|
||
}
|
||
ref={inlineTextEditorRef}
|
||
style={getInlineTextEditorStyle(
|
||
layer,
|
||
preserveSourceAppearance,
|
||
currentValue,
|
||
resolvedTextColor
|
||
)}
|
||
value={currentValue}
|
||
wrap="off"
|
||
/>
|
||
</div>
|
||
<div
|
||
className={getTextLayerNameBadgeClassName()}
|
||
>
|
||
{layer.layerName}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
className="absolute overflow-visible rounded-lg transition-all"
|
||
key={layer.id}
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
onDoubleClick={() => setSelectedLayerId(layer.id)}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: isSelected ? 40 : 30,
|
||
background: isSelected
|
||
? "rgba(56,189,248,0.05)"
|
||
: "transparent",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
type="button"
|
||
>
|
||
<div className={getTextLayerNameBadgeClassName()}>
|
||
{layer.layerName}
|
||
</div>
|
||
|
||
<div className={getTextLayerHintBadgeClassName()}>
|
||
点击编辑文字
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-full w-full flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-5">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-semibold text-sm text-white">
|
||
SKU图模式模板
|
||
</div>
|
||
<div className="mt-1 text-white/45 text-xs">
|
||
{normalizedTemplateMode === "sku"
|
||
? "当前结果属于 SKU 图模式,可直接从这里选择并进入编辑。"
|
||
: "这里集中展示当前品牌下的 SKU 图模式模板。"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-white/65 text-xs">
|
||
{skuModeTemplates.length} 个模板
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{Array.from({ length: 4 }).map((_, index) => (
|
||
<div
|
||
className="rounded-2xl border border-white/15 border-dashed bg-white/[0.02] px-4 py-5 text-muted-foreground text-sm"
|
||
key={`sku-template-loading-${index}`}
|
||
>
|
||
正在加载 SKU 图模式模板...
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : skuModeTemplates.length > 0 ? (
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||
{skuModeTemplates.map((template) => (
|
||
<button
|
||
className={`rounded-2xl border p-3 text-left transition ${
|
||
selectedTemplateId === template.id
|
||
? "border-emerald-400/45 bg-emerald-500/10"
|
||
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"
|
||
}`}
|
||
key={template.id}
|
||
onClick={() => setSelectedTemplateId(template.id)}
|
||
type="button"
|
||
>
|
||
{renderTemplatePickerCardContent(template, {
|
||
showSecondaryCategory:
|
||
shouldGroupTemplatesBySecondaryCategory,
|
||
emphasize: true,
|
||
})}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
|
||
{normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下暂无 SKU 图模式模板`
|
||
: "请先返回工作流区域选择品牌,再打开 PSD 模板。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-col bg-[#09090b] p-4 xl:p-5">
|
||
{renderedResultUrl ? (
|
||
<div className="mb-3 flex items-center justify-end gap-3">
|
||
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.03] p-1">
|
||
<button
|
||
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
|
||
rightPanelView === "editor"
|
||
? "bg-emerald-500/[0.16] text-emerald-100"
|
||
: "text-white/60 hover:text-white"
|
||
}`}
|
||
onClick={() => setRightPanelView("editor")}
|
||
type="button"
|
||
>
|
||
编辑面板
|
||
</button>
|
||
<button
|
||
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
|
||
rightPanelView === "export"
|
||
? "bg-emerald-500/[0.16] text-emerald-100"
|
||
: "text-white/60 hover:text-white"
|
||
}`}
|
||
onClick={() => setRightPanelView("export")}
|
||
type="button"
|
||
>
|
||
导出结果
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Input
|
||
accept="image/png,image/jpeg,image/webp"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
if (!selectedImageLayer) return;
|
||
void handleSlotUpload(selectedImageLayer.id, event);
|
||
}}
|
||
ref={uploadInputRef}
|
||
type="file"
|
||
/>
|
||
|
||
{renderedResultUrl && rightPanelView === "export" ? (
|
||
<AgentPsdTemplateExportPreview
|
||
downloadName={`${selectedTemplate?.name || "PSD模板"}-${selectedTemplateMode === "sku" ? "SKU图" : "主图"}.png`}
|
||
enableDimensionAnnotations={selectedTemplateMode === "sku"}
|
||
imageAlt="PSD 模板导出结果"
|
||
imageUrl={renderedResultUrl}
|
||
key={`${selectedTemplate?.id || "template"}-${renderedResultUrl}`}
|
||
sizeInfoText={sizeInfoText}
|
||
/>
|
||
) : selectedTextLayer ? (
|
||
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
|
||
<div className="flex h-full flex-col gap-3">
|
||
<Textarea
|
||
className="min-h-[180px] flex-1 resize-none xl:min-h-[200px]"
|
||
onChange={(event) =>
|
||
setTextAssignments((current) => ({
|
||
...current,
|
||
[selectedTextLayer.id]: event.target.value,
|
||
}))
|
||
}
|
||
placeholder={
|
||
selectedTextLayer.placeholder || "请输入文字"
|
||
}
|
||
rows={4}
|
||
value={
|
||
effectiveTextAssignments[selectedTextLayer.id] || ""
|
||
}
|
||
/>
|
||
<div className="flex flex-wrap gap-2 text-muted-foreground text-xs">
|
||
<span>
|
||
字体:
|
||
{selectedTextLayer.fontLabel ||
|
||
selectedTextLayer.fontFamily ||
|
||
"系统字体"}
|
||
</span>
|
||
<span>字号:{selectedTextLayer.fontSize}px</span>
|
||
<span>行高:{selectedTextLayer.lineHeight}</span>
|
||
<span>字距:{selectedTextLayer.letterSpacing}px</span>
|
||
</div>
|
||
{textColorConfig.enabled ? (
|
||
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-medium text-sm">文字颜色</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
选中文字后,可直接点击下面按钮切换品牌预设文字颜色。
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="h-8 w-8 rounded-full border border-white/15"
|
||
style={{ backgroundColor: selectedTextColor }}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{textColorConfig.colors.map((color, index) => {
|
||
const isActive =
|
||
getResolvedTextLayerColor(
|
||
selectedTextLayer,
|
||
color
|
||
) ===
|
||
getResolvedTextLayerColor(
|
||
selectedTextLayer,
|
||
selectedTextColor
|
||
);
|
||
return (
|
||
<Button
|
||
className={`h-11 justify-start gap-2.5 rounded-xl border px-3 text-left ${
|
||
isActive
|
||
? "border-emerald-400/60 bg-emerald-500/[0.14] text-white hover:bg-emerald-500/[0.18]"
|
||
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
|
||
}`}
|
||
key={`text-color-button-${index + 1}`}
|
||
onClick={() =>
|
||
setTextColorAssignments((current) => ({
|
||
...current,
|
||
[selectedTextLayer.id]: color,
|
||
}))
|
||
}
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<span
|
||
className="h-5 w-5 shrink-0 rounded-full border border-white/20"
|
||
style={{ backgroundColor: color }}
|
||
/>
|
||
<span className="min-w-0 flex-1 truncate font-medium text-sm">{`切换为颜色 ${index + 1}`}</span>
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{textColorConfig.colors.map((color, index) => {
|
||
const isActive =
|
||
getResolvedTextLayerColor(
|
||
selectedTextLayer,
|
||
color
|
||
) ===
|
||
getResolvedTextLayerColor(
|
||
selectedTextLayer,
|
||
selectedTextColor
|
||
);
|
||
return (
|
||
<button
|
||
className={`flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition ${
|
||
isActive
|
||
? "border-emerald-400/55 bg-emerald-500/[0.12] text-white"
|
||
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
|
||
}`}
|
||
key={`text-color-option-${index + 1}`}
|
||
onClick={() =>
|
||
setTextColorAssignments((current) => ({
|
||
...current,
|
||
[selectedTextLayer.id]: color,
|
||
}))
|
||
}
|
||
type="button"
|
||
>
|
||
<span
|
||
className="h-6 w-6 rounded-full border border-white/20"
|
||
style={{ backgroundColor: color }}
|
||
/>
|
||
<span className="flex min-w-0 flex-col">
|
||
<span className="font-medium text-xs">{`颜色 ${index + 1}`}</span>
|
||
<span className="font-mono text-[11px] text-white/55 uppercase">
|
||
{color}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex-1" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|