5386 lines
200 KiB
TypeScript
5386 lines
200 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
ArrowLeft,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
CheckCircle2,
|
||
Eye,
|
||
EyeOff,
|
||
Expand,
|
||
Image as ImageIcon,
|
||
Images,
|
||
Loader2,
|
||
Move,
|
||
Pointer,
|
||
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, { preload } 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,
|
||
} 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 {
|
||
AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG,
|
||
defaultAgentPsdTemplateCategoryTagConfig,
|
||
normalizeAgentPsdTemplateCategoryTagConfig,
|
||
normalizeAgentPsdTemplateCategoryTags,
|
||
} from "@/lib/agents/psd-template-category-tag-config";
|
||
import {
|
||
type AgentPsdTemplate,
|
||
type AgentPsdTemplateImageLayerSlot,
|
||
type AgentPsdTemplateLayerSlot,
|
||
type AgentPsdTemplateListResponse,
|
||
type AgentPsdTemplateMode,
|
||
type AgentPsdTemplateMutationResponse,
|
||
type AgentPsdTemplateRenderResponse,
|
||
type AgentPsdTemplateTextLayerSlot,
|
||
isAgentPsdImageLayerSlot,
|
||
isAgentPsdLayerSuperLocked,
|
||
isAgentPsdTextLayerSlot,
|
||
parseAgentPsdTemplates,
|
||
resolveAgentPsdTextLayerEffectiveAlign,
|
||
} from "@/lib/agents/psd-template-config";
|
||
import {
|
||
defaultAgentPsdTemplateSecondaryCategoryConfig,
|
||
normalizeAgentPsdTemplateSecondaryCategoryConfig,
|
||
} from "@/lib/agents/psd-template-secondary-category-config";
|
||
import {
|
||
defaultAgentPsdTextColorConfig,
|
||
normalizeAgentPsdTextColorConfig,
|
||
resolveAgentPsdTextColorConfigForBrand,
|
||
} from "@/lib/agents/psd-text-color-config";
|
||
import {
|
||
normalizeHexColor,
|
||
resolveTextLayout,
|
||
} from "@/lib/agents/psd-text-layout";
|
||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
import type { CanvasPsdTemplatePendingImage } from "@/lib/canvas/psd-template-launch";
|
||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||
import {
|
||
getFriendlyRateLimitMessage,
|
||
getUserFacingRequestErrorMessage,
|
||
} from "@/lib/request-error";
|
||
|
||
function buildTemplateListRequestUrl(params?: {
|
||
brandName?: string | null;
|
||
summaryOnly?: boolean;
|
||
}) {
|
||
const searchParams = new URLSearchParams();
|
||
if (params?.summaryOnly !== false) {
|
||
searchParams.set("summary", "1");
|
||
}
|
||
|
||
const normalizedBrandName = String(params?.brandName || "").trim();
|
||
if (normalizedBrandName) {
|
||
searchParams.set("brand", normalizedBrandName);
|
||
}
|
||
|
||
const queryString = searchParams.toString();
|
||
return queryString
|
||
? `/api/psd-templates?${queryString}`
|
||
: "/api/psd-templates";
|
||
}
|
||
|
||
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,
|
||
categoryTagConfig: defaultAgentPsdTemplateCategoryTagConfig,
|
||
error:
|
||
typeof payload?.error === "string"
|
||
? payload.error
|
||
: "PSD 模板加载失败",
|
||
};
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
templates: parseAgentPsdTemplates(payload.templates),
|
||
textColorConfig: normalizeAgentPsdTextColorConfig(
|
||
payload.textColorConfig
|
||
),
|
||
secondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
|
||
payload.secondaryCategoryConfig
|
||
),
|
||
categoryTagConfig: normalizeAgentPsdTemplateCategoryTagConfig(
|
||
payload.categoryTagConfig
|
||
),
|
||
};
|
||
} catch {
|
||
return {
|
||
success: false,
|
||
templates: [],
|
||
textColorConfig: defaultAgentPsdTextColorConfig,
|
||
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
|
||
categoryTagConfig: defaultAgentPsdTemplateCategoryTagConfig,
|
||
error: "PSD 模板加载失败",
|
||
};
|
||
}
|
||
};
|
||
|
||
const templateDetailFetcher = async (
|
||
url: string
|
||
): Promise<AgentPsdTemplateMutationResponse> => {
|
||
try {
|
||
const response = await fetch(url, { cache: "default" });
|
||
const payload = (await response
|
||
.json()
|
||
.catch(() => null)) as Partial<AgentPsdTemplateMutationResponse> | null;
|
||
|
||
if (
|
||
!response.ok ||
|
||
!payload ||
|
||
payload.success !== true ||
|
||
!payload.template
|
||
) {
|
||
return {
|
||
success: false,
|
||
error:
|
||
typeof payload?.error === "string"
|
||
? payload.error
|
||
: "PSD 模板详情加载失败",
|
||
};
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
template: parseAgentPsdTemplates([payload.template])[0],
|
||
};
|
||
} catch {
|
||
return {
|
||
success: false,
|
||
error: "PSD 模板详情加载失败",
|
||
};
|
||
}
|
||
};
|
||
|
||
const PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY = 4;
|
||
|
||
async function mapWithConcurrency<T, R>(
|
||
items: T[],
|
||
concurrency: number,
|
||
mapper: (item: T, index: number) => Promise<R>
|
||
) {
|
||
const results = new Array<R>(items.length);
|
||
let nextIndex = 0;
|
||
const workerCount = Math.max(
|
||
1,
|
||
Math.min(items.length, Math.round(Number(concurrency || 1)))
|
||
);
|
||
|
||
await Promise.all(
|
||
Array.from({ length: workerCount }, async () => {
|
||
while (nextIndex < items.length) {
|
||
const currentIndex = nextIndex;
|
||
nextIndex += 1;
|
||
results[currentIndex] = await mapper(items[currentIndex]!, currentIndex);
|
||
}
|
||
})
|
||
);
|
||
|
||
return results;
|
||
}
|
||
|
||
async function parseActionJsonResponse<T>(
|
||
response: Response,
|
||
fallbackMessage: string,
|
||
rateLimitMessage?: string
|
||
): Promise<T> {
|
||
const payload = await response
|
||
.clone()
|
||
.json()
|
||
.catch(() => null);
|
||
|
||
if (!response.ok) {
|
||
let responseText = "";
|
||
if (payload == null) {
|
||
responseText = await response
|
||
.text()
|
||
.then((value) => value.trim())
|
||
.catch(() => "");
|
||
}
|
||
|
||
const requestError = new Error(
|
||
getUserFacingRequestErrorMessage(
|
||
{
|
||
status: response.status,
|
||
data: payload,
|
||
message: responseText,
|
||
},
|
||
fallbackMessage,
|
||
{
|
||
rateLimitMessage,
|
||
}
|
||
)
|
||
) as Error & {
|
||
status?: number;
|
||
data?: unknown;
|
||
};
|
||
requestError.status = response.status;
|
||
requestError.data =
|
||
payload ??
|
||
(responseText
|
||
? {
|
||
message: responseText,
|
||
}
|
||
: null);
|
||
throw requestError;
|
||
}
|
||
|
||
return payload as T;
|
||
}
|
||
|
||
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;
|
||
const PSD_LAYER_Z_INDEX_STEP = 10;
|
||
const PSD_RENDER_BASE_Z_INDEX_OFFSET = 5;
|
||
const SELECTED_LAYER_INTERACTION_Z_BOOST = 1;
|
||
const SELECTED_LAYER_CONTROLS_Z_BOOST = 1000;
|
||
const MIN_TEXT_POSITION_OFFSET = -5000;
|
||
const MAX_TEXT_POSITION_OFFSET = 5000;
|
||
const UNASSIGNED_BRAND_LABEL = "未分类";
|
||
const IMAGE_EXTENSION_REGEX = /\.([a-z0-9]{2,5})$/i;
|
||
const HIDDEN_LAYER_SCROLL_STEP = 180;
|
||
|
||
type AgentPsdTextPositionOffset = {
|
||
offsetX: number;
|
||
offsetY: number;
|
||
};
|
||
type AgentPsdLayerPositionOffset = AgentPsdTextPositionOffset;
|
||
|
||
function clampTextPositionOffsetValue(value: number) {
|
||
if (!Number.isFinite(value)) {
|
||
return 0;
|
||
}
|
||
return Math.max(
|
||
MIN_TEXT_POSITION_OFFSET,
|
||
Math.min(MAX_TEXT_POSITION_OFFSET, value)
|
||
);
|
||
}
|
||
|
||
function normalizeTextPositionOffset(
|
||
value?: Partial<AgentPsdTextPositionOffset> | null
|
||
): AgentPsdTextPositionOffset {
|
||
return {
|
||
offsetX: Number(
|
||
clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2)
|
||
),
|
||
offsetY: Number(
|
||
clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2)
|
||
),
|
||
};
|
||
}
|
||
|
||
function hasTextPositionOffset(
|
||
value?: Partial<AgentPsdTextPositionOffset> | null
|
||
) {
|
||
const normalized = normalizeTextPositionOffset(value);
|
||
return (
|
||
Math.abs(normalized.offsetX) > 0.01 || Math.abs(normalized.offsetY) > 0.01
|
||
);
|
||
}
|
||
|
||
function normalizeLayerPositionOffset(
|
||
value?: Partial<AgentPsdLayerPositionOffset> | null
|
||
) {
|
||
return normalizeTextPositionOffset(value);
|
||
}
|
||
|
||
function hasLayerPositionOffset(
|
||
value?: Partial<AgentPsdLayerPositionOffset> | null
|
||
) {
|
||
return hasTextPositionOffset(value);
|
||
}
|
||
|
||
function buildTemplatePreviewThumbUrl(
|
||
previewUrl: string,
|
||
params?: {
|
||
width?: number;
|
||
quality?: number;
|
||
}
|
||
) {
|
||
const normalizedUrl = String(previewUrl || "").trim();
|
||
if (!normalizedUrl) {
|
||
return "";
|
||
}
|
||
|
||
const width = Math.max(80, Math.round(params?.width || 160));
|
||
const quality = Math.max(40, Math.round(params?.quality || 72));
|
||
const directOptimizedUrl = buildImageProxyUrl(normalizedUrl, {
|
||
width,
|
||
quality,
|
||
format: "webp",
|
||
});
|
||
|
||
if (directOptimizedUrl !== normalizedUrl) {
|
||
return directOptimizedUrl;
|
||
}
|
||
|
||
return buildImageProxyUrl(normalizedUrl, {
|
||
width,
|
||
quality,
|
||
format: "webp",
|
||
delivery: "proxy",
|
||
});
|
||
}
|
||
|
||
function buildPsdLayerPreviewUrl(
|
||
imageUrl: string,
|
||
params?: {
|
||
width?: number;
|
||
quality?: number;
|
||
delivery?: "proxy";
|
||
}
|
||
) {
|
||
const width = Math.max(96, Math.min(1280, Math.round(params?.width || 720)));
|
||
const quality = Math.max(52, Math.min(82, Math.round(params?.quality || 68)));
|
||
if (params?.delivery === "proxy") {
|
||
return buildImageProxyUrl(imageUrl, {
|
||
width,
|
||
quality,
|
||
format: "webp",
|
||
delivery: "proxy",
|
||
});
|
||
}
|
||
|
||
return buildTemplatePreviewThumbUrl(imageUrl, { width, quality });
|
||
}
|
||
|
||
function buildPsdLayerMaskUrl(maskUrl: string, layerWidth: number) {
|
||
const normalizedUrl = String(maskUrl || "").trim();
|
||
if (!normalizedUrl) {
|
||
return "";
|
||
}
|
||
|
||
return buildImageProxyUrl(normalizedUrl, {
|
||
width: Math.max(96, Math.min(1280, Math.round(layerWidth || 720))),
|
||
quality: 100,
|
||
format: "png",
|
||
delivery: "proxy",
|
||
});
|
||
}
|
||
|
||
function getPsdLayerPreviewSourceUrl(
|
||
layer: Pick<AgentPsdTemplateLayerSlot, "sourceUrl" | "previewUrl">
|
||
) {
|
||
return (
|
||
String(layer.sourceUrl || "").trim() ||
|
||
String(layer.previewUrl || "").trim()
|
||
);
|
||
}
|
||
|
||
function buildTemplatePreviewFallbackUrls(
|
||
template: Pick<AgentPsdTemplate, "previewUrl" | "renderBaseUrl">,
|
||
params?: {
|
||
width?: number;
|
||
quality?: number;
|
||
preferRenderBase?: boolean;
|
||
}
|
||
) {
|
||
const sourceUrls = (
|
||
params?.preferRenderBase
|
||
? [template.renderBaseUrl, template.previewUrl]
|
||
: [template.previewUrl, template.renderBaseUrl]
|
||
)
|
||
.map((url) => String(url || "").trim())
|
||
.filter(Boolean);
|
||
const candidates = [
|
||
...sourceUrls.map((url) =>
|
||
buildTemplatePreviewThumbUrl(url, {
|
||
width: params?.width,
|
||
quality: params?.quality,
|
||
})
|
||
),
|
||
...sourceUrls,
|
||
];
|
||
const seen = new Set<string>();
|
||
|
||
return candidates.filter((url) => {
|
||
if (!url || seen.has(url)) {
|
||
return false;
|
||
}
|
||
seen.add(url);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function TemplatePreviewImage({
|
||
template,
|
||
alt,
|
||
className,
|
||
width,
|
||
quality,
|
||
preferRenderBase,
|
||
decoding = "async",
|
||
fetchPriority,
|
||
loading = "lazy",
|
||
style,
|
||
}: {
|
||
template: Pick<AgentPsdTemplate, "name" | "previewUrl" | "renderBaseUrl">;
|
||
alt?: string;
|
||
className?: string;
|
||
style?: CSSProperties;
|
||
width?: number;
|
||
quality?: number;
|
||
preferRenderBase?: boolean;
|
||
decoding?: "async" | "auto" | "sync";
|
||
fetchPriority?: "auto" | "high" | "low";
|
||
loading?: "eager" | "lazy";
|
||
}) {
|
||
const fallbackUrls = useMemo(
|
||
() =>
|
||
buildTemplatePreviewFallbackUrls(template, {
|
||
width,
|
||
quality,
|
||
preferRenderBase,
|
||
}),
|
||
[
|
||
preferRenderBase,
|
||
quality,
|
||
template.previewUrl,
|
||
template.renderBaseUrl,
|
||
width,
|
||
]
|
||
);
|
||
const fallbackUrlKey = fallbackUrls.join("\n");
|
||
const [activeUrlIndex, setActiveUrlIndex] = useState(0);
|
||
|
||
useEffect(() => {
|
||
setActiveUrlIndex(0);
|
||
}, [fallbackUrlKey]);
|
||
|
||
const src = fallbackUrls[activeUrlIndex] || "";
|
||
if (!src) {
|
||
return (
|
||
<div
|
||
aria-label={alt || template.name}
|
||
className={className}
|
||
role="img"
|
||
style={style}
|
||
/>
|
||
);
|
||
}
|
||
const intrinsicSize = Math.max(1, Math.round(width || 1));
|
||
|
||
return (
|
||
<img
|
||
alt={alt || template.name}
|
||
className={className}
|
||
decoding={decoding}
|
||
fetchPriority={fetchPriority}
|
||
height={intrinsicSize}
|
||
loading={loading}
|
||
onError={() =>
|
||
setActiveUrlIndex((current) =>
|
||
current + 1 < fallbackUrls.length ? current + 1 : current
|
||
)
|
||
}
|
||
src={src}
|
||
style={style}
|
||
width={intrinsicSize}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function getScrollAreaViewportElement(root: HTMLDivElement | null) {
|
||
return (
|
||
(root?.querySelector(
|
||
"[data-radix-scroll-area-viewport]"
|
||
) as HTMLDivElement | null) || root
|
||
);
|
||
}
|
||
|
||
function isLikelyStableRenderImageUrl(url: string) {
|
||
const normalized = String(url || "").trim();
|
||
if (!normalized) {
|
||
return true;
|
||
}
|
||
|
||
if (
|
||
normalized.startsWith("data:") ||
|
||
normalized.startsWith("blob:") ||
|
||
normalized.startsWith("/api/image-proxy")
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
if (typeof window === "undefined") {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(normalized, window.location.origin);
|
||
if (parsed.origin === window.location.origin) {
|
||
return true;
|
||
}
|
||
|
||
const hostname = parsed.hostname.toLowerCase();
|
||
return (
|
||
hostname.includes("aliyuncs.com") ||
|
||
hostname.includes("oss-cn-") ||
|
||
hostname.includes("cloudfront.net") ||
|
||
hostname.includes("r2.dev")
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function inferImageExtension(url: string, contentType: string) {
|
||
const normalizedContentType = String(contentType || "").toLowerCase();
|
||
if (normalizedContentType.includes("png")) {
|
||
return "png";
|
||
}
|
||
if (normalizedContentType.includes("webp")) {
|
||
return "webp";
|
||
}
|
||
if (
|
||
normalizedContentType.includes("jpeg") ||
|
||
normalizedContentType.includes("jpg")
|
||
) {
|
||
return "jpg";
|
||
}
|
||
if (normalizedContentType.includes("gif")) {
|
||
return "gif";
|
||
}
|
||
if (normalizedContentType.includes("avif")) {
|
||
return "avif";
|
||
}
|
||
|
||
try {
|
||
const pathname = new URL(url, "https://placeholder.invalid").pathname;
|
||
const matched = pathname.match(IMAGE_EXTENSION_REGEX);
|
||
if (matched?.[1]) {
|
||
return matched[1].toLowerCase();
|
||
}
|
||
} catch {
|
||
// Ignore malformed URLs and fall back to png.
|
||
}
|
||
|
||
return "png";
|
||
}
|
||
|
||
function normalizeManagedBrandName(value?: string | null) {
|
||
const normalized = String(value || "").trim();
|
||
if (!normalized || normalized === "未分配品牌") {
|
||
return "";
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function matchesTemplateBrand(template: AgentPsdTemplate, brandName?: string | null) {
|
||
const normalizedBrandName = normalizeManagedBrandName(brandName).toLowerCase();
|
||
if (!normalizedBrandName) {
|
||
return true;
|
||
}
|
||
|
||
return normalizeManagedBrandName(template.brandName).toLowerCase() === normalizedBrandName;
|
||
}
|
||
|
||
function getEffectiveTemplateCategoryTags(
|
||
template: AgentPsdTemplate,
|
||
activeCategoryTags: string[]
|
||
) {
|
||
const templateCategoryTags = normalizeAgentPsdTemplateCategoryTags(
|
||
template.categoryTags
|
||
);
|
||
if (templateCategoryTags.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const activeCategoryTagSet = new Set(
|
||
activeCategoryTags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
|
||
);
|
||
if (activeCategoryTagSet.size === 0) {
|
||
return templateCategoryTags;
|
||
}
|
||
|
||
return templateCategoryTags.filter(
|
||
(tag) =>
|
||
tag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG ||
|
||
activeCategoryTagSet.has(tag.trim().toLowerCase())
|
||
);
|
||
}
|
||
|
||
function matchesTemplateSelectedCategories(
|
||
template: AgentPsdTemplate,
|
||
selectedCategoryNames: string[],
|
||
activeCategoryTags: string[]
|
||
) {
|
||
if (selectedCategoryNames.length === 0) {
|
||
return true;
|
||
}
|
||
|
||
const rawTemplateCategoryTags = normalizeAgentPsdTemplateCategoryTags(
|
||
template.categoryTags
|
||
);
|
||
if (rawTemplateCategoryTags.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
const templateCategoryTags = getEffectiveTemplateCategoryTags(
|
||
template,
|
||
activeCategoryTags
|
||
);
|
||
if (templateCategoryTags.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
if (templateCategoryTags.includes(AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG)) {
|
||
return true;
|
||
}
|
||
|
||
const selectedCategorySet = new Set(
|
||
selectedCategoryNames.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
|
||
);
|
||
return templateCategoryTags.some((tag) =>
|
||
selectedCategorySet.has(tag.trim().toLowerCase())
|
||
);
|
||
}
|
||
|
||
function getTemplateMetaLabel(
|
||
template: AgentPsdTemplate,
|
||
showSecondaryCategory: boolean,
|
||
showBrand = false
|
||
) {
|
||
const metaParts = [
|
||
showBrand
|
||
? normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL
|
||
: "",
|
||
`${template.width} x ${template.height}`,
|
||
showSecondaryCategory
|
||
? String(template.secondaryCategory || "").trim()
|
||
: "",
|
||
].filter(Boolean);
|
||
return metaParts.join(" · ");
|
||
}
|
||
|
||
function getTemplateCategoryTagLabels(template: AgentPsdTemplate) {
|
||
return normalizeAgentPsdTemplateCategoryTags(template.categoryTags)
|
||
.map((tag) =>
|
||
tag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG ? "适合所有" : tag
|
||
)
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function renderTemplateCategoryTagRow(
|
||
template: AgentPsdTemplate,
|
||
params?: {
|
||
compact?: boolean;
|
||
}
|
||
) {
|
||
const compact = params?.compact === true;
|
||
const labels = getTemplateCategoryTagLabels(template);
|
||
if (labels.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const visibleLabels = compact ? labels.slice(0, 2) : labels.slice(0, 3);
|
||
const hiddenCount = Math.max(0, labels.length - visibleLabels.length);
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
compact ? "mt-1.5 flex flex-wrap gap-1" : "mt-2 flex flex-wrap gap-1.5"
|
||
}
|
||
>
|
||
{visibleLabels.map((label) => (
|
||
<span
|
||
className={
|
||
compact
|
||
? "rounded-full border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white/68 leading-none"
|
||
: "rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/72 leading-none"
|
||
}
|
||
key={`${template.id}-category-tag-${label}`}
|
||
>
|
||
{label}
|
||
</span>
|
||
))}
|
||
{hiddenCount > 0 ? (
|
||
<span
|
||
className={
|
||
compact
|
||
? "rounded-full border border-white/10 bg-white/[0.03] px-1.5 py-0.5 text-[10px] text-white/45 leading-none"
|
||
: "rounded-full border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[10px] text-white/45 leading-none"
|
||
}
|
||
>
|
||
+{hiddenCount}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function renderTemplateSelectContent(
|
||
template: AgentPsdTemplate,
|
||
params?: {
|
||
showSecondaryCategory?: boolean;
|
||
showBrand?: boolean;
|
||
compact?: boolean;
|
||
}
|
||
) {
|
||
const showSecondaryCategory = params?.showSecondaryCategory === true;
|
||
const showBrand = params?.showBrand === true;
|
||
const compact = params?.compact === true;
|
||
const metaLabel = getTemplateMetaLabel(
|
||
template,
|
||
showSecondaryCategory,
|
||
showBrand
|
||
);
|
||
|
||
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"
|
||
}
|
||
>
|
||
<TemplatePreviewImage
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
template={template}
|
||
width={compact ? 96 : 92}
|
||
/>
|
||
</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}
|
||
{renderTemplateCategoryTagRow(template, { compact })}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function renderTemplatePickerCardContent(
|
||
template: AgentPsdTemplate,
|
||
params?: {
|
||
showSecondaryCategory?: boolean;
|
||
showBrand?: boolean;
|
||
emphasize?: boolean;
|
||
eager?: boolean;
|
||
}
|
||
) {
|
||
const showSecondaryCategory = params?.showSecondaryCategory === true;
|
||
const showBrand = params?.showBrand === true;
|
||
const emphasize = params?.emphasize === true;
|
||
const eager = params?.eager === true;
|
||
const metaLabel = getTemplateMetaLabel(
|
||
template,
|
||
showSecondaryCategory,
|
||
showBrand
|
||
);
|
||
|
||
return (
|
||
<div className="flex min-w-0 items-center gap-3.5">
|
||
<div className="flex h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5 shadow-inner">
|
||
<TemplatePreviewImage
|
||
className="h-full w-full object-cover"
|
||
fetchPriority={eager ? "high" : "low"}
|
||
loading={eager ? "eager" : "lazy"}
|
||
quality={66}
|
||
template={template}
|
||
width={112}
|
||
/>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div
|
||
className={
|
||
emphasize
|
||
? "truncate font-semibold text-[15px] text-white leading-tight"
|
||
: "truncate font-medium text-sm text-white leading-tight"
|
||
}
|
||
>
|
||
{template.name}
|
||
</div>
|
||
{metaLabel ? (
|
||
<div className="mt-1 truncate text-[11px] text-white/45">
|
||
{metaLabel}
|
||
</div>
|
||
) : null}
|
||
{renderTemplateCategoryTagRow(template)}
|
||
</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 getDefaultSourceImageLayer(
|
||
layers: AgentPsdTemplateLayerSlot[]
|
||
): AgentPsdTemplateImageLayerSlot | null {
|
||
const editableImageLayers = layers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer) &&
|
||
isAgentPsdImageLayerSlot(layer)
|
||
);
|
||
|
||
return (
|
||
editableImageLayers.find((layer) => layer.isDefaultResultSource) ||
|
||
editableImageLayers[0] ||
|
||
null
|
||
);
|
||
}
|
||
|
||
function createInitialState(
|
||
template: AgentPsdTemplate | null,
|
||
sourceImageUrl: string
|
||
) {
|
||
const imageAssignments: Record<string, string> = {};
|
||
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
|
||
const layerOrderIds: string[] = [];
|
||
const layerPositionOffsets: Record<string, AgentPsdLayerPositionOffset> = {};
|
||
const textAssignments: Record<string, string> = {};
|
||
const textPositionOffsets: Record<string, AgentPsdTextPositionOffset> = {};
|
||
|
||
if (!template) {
|
||
return {
|
||
imageAssignments,
|
||
imagePlacements,
|
||
layerOrderIds,
|
||
layerPositionOffsets,
|
||
textAssignments,
|
||
textPositionOffsets,
|
||
};
|
||
}
|
||
|
||
const defaultSourceImageLayerId = sourceImageUrl
|
||
? getDefaultSourceImageLayer(template.layers)?.id || ""
|
||
: "";
|
||
|
||
for (const layer of template.layers) {
|
||
layerOrderIds.push(layer.id);
|
||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||
continue;
|
||
}
|
||
if (
|
||
isAgentPsdImageLayerSlot(layer) &&
|
||
layer.id === defaultSourceImageLayerId &&
|
||
sourceImageUrl
|
||
) {
|
||
imageAssignments[layer.id] = sourceImageUrl;
|
||
imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement();
|
||
}
|
||
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
|
||
textAssignments[layer.id] = layer.defaultText;
|
||
}
|
||
}
|
||
|
||
return {
|
||
imageAssignments,
|
||
imagePlacements,
|
||
layerOrderIds,
|
||
layerPositionOffsets,
|
||
textAssignments,
|
||
textPositionOffsets,
|
||
};
|
||
}
|
||
|
||
function normalizeLayerTransferKey(value: string) {
|
||
return String(value || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
function getLayerTransferKeys(layer: AgentPsdTemplateLayerSlot) {
|
||
const keys = new Set<string>();
|
||
const addKey = (value?: string | null) => {
|
||
const normalized = normalizeLayerTransferKey(String(value || ""));
|
||
if (normalized) {
|
||
keys.add(normalized);
|
||
}
|
||
};
|
||
|
||
addKey(layer.id);
|
||
addKey(layer.layerId);
|
||
addKey(layer.layerPath);
|
||
addKey(layer.layerName);
|
||
|
||
if (isAgentPsdImageLayerSlot(layer)) {
|
||
addKey(`image:${layer.layerPath}`);
|
||
addKey(`image:${layer.layerName}`);
|
||
addKey(`image:${layer.layerPath}:${layer.width}x${layer.height}`);
|
||
addKey(`image:${layer.layerName}:${layer.width}x${layer.height}`);
|
||
if (layer.isDefaultResultSource) {
|
||
addKey("__default_result_source__");
|
||
}
|
||
}
|
||
|
||
if (isAgentPsdTextLayerSlot(layer)) {
|
||
addKey(`text:${layer.layerPath}`);
|
||
addKey(`text:${layer.layerName}`);
|
||
addKey(`text:${layer.layerPath}:${layer.width}x${layer.height}`);
|
||
addKey(`text:${layer.layerName}:${layer.width}x${layer.height}`);
|
||
}
|
||
|
||
return Array.from(keys);
|
||
}
|
||
|
||
function findMatchingLayer<TLayer extends AgentPsdTemplateLayerSlot>(params: {
|
||
nextLayer: TLayer;
|
||
previousLayers: TLayer[];
|
||
usedPreviousLayerIds?: Set<string>;
|
||
}) {
|
||
const { nextLayer, previousLayers, usedPreviousLayerIds } = params;
|
||
const nextLayerKeySet = new Set(getLayerTransferKeys(nextLayer));
|
||
for (const previousLayer of previousLayers) {
|
||
if (usedPreviousLayerIds?.has(previousLayer.id)) {
|
||
continue;
|
||
}
|
||
|
||
const previousKeys = getLayerTransferKeys(previousLayer);
|
||
if (previousKeys.some((key) => nextLayerKeySet.has(key))) {
|
||
return previousLayer;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function resolveTransferredSelectedLayerId(params: {
|
||
nextTemplate: AgentPsdTemplate | null;
|
||
previousTemplate: AgentPsdTemplate | null;
|
||
previousSelectedLayerId: string;
|
||
}) {
|
||
const { nextTemplate, previousTemplate, previousSelectedLayerId } = params;
|
||
if (!nextTemplate) {
|
||
return "";
|
||
}
|
||
|
||
if (!previousTemplate || !previousSelectedLayerId) {
|
||
return getPreferredSelectedLayerId(nextTemplate);
|
||
}
|
||
|
||
const previousLayer =
|
||
previousTemplate.layers.find(
|
||
(layer) => layer.id === previousSelectedLayerId
|
||
) || null;
|
||
if (!previousLayer) {
|
||
return getPreferredSelectedLayerId(nextTemplate);
|
||
}
|
||
|
||
const nextCandidateLayers = nextTemplate.layers.filter(
|
||
(layer) =>
|
||
layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer) &&
|
||
((isAgentPsdImageLayerSlot(previousLayer) &&
|
||
isAgentPsdImageLayerSlot(layer)) ||
|
||
(isAgentPsdTextLayerSlot(previousLayer) &&
|
||
isAgentPsdTextLayerSlot(layer)))
|
||
);
|
||
const previousLayerKeySet = new Set(getLayerTransferKeys(previousLayer));
|
||
const matchedLayer = nextCandidateLayers.find((layer) =>
|
||
getLayerTransferKeys(layer).some((key) => previousLayerKeySet.has(key))
|
||
);
|
||
|
||
return matchedLayer?.id || getPreferredSelectedLayerId(nextTemplate);
|
||
}
|
||
|
||
function createTemplateEditorState(params: {
|
||
nextTemplate: AgentPsdTemplate | null;
|
||
previousTemplate: AgentPsdTemplate | null;
|
||
sourceImageUrl: string;
|
||
previousImageAssignments: Record<string, string>;
|
||
previousImagePlacements: Record<string, AgentPsdImagePlacement>;
|
||
previousLayerPositionOffsets: Record<string, AgentPsdLayerPositionOffset>;
|
||
previousTextAssignments: Record<string, string>;
|
||
previousTextColorAssignments: Record<string, string>;
|
||
previousTextPositionOffsets: Record<string, AgentPsdTextPositionOffset>;
|
||
}) {
|
||
const {
|
||
nextTemplate,
|
||
previousTemplate,
|
||
sourceImageUrl,
|
||
previousImageAssignments,
|
||
previousImagePlacements,
|
||
previousLayerPositionOffsets,
|
||
previousTextAssignments,
|
||
previousTextColorAssignments,
|
||
previousTextPositionOffsets,
|
||
} = params;
|
||
|
||
const initialState = createInitialState(nextTemplate, sourceImageUrl);
|
||
const nextState = {
|
||
imageAssignments: { ...initialState.imageAssignments },
|
||
imagePlacements: { ...initialState.imagePlacements },
|
||
layerOrderIds: [...initialState.layerOrderIds],
|
||
layerPositionOffsets: { ...initialState.layerPositionOffsets },
|
||
textAssignments: { ...initialState.textAssignments },
|
||
textColorAssignments: {} as Record<string, string>,
|
||
textPositionOffsets: { ...initialState.textPositionOffsets },
|
||
};
|
||
|
||
if (!nextTemplate || !previousTemplate) {
|
||
return nextState;
|
||
}
|
||
|
||
const usedPreviousLayerIds = new Set<string>();
|
||
for (const nextLayer of nextTemplate.layers) {
|
||
if (isAgentPsdLayerSuperLocked(nextLayer)) {
|
||
continue;
|
||
}
|
||
const matchedPreviousLayer = findMatchingLayer({
|
||
nextLayer,
|
||
previousLayers: previousTemplate.layers,
|
||
usedPreviousLayerIds,
|
||
});
|
||
if (!matchedPreviousLayer) {
|
||
continue;
|
||
}
|
||
|
||
const previousLayerPositionOffset =
|
||
previousLayerPositionOffsets[matchedPreviousLayer.id];
|
||
if (isAgentPsdLayerSuperLocked(matchedPreviousLayer)) {
|
||
usedPreviousLayerIds.add(matchedPreviousLayer.id);
|
||
continue;
|
||
}
|
||
if (hasLayerPositionOffset(previousLayerPositionOffset)) {
|
||
nextState.layerPositionOffsets[nextLayer.id] =
|
||
normalizeLayerPositionOffset(previousLayerPositionOffset);
|
||
}
|
||
|
||
usedPreviousLayerIds.add(matchedPreviousLayer.id);
|
||
}
|
||
|
||
const previousEditableImageLayers = previousTemplate.layers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer) &&
|
||
isAgentPsdImageLayerSlot(layer)
|
||
);
|
||
const nextEditableImageLayers = nextTemplate.layers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer) &&
|
||
isAgentPsdImageLayerSlot(layer)
|
||
);
|
||
const usedPreviousImageLayerIds = new Set<string>();
|
||
const usedNextImageLayerIds = new Set<string>();
|
||
|
||
for (const nextLayer of nextEditableImageLayers) {
|
||
const matchedPreviousLayer = findMatchingLayer({
|
||
nextLayer,
|
||
previousLayers: previousEditableImageLayers,
|
||
usedPreviousLayerIds: usedPreviousImageLayerIds,
|
||
});
|
||
if (!matchedPreviousLayer) {
|
||
continue;
|
||
}
|
||
|
||
const previousImageUrl = String(
|
||
previousImageAssignments[matchedPreviousLayer.id] || ""
|
||
).trim();
|
||
if (!previousImageUrl) {
|
||
continue;
|
||
}
|
||
|
||
nextState.imageAssignments[nextLayer.id] = previousImageUrl;
|
||
nextState.imagePlacements[nextLayer.id] = normalizeAgentPsdImagePlacement(
|
||
previousImagePlacements[matchedPreviousLayer.id]
|
||
);
|
||
usedPreviousImageLayerIds.add(matchedPreviousLayer.id);
|
||
usedNextImageLayerIds.add(nextLayer.id);
|
||
}
|
||
|
||
const unmatchedPreviousImageLayers = previousEditableImageLayers.filter(
|
||
(layer) =>
|
||
!usedPreviousImageLayerIds.has(layer.id) &&
|
||
Boolean(String(previousImageAssignments[layer.id] || "").trim())
|
||
);
|
||
if (unmatchedPreviousImageLayers.length === 1) {
|
||
const fallbackNextLayer =
|
||
nextEditableImageLayers.find(
|
||
(layer) =>
|
||
!usedNextImageLayerIds.has(layer.id) && layer.isDefaultResultSource
|
||
) ||
|
||
nextEditableImageLayers.find(
|
||
(layer) => !usedNextImageLayerIds.has(layer.id)
|
||
);
|
||
if (fallbackNextLayer) {
|
||
const fallbackPreviousLayer = unmatchedPreviousImageLayers[0];
|
||
nextState.imageAssignments[fallbackNextLayer.id] = String(
|
||
previousImageAssignments[fallbackPreviousLayer.id] || ""
|
||
).trim();
|
||
nextState.imagePlacements[fallbackNextLayer.id] =
|
||
normalizeAgentPsdImagePlacement(
|
||
previousImagePlacements[fallbackPreviousLayer.id]
|
||
);
|
||
}
|
||
}
|
||
|
||
const previousEditableTextLayers = previousTemplate.layers.filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer) &&
|
||
isAgentPsdTextLayerSlot(layer)
|
||
);
|
||
const nextEditableTextLayers = nextTemplate.layers.filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer) &&
|
||
isAgentPsdTextLayerSlot(layer)
|
||
);
|
||
const usedPreviousTextLayerIds = new Set<string>();
|
||
|
||
for (const nextLayer of nextEditableTextLayers) {
|
||
const matchedPreviousLayer = findMatchingLayer({
|
||
nextLayer,
|
||
previousLayers: previousEditableTextLayers,
|
||
usedPreviousLayerIds: usedPreviousTextLayerIds,
|
||
});
|
||
if (!matchedPreviousLayer) {
|
||
continue;
|
||
}
|
||
|
||
const previousTextValue = previousTextAssignments[matchedPreviousLayer.id];
|
||
if (typeof previousTextValue === "string") {
|
||
nextState.textAssignments[nextLayer.id] = previousTextValue;
|
||
}
|
||
|
||
const previousTextColor = String(
|
||
previousTextColorAssignments[matchedPreviousLayer.id] || ""
|
||
).trim();
|
||
if (previousTextColor) {
|
||
nextState.textColorAssignments[nextLayer.id] = previousTextColor;
|
||
}
|
||
|
||
const previousTextPositionOffset =
|
||
previousTextPositionOffsets[matchedPreviousLayer.id];
|
||
if (hasTextPositionOffset(previousTextPositionOffset)) {
|
||
nextState.textPositionOffsets[nextLayer.id] = normalizeTextPositionOffset(
|
||
previousTextPositionOffset
|
||
);
|
||
}
|
||
|
||
usedPreviousTextLayerIds.add(matchedPreviousLayer.id);
|
||
}
|
||
|
||
return nextState;
|
||
}
|
||
|
||
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 && !isAgentPsdLayerSuperLocked(layer)
|
||
);
|
||
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,
|
||
positionOffset?: Partial<AgentPsdLayerPositionOffset> | null
|
||
): CSSProperties {
|
||
const normalizedOffset = normalizeLayerPositionOffset(positionOffset);
|
||
return {
|
||
left: layer.x + normalizedOffset.offsetX,
|
||
top: layer.y + normalizedOffset.offsetY,
|
||
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 typeof value === "string"
|
||
? value
|
||
: layer.placeholder || layer.layerName;
|
||
}
|
||
|
||
function hasRenderableTextLayerPreview(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value?: string
|
||
) {
|
||
return Boolean(
|
||
String(value || "").trim() || String(layer.placeholder || "").trim()
|
||
);
|
||
}
|
||
|
||
function getResolvedTextLayerColor(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value?: string
|
||
) {
|
||
const fallbackColor = normalizeHexColor(layer.textColor, "#111111");
|
||
return normalizeHexColor(value || fallbackColor, fallbackColor);
|
||
}
|
||
|
||
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 textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer);
|
||
const offsetX =
|
||
textAlign === "right"
|
||
? layer.width - width
|
||
: textAlign === "center"
|
||
? (layer.width - width) / 2
|
||
: 0;
|
||
|
||
return {
|
||
...resolvedLayout,
|
||
width,
|
||
height,
|
||
offsetX,
|
||
offsetY: -resolvedLayout.visualOffsetY,
|
||
};
|
||
}
|
||
|
||
function renderTextLayerPreviewContent(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string,
|
||
color: string
|
||
) {
|
||
const resolvedText = getResolvedTextPreview(layer, value);
|
||
const resolvedLayout = getTextLayerRenderFrame(layer, resolvedText);
|
||
const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer);
|
||
const textAnchor =
|
||
textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
|
||
const textX =
|
||
textAlign === "center"
|
||
? resolvedLayout.width / 2
|
||
: textAlign === "right"
|
||
? resolvedLayout.width - layer.paddingX
|
||
: layer.paddingX;
|
||
|
||
return (
|
||
<svg
|
||
className="block overflow-visible"
|
||
height={resolvedLayout.height}
|
||
viewBox={`0 0 ${resolvedLayout.width} ${resolvedLayout.height}`}
|
||
width={resolvedLayout.width}
|
||
style={{
|
||
width: resolvedLayout.width,
|
||
height: resolvedLayout.height,
|
||
}}
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<text
|
||
fill={color}
|
||
fontSize={resolvedLayout.fontSize}
|
||
fontWeight={layer.fontWeight}
|
||
letterSpacing={layer.letterSpacing}
|
||
style={{
|
||
fontFamily: buildTextLayerFontFamily(layer),
|
||
textRendering: "geometricPrecision",
|
||
}}
|
||
textAnchor={textAnchor}
|
||
xmlSpace="preserve"
|
||
>
|
||
{resolvedLayout.lines.map((line, index) => (
|
||
<tspan
|
||
key={`${layer.id}-preview-line-${index + 1}`}
|
||
x={textX}
|
||
y={
|
||
layer.paddingY +
|
||
resolvedLayout.fontSize +
|
||
index * resolvedLayout.lineHeightPx
|
||
}
|
||
>
|
||
{line || "\u00a0"}
|
||
</tspan>
|
||
))}
|
||
</text>
|
||
<foreignObject height="0" width="0" x="0" y="0">
|
||
<div
|
||
style={{
|
||
fontFamily: buildTextLayerFontFamily(layer),
|
||
fontSize: 0,
|
||
}}
|
||
>
|
||
|
||
</div>
|
||
</foreignObject>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function getAssignedImageMaskStyle(
|
||
layer: AgentPsdTemplateImageLayerSlot,
|
||
layers: AgentPsdTemplateLayerSlot[]
|
||
): CSSProperties {
|
||
const clipMaskLayer = layer.clipMaskLayerId
|
||
? layers.find((item) => item.id === layer.clipMaskLayerId)
|
||
: null;
|
||
const fallbackMaskUrl =
|
||
layer.forceClipMask && clipMaskLayer
|
||
? getPsdLayerPreviewSourceUrl(clipMaskLayer)
|
||
: "";
|
||
const normalizedMaskUrl = String(
|
||
layer.maskUrl || fallbackMaskUrl || ""
|
||
).trim();
|
||
const hasMask = Boolean(normalizedMaskUrl);
|
||
const maskImageUrl = hasMask
|
||
? `url(${buildPsdLayerMaskUrl(normalizedMaskUrl, layer.width)})`
|
||
: undefined;
|
||
return {
|
||
WebkitMaskImage: maskImageUrl,
|
||
maskImage: maskImageUrl,
|
||
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,
|
||
maskMode: hasMask ? "alpha" : 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,
|
||
positionOffset?: Partial<AgentPsdTextPositionOffset> | null
|
||
) {
|
||
return (
|
||
hasEditedTextValue(layer, value) ||
|
||
getResolvedTextLayerColor(layer, color) !==
|
||
getResolvedTextLayerColor(layer) ||
|
||
hasTextPositionOffset(positionOffset)
|
||
);
|
||
}
|
||
|
||
export function AgentPsdTemplateEditorDialog({
|
||
open,
|
||
onOpenChange,
|
||
sourceImageUrl,
|
||
brandName,
|
||
selectedCategoryNames,
|
||
sizeInfoText,
|
||
templateMode,
|
||
onDerivedImageCreated,
|
||
onRenderedResultCreated,
|
||
standalone = false,
|
||
onBack,
|
||
pendingImages,
|
||
activePendingImageId,
|
||
onActivePendingImageIdChange,
|
||
pendingRenderedResultUrls,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
sourceImageUrl: string | null;
|
||
brandName?: string | null;
|
||
selectedCategoryNames?: string[] | null;
|
||
sizeInfoText?: string | null;
|
||
templateMode?: AgentPsdTemplateMode | null;
|
||
onDerivedImageCreated?: (imageUrl: string) => void;
|
||
onRenderedResultCreated?: (imageUrl: string) => void;
|
||
standalone?: boolean;
|
||
onBack?: () => void;
|
||
pendingImages?: CanvasPsdTemplatePendingImage[] | null;
|
||
activePendingImageId?: string | null;
|
||
onActivePendingImageIdChange?: (imageId: string) => void;
|
||
pendingRenderedResultUrls?: Record<string, string | undefined> | null;
|
||
}) {
|
||
const normalizedBrandName = String(brandName || "").trim();
|
||
const normalizedSelectedCategoryNames = useMemo(
|
||
() =>
|
||
normalizeAgentPsdTemplateCategoryTags(selectedCategoryNames).filter(
|
||
(tag) => tag !== AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG
|
||
),
|
||
[selectedCategoryNames]
|
||
);
|
||
const normalizedTemplateMode =
|
||
templateMode === "sku" ? "sku" : templateMode === "main" ? "main" : "";
|
||
const normalizedPendingImages = useMemo(
|
||
() =>
|
||
Array.isArray(pendingImages)
|
||
? pendingImages.filter(
|
||
(item) =>
|
||
typeof item?.id === "string" &&
|
||
item.id.trim().length > 0 &&
|
||
typeof item?.imageUrl === "string" &&
|
||
item.imageUrl.trim().length > 0
|
||
)
|
||
: [],
|
||
[pendingImages]
|
||
);
|
||
const templateListPrefetchUrl = useMemo(
|
||
() =>
|
||
buildTemplateListRequestUrl({
|
||
brandName: normalizedBrandName,
|
||
summaryOnly: true,
|
||
}),
|
||
[normalizedBrandName]
|
||
);
|
||
const templateListRequestUrl = useMemo(() => {
|
||
if (!open) {
|
||
return null;
|
||
}
|
||
|
||
return templateListPrefetchUrl;
|
||
}, [open, templateListPrefetchUrl]);
|
||
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
|
||
templateListRequestUrl,
|
||
fetcher,
|
||
{
|
||
dedupingInterval: 30_000,
|
||
keepPreviousData: true,
|
||
revalidateOnFocus: false,
|
||
}
|
||
);
|
||
const allTemplates = useMemo(
|
||
() => (data?.success ? data.templates : []),
|
||
[data]
|
||
);
|
||
const rawTextColorConfig = useMemo(
|
||
() => normalizeAgentPsdTextColorConfig(data?.textColorConfig),
|
||
[data?.textColorConfig]
|
||
);
|
||
const secondaryCategoryConfig = useMemo(
|
||
() =>
|
||
normalizeAgentPsdTemplateSecondaryCategoryConfig(
|
||
data?.secondaryCategoryConfig
|
||
),
|
||
[data?.secondaryCategoryConfig]
|
||
);
|
||
const categoryTagConfig = useMemo(
|
||
() => normalizeAgentPsdTemplateCategoryTagConfig(data?.categoryTagConfig),
|
||
[data?.categoryTagConfig]
|
||
);
|
||
const activeCategoryTags = useMemo(
|
||
() => categoryTagConfig.tags,
|
||
[categoryTagConfig.tags]
|
||
);
|
||
const templates = useMemo(
|
||
() =>
|
||
allTemplates
|
||
.filter((template) => matchesTemplateBrand(template, normalizedBrandName))
|
||
.filter((template) =>
|
||
matchesTemplateSelectedCategories(
|
||
template,
|
||
normalizedSelectedCategoryNames,
|
||
activeCategoryTags
|
||
)
|
||
),
|
||
[
|
||
activeCategoryTags,
|
||
allTemplates,
|
||
normalizedBrandName,
|
||
normalizedSelectedCategoryNames,
|
||
]
|
||
);
|
||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||
const selectedTemplateDetailRequestUrl = useMemo(() => {
|
||
if (!open || !selectedTemplateId) {
|
||
return null;
|
||
}
|
||
|
||
return `/api/psd-templates/${encodeURIComponent(selectedTemplateId)}`;
|
||
}, [open, selectedTemplateId]);
|
||
const {
|
||
data: selectedTemplateDetailData,
|
||
isLoading: isSelectedTemplateDetailLoading,
|
||
} = useSWR<AgentPsdTemplateMutationResponse>(
|
||
selectedTemplateDetailRequestUrl,
|
||
templateDetailFetcher,
|
||
{
|
||
dedupingInterval: 60_000,
|
||
keepPreviousData: true,
|
||
revalidateOnFocus: false,
|
||
}
|
||
);
|
||
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 [layerOrderIds, setLayerOrderIds] = useState<string[]>([]);
|
||
const [layerPositionOffsets, setLayerPositionOffsets] = useState<
|
||
Record<string, AgentPsdLayerPositionOffset>
|
||
>({});
|
||
const [textAssignments, setTextAssignments] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [textColorAssignments, setTextColorAssignments] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [textPositionOffsets, setTextPositionOffsets] = useState<
|
||
Record<string, AgentPsdTextPositionOffset>
|
||
>({});
|
||
const [hiddenLayerIds, setHiddenLayerIds] = useState<string[]>([]);
|
||
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
|
||
const [extendingSlotId, setExtendingSlotId] = 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 [canScrollHiddenLayersUp, setCanScrollHiddenLayersUp] = useState(false);
|
||
const [canScrollHiddenLayersDown, setCanScrollHiddenLayersDown] =
|
||
useState(false);
|
||
|
||
const previewViewportRef = useRef<HTMLDivElement | null>(null);
|
||
const hiddenLayerScrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
const previousSelectedTemplateRef = useRef<AgentPsdTemplate | null>(null);
|
||
const layerDragStateRef = useRef<{
|
||
layerId: string;
|
||
pointerId: number;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startOffset: AgentPsdLayerPositionOffset;
|
||
} | null>(null);
|
||
const textDragStateRef = useRef<{
|
||
layerId: string;
|
||
pointerId: number;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startOffset: AgentPsdTextPositionOffset;
|
||
} | null>(null);
|
||
|
||
const imageAssignmentsRef = useRef(imageAssignments);
|
||
const imagePlacementsRef = useRef(imagePlacements);
|
||
const layerPositionOffsetsRef = useRef(layerPositionOffsets);
|
||
const textAssignmentsRef = useRef(textAssignments);
|
||
const textColorAssignmentsRef = useRef(textColorAssignments);
|
||
const textPositionOffsetsRef = useRef(textPositionOffsets);
|
||
const archivedRenderImageUrlCacheRef = useRef<Record<string, string>>({});
|
||
const archivedRenderImageUrlPromiseCacheRef = useRef<
|
||
Record<string, Promise<string>>
|
||
>({});
|
||
const selectedLayerIdRef = useRef(selectedLayerId);
|
||
|
||
imageAssignmentsRef.current = imageAssignments;
|
||
imagePlacementsRef.current = imagePlacements;
|
||
layerPositionOffsetsRef.current = layerPositionOffsets;
|
||
textAssignmentsRef.current = textAssignments;
|
||
textColorAssignmentsRef.current = textColorAssignments;
|
||
textPositionOffsetsRef.current = textPositionOffsets;
|
||
selectedLayerIdRef.current = selectedLayerId;
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
archivedRenderImageUrlCacheRef.current = {};
|
||
archivedRenderImageUrlPromiseCacheRef.current = {};
|
||
}
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!open || typeof document === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const html = document.documentElement;
|
||
const body = document.body;
|
||
const previousHtmlOverflow = html.style.overflow;
|
||
const previousBodyOverflow = body.style.overflow;
|
||
const previousHtmlOverscrollBehavior = html.style.overscrollBehavior;
|
||
const previousBodyOverscrollBehavior = body.style.overscrollBehavior;
|
||
const previousHtmlBackgroundColor = html.style.backgroundColor;
|
||
const previousBodyBackgroundColor = body.style.backgroundColor;
|
||
const previousBodyPaddingRight = body.style.paddingRight;
|
||
const scrollbarWidth = Math.max(0, window.innerWidth - html.clientWidth);
|
||
const currentBodyPaddingRight = Number.parseFloat(
|
||
window.getComputedStyle(body).paddingRight || "0"
|
||
);
|
||
|
||
html.style.overflow = "hidden";
|
||
body.style.overflow = "hidden";
|
||
html.style.overscrollBehavior = "none";
|
||
body.style.overscrollBehavior = "none";
|
||
html.style.backgroundColor = "#050505";
|
||
body.style.backgroundColor = "#050505";
|
||
|
||
if (scrollbarWidth > 0) {
|
||
body.style.paddingRight = `${currentBodyPaddingRight + scrollbarWidth}px`;
|
||
}
|
||
|
||
return () => {
|
||
html.style.overflow = previousHtmlOverflow;
|
||
body.style.overflow = previousBodyOverflow;
|
||
html.style.overscrollBehavior = previousHtmlOverscrollBehavior;
|
||
body.style.overscrollBehavior = previousBodyOverscrollBehavior;
|
||
html.style.backgroundColor = previousHtmlBackgroundColor;
|
||
body.style.backgroundColor = previousBodyBackgroundColor;
|
||
body.style.paddingRight = previousBodyPaddingRight;
|
||
};
|
||
}, [open]);
|
||
|
||
const selectedTemplateSummary = useMemo(
|
||
() =>
|
||
templates.find((template) => template.id === selectedTemplateId) || null,
|
||
[selectedTemplateId, templates]
|
||
);
|
||
const selectedTemplateDetail = useMemo(() => {
|
||
if (
|
||
!selectedTemplateDetailData?.success ||
|
||
!selectedTemplateDetailData.template
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return selectedTemplateDetailData.template.id === selectedTemplateId
|
||
? selectedTemplateDetailData.template
|
||
: null;
|
||
}, [selectedTemplateDetailData, selectedTemplateId]);
|
||
const selectedTemplate = useMemo(
|
||
() => selectedTemplateDetail || selectedTemplateSummary,
|
||
[selectedTemplateDetail, selectedTemplateSummary]
|
||
);
|
||
const selectedTemplateDetailError =
|
||
selectedTemplateDetailData?.success === false
|
||
? String(selectedTemplateDetailData.error || "").trim()
|
||
: "";
|
||
const isSelectedTemplateLoading =
|
||
Boolean(selectedTemplateSummary) &&
|
||
(selectedTemplateSummary?.layers.length || 0) === 0 &&
|
||
isSelectedTemplateDetailLoading;
|
||
const effectiveTemplateBrandName = useMemo(
|
||
() =>
|
||
normalizedBrandName || String(selectedTemplate?.brandName || "").trim(),
|
||
[normalizedBrandName, selectedTemplate?.brandName]
|
||
);
|
||
const textColorConfig = useMemo(
|
||
() =>
|
||
resolveAgentPsdTextColorConfigForBrand(
|
||
rawTextColorConfig,
|
||
effectiveTemplateBrandName
|
||
),
|
||
[effectiveTemplateBrandName, rawTextColorConfig]
|
||
);
|
||
const shouldShowTemplateBrand = useMemo(
|
||
() =>
|
||
!normalizedBrandName &&
|
||
templates.some((template) =>
|
||
Boolean(normalizeManagedBrandName(template.brandName))
|
||
),
|
||
[normalizedBrandName, templates]
|
||
);
|
||
const selectedCategorySummaryText = useMemo(
|
||
() => normalizedSelectedCategoryNames.join(" / "),
|
||
[normalizedSelectedCategoryNames]
|
||
);
|
||
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 selectedTemplateLayerById = useMemo(
|
||
() => new Map(selectedTemplateLayers.map((layer) => [layer.id, layer])),
|
||
[selectedTemplateLayers]
|
||
);
|
||
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 preferredInitialTemplate = useMemo(() => {
|
||
if (normalizedTemplateMode === "sku" && skuModeTemplates.length > 0) {
|
||
return skuModeTemplates[0] || null;
|
||
}
|
||
|
||
if (normalizedTemplateMode === "main" && mainModeTemplates.length > 0) {
|
||
return mainModeTemplates[0] || null;
|
||
}
|
||
|
||
if (mainModeTemplates.length > 0) {
|
||
return mainModeTemplates[0] || null;
|
||
}
|
||
|
||
if (skuModeTemplates.length > 0) {
|
||
return skuModeTemplates[0] || null;
|
||
}
|
||
|
||
return templates[0] || null;
|
||
}, [mainModeTemplates, normalizedTemplateMode, skuModeTemplates, templates]);
|
||
|
||
const editableImageLayers = useMemo(
|
||
() =>
|
||
selectedTemplateLayers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer) &&
|
||
isAgentPsdImageLayerSlot(layer)
|
||
),
|
||
[selectedTemplateLayers]
|
||
);
|
||
const editableTextLayers = useMemo(
|
||
() =>
|
||
selectedTemplateLayers.filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer) &&
|
||
isAgentPsdTextLayerSlot(layer)
|
||
),
|
||
[selectedTemplateLayers]
|
||
);
|
||
const orderedTemplateLayers = useMemo(() => {
|
||
if (selectedTemplateLayers.length === 0) {
|
||
return [] as AgentPsdTemplateLayerSlot[];
|
||
}
|
||
|
||
const layerMap = new Map(
|
||
selectedTemplateLayers.map((layer) => [layer.id, layer])
|
||
);
|
||
const normalizedOrder = [
|
||
...layerOrderIds
|
||
.map((layerId) => layerMap.get(layerId) || null)
|
||
.filter((layer): layer is AgentPsdTemplateLayerSlot => Boolean(layer)),
|
||
...selectedTemplateLayers.filter((layer) => !layerOrderIds.includes(layer.id)),
|
||
];
|
||
|
||
return normalizedOrder;
|
||
}, [layerOrderIds, selectedTemplateLayers]);
|
||
const orderedPreviewLayers = useMemo(
|
||
() => [...orderedTemplateLayers].reverse(),
|
||
[orderedTemplateLayers]
|
||
);
|
||
const hiddenLayerIdSet = useMemo(
|
||
() =>
|
||
new Set(
|
||
hiddenLayerIds
|
||
.map((layerId) => String(layerId || "").trim())
|
||
.filter(Boolean)
|
||
),
|
||
[hiddenLayerIds]
|
||
);
|
||
useEffect(() => {
|
||
const viewport = getScrollAreaViewportElement(
|
||
hiddenLayerScrollAreaRef.current
|
||
);
|
||
if (!viewport) {
|
||
setCanScrollHiddenLayersUp(false);
|
||
setCanScrollHiddenLayersDown(false);
|
||
return;
|
||
}
|
||
|
||
const updateScrollButtons = () => {
|
||
const nextCanScrollUp = viewport.scrollTop > 4;
|
||
const nextCanScrollDown =
|
||
viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 4;
|
||
setCanScrollHiddenLayersUp(nextCanScrollUp);
|
||
setCanScrollHiddenLayersDown(nextCanScrollDown);
|
||
};
|
||
|
||
updateScrollButtons();
|
||
viewport.addEventListener("scroll", updateScrollButtons, { passive: true });
|
||
const animationFrameId = window.requestAnimationFrame(updateScrollButtons);
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(animationFrameId);
|
||
viewport.removeEventListener("scroll", updateScrollButtons);
|
||
};
|
||
}, [
|
||
hiddenLayerIds.length,
|
||
open,
|
||
orderedPreviewLayers.length,
|
||
selectedTemplate?.id,
|
||
]);
|
||
const orderedVisiblePreviewLayers = useMemo(
|
||
() =>
|
||
orderedPreviewLayers.filter((layer) => !hiddenLayerIdSet.has(layer.id)),
|
||
[hiddenLayerIdSet, orderedPreviewLayers]
|
||
);
|
||
const orderedEditableLayers = useMemo(
|
||
() =>
|
||
orderedPreviewLayers.filter(
|
||
(layer) => layer.isEditable && !isAgentPsdLayerSuperLocked(layer)
|
||
),
|
||
[orderedPreviewLayers]
|
||
);
|
||
const orderedUserManageablePreviewLayers = useMemo(
|
||
() =>
|
||
orderedPreviewLayers.filter((layer) => !isAgentPsdLayerSuperLocked(layer)),
|
||
[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 layerStackZIndexById = useMemo(() => {
|
||
return new Map(
|
||
orderedPreviewLayers.map((layer, index) => [
|
||
layer.id,
|
||
(index + 1) * PSD_LAYER_Z_INDEX_STEP,
|
||
])
|
||
);
|
||
}, [orderedPreviewLayers]);
|
||
const renderBaseZIndex = useMemo(() => {
|
||
const defaultSourceImageLayer = getDefaultSourceImageLayer(
|
||
selectedTemplateLayers
|
||
);
|
||
const defaultSourceImageZIndex = defaultSourceImageLayer
|
||
? layerStackZIndexById.get(defaultSourceImageLayer.id) || 0
|
||
: 0;
|
||
return defaultSourceImageZIndex + PSD_RENDER_BASE_Z_INDEX_OFFSET;
|
||
}, [layerStackZIndexById, selectedTemplateLayers]);
|
||
const shouldShowCompositeFallback = useMemo(
|
||
() =>
|
||
orderedVisiblePreviewLayers.length === 0 ||
|
||
orderedVisiblePreviewLayers.some(
|
||
(layer) => !getPsdLayerPreviewSourceUrl(layer)
|
||
),
|
||
[orderedVisiblePreviewLayers]
|
||
);
|
||
|
||
const effectiveImageAssignments = useMemo(() => {
|
||
const nextAssignments: Record<string, string> = {};
|
||
if (!selectedTemplate) {
|
||
return nextAssignments;
|
||
}
|
||
|
||
const defaultSourceImageLayerId = sourceImageUrl?.trim()
|
||
? getDefaultSourceImageLayer(editableImageLayers)?.id || ""
|
||
: "";
|
||
|
||
for (const layer of editableImageLayers) {
|
||
const explicit = String(imageAssignments[layer.id] || "").trim();
|
||
if (explicit) {
|
||
nextAssignments[layer.id] = explicit;
|
||
continue;
|
||
}
|
||
|
||
if (layer.id === defaultSourceImageLayerId && 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 effectiveTextPositionOffsets = useMemo(() => {
|
||
const nextOffsets: Record<string, AgentPsdTextPositionOffset> = {};
|
||
if (!selectedTemplate) {
|
||
return nextOffsets;
|
||
}
|
||
|
||
for (const layer of editableTextLayers) {
|
||
nextOffsets[layer.id] = normalizeTextPositionOffset(
|
||
textPositionOffsets[layer.id]
|
||
);
|
||
}
|
||
|
||
return nextOffsets;
|
||
}, [editableTextLayers, selectedTemplate, textPositionOffsets]);
|
||
const effectiveLayerPositionOffsets = useMemo(() => {
|
||
const nextOffsets: Record<string, AgentPsdLayerPositionOffset> = {};
|
||
if (!selectedTemplate) {
|
||
return nextOffsets;
|
||
}
|
||
|
||
for (const layer of selectedTemplateLayers) {
|
||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||
continue;
|
||
}
|
||
nextOffsets[layer.id] = normalizeLayerPositionOffset(
|
||
layerPositionOffsets[layer.id]
|
||
);
|
||
}
|
||
|
||
return nextOffsets;
|
||
}, [layerPositionOffsets, selectedTemplate, selectedTemplateLayers]);
|
||
|
||
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(() => {
|
||
const layer =
|
||
selectedTemplateLayers.find((entry) => entry.id === selectedLayerId) ||
|
||
null;
|
||
return layer && !isAgentPsdLayerSuperLocked(layer) ? layer : null;
|
||
}, [selectedLayerId, selectedTemplateLayers]);
|
||
const isSelectedLayerHidden = Boolean(
|
||
selectedLayer && hiddenLayerIdSet.has(selectedLayer.id)
|
||
);
|
||
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 selectedTextPositionOffset = selectedTextLayer
|
||
? effectiveTextPositionOffsets[selectedTextLayer.id] ||
|
||
normalizeTextPositionOffset()
|
||
: normalizeTextPositionOffset();
|
||
const isSelectedImageExtending = Boolean(
|
||
selectedImageLayer && extendingSlotId === selectedImageLayer.id
|
||
);
|
||
|
||
function hideLayerTemporarily(layerId: string) {
|
||
const normalizedLayerId = String(layerId || "").trim();
|
||
if (!normalizedLayerId) {
|
||
return;
|
||
}
|
||
const layer = selectedTemplateLayerById.get(normalizedLayerId);
|
||
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
|
||
return;
|
||
}
|
||
setHiddenLayerIds((current) =>
|
||
current.includes(normalizedLayerId)
|
||
? current
|
||
: [...current, normalizedLayerId]
|
||
);
|
||
}
|
||
|
||
function restoreHiddenLayer(layerId: string) {
|
||
const normalizedLayerId = String(layerId || "").trim();
|
||
if (!normalizedLayerId) {
|
||
return;
|
||
}
|
||
const layer = selectedTemplateLayerById.get(normalizedLayerId);
|
||
if (layer && isAgentPsdLayerSuperLocked(layer)) {
|
||
return;
|
||
}
|
||
setHiddenLayerIds((current) =>
|
||
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
|
||
);
|
||
}
|
||
const selectedLayerPositionPanel = selectedLayer ? (
|
||
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="font-semibold text-sm text-white">图层层级</div>
|
||
<div className="mt-1.5 text-white/50 text-xs">
|
||
选中图层后可调整与其他图层的上下关系。
|
||
</div>
|
||
</div>
|
||
<Move className="h-5 w-5 shrink-0 text-emerald-300" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="h-10 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() => moveLayerOrder(selectedLayer.id, "forward")}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
上移一层
|
||
</Button>
|
||
<Button
|
||
className="h-10 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() => moveLayerOrder(selectedLayer.id, "backward")}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
下移一层
|
||
</Button>
|
||
<Button
|
||
className="h-10 justify-center rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() => moveLayerOrder(selectedLayer.id, "front")}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
置于最上
|
||
</Button>
|
||
<Button
|
||
className="h-10 justify-center rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() => moveLayerOrder(selectedLayer.id, "back")}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
置于最下
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
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-2 gap-2">
|
||
{sourceImageUrl ? (
|
||
<Button
|
||
className="h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-2.5 text-xs text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400"
|
||
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 whitespace-nowrap">使用当前图片</span>
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
className="h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-2.5 text-xs text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400 disabled:opacity-50"
|
||
disabled={
|
||
uploadingSlotId === selectedImageLayer.id ||
|
||
isSelectedImageExtending
|
||
}
|
||
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 whitespace-nowrap">上传替换图片</span>
|
||
</Button>
|
||
<Button
|
||
className={`h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-2.5 text-xs text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400 disabled:opacity-50 ${
|
||
sourceImageUrl ? "col-span-2" : ""
|
||
}`}
|
||
disabled={
|
||
!selectedImageAssignmentUrl || Boolean(isSelectedImageExtending)
|
||
}
|
||
onClick={() =>
|
||
void handleImageExtend(
|
||
selectedImageLayer.id,
|
||
selectedImageAssignmentUrl
|
||
)
|
||
}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isSelectedImageExtending ? (
|
||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||
) : (
|
||
<Expand className="h-4 w-4 shrink-0" />
|
||
)}
|
||
<span className="min-w-0 whitespace-nowrap">图片拓展</span>
|
||
</Button>
|
||
</div>
|
||
|
||
{selectedImageAssignmentUrl ? (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="h-10 w-full rounded-lg border-white/10 bg-white/[0.02] px-2.5 text-xs text-white/70 transition-all hover:border-white/20 hover:bg-white/[0.06] hover:text-white"
|
||
onClick={() => resetImagePlacement(selectedImageLayer.id)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
重置位置
|
||
</Button>
|
||
<Button
|
||
className="h-10 w-full rounded-lg px-2.5 text-xs text-red-400/80 transition-all hover:bg-red-500/10 hover:text-red-400"
|
||
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-white/70 text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<ZoomIn className="h-4 w-4" />
|
||
<span className="font-medium">图片缩放</span>
|
||
</div>
|
||
<span className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 font-medium font-mono text-[11px] text-emerald-400">
|
||
{Math.round(selectedImagePlacement.scale * 100)}%
|
||
</span>
|
||
</div>
|
||
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-3 shadow-inner">
|
||
<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/10 bg-white/[0.04] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white"
|
||
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-500 to-emerald-400"
|
||
step={0.01}
|
||
thumbClassName="h-4 w-4 border-2 border-[#1a1a1a] bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)] hover:scale-110 transition-transform"
|
||
trackClassName="h-1.5 rounded-full bg-white/10"
|
||
value={[selectedImagePlacement.scale]}
|
||
/>
|
||
<button
|
||
aria-label="放大图片"
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white"
|
||
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="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-4 shadow-inner">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="font-semibold text-sm text-white">
|
||
槽位内图片位置
|
||
</div>
|
||
<div className="mt-1.5 text-white/50 text-xs">
|
||
图层整体拖动已经改到中间预览区;这里保留槽位内图片的细调。
|
||
</div>
|
||
</div>
|
||
<ImageIcon className="h-5 w-5 shrink-0 text-orange-300" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1.5">
|
||
<Label className="text-white/50 text-xs">水平 X</Label>
|
||
<Input
|
||
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
|
||
inputMode="numeric"
|
||
onChange={(event) => {
|
||
const nextValue = Number(event.target.value);
|
||
if (!Number.isFinite(nextValue)) return;
|
||
updateImagePlacement(selectedImageLayer.id, (current) => ({
|
||
...current,
|
||
offsetX: nextValue,
|
||
}));
|
||
}}
|
||
type="number"
|
||
value={Math.round(selectedImagePlacement.offsetX)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-white/50 text-xs">垂直 Y</Label>
|
||
<Input
|
||
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
|
||
inputMode="numeric"
|
||
onChange={(event) => {
|
||
const nextValue = Number(event.target.value);
|
||
if (!Number.isFinite(nextValue)) return;
|
||
updateImagePlacement(selectedImageLayer.id, (current) => ({
|
||
...current,
|
||
offsetY: nextValue,
|
||
}));
|
||
}}
|
||
type="number"
|
||
value={Math.round(selectedImagePlacement.offsetY)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||
<div />
|
||
<Button
|
||
className="h-9 w-12 justify-self-center rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
nudgeImagePlacementOffset(selectedImageLayer.id, 0, -1)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
↑
|
||
</Button>
|
||
<div />
|
||
<Button
|
||
className="h-9 w-12 justify-self-center rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
nudgeImagePlacementOffset(selectedImageLayer.id, -1, 0)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
←
|
||
</Button>
|
||
<Button
|
||
className="h-9 justify-self-center rounded-xl border-white/10 bg-white/[0.04] px-3 text-white/60 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
updateImagePlacement(selectedImageLayer.id, (current) => ({
|
||
...current,
|
||
offsetX: 0,
|
||
offsetY: 0,
|
||
}))
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
归零
|
||
</Button>
|
||
<Button
|
||
className="h-9 w-12 justify-self-center rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
nudgeImagePlacementOffset(selectedImageLayer.id, 1, 0)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
→
|
||
</Button>
|
||
<div />
|
||
<Button
|
||
className="h-9 w-12 justify-self-center rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
nudgeImagePlacementOffset(selectedImageLayer.id, 0, 1)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
↓
|
||
</Button>
|
||
<div />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end">
|
||
<Button
|
||
className="rounded-lg px-3 text-white/60 transition-all hover:bg-white/5 hover:text-white/90"
|
||
onClick={() => resetImagePlacement(selectedImageLayer.id)}
|
||
size="sm"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
重置位置与缩放
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null;
|
||
const textLayerQuickColorPanel =
|
||
editableTextLayers.length > 0 ? (
|
||
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-4 shadow-inner">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 font-semibold text-sm text-white">
|
||
<Type className="h-4 w-4 text-emerald-300" />
|
||
<span>文字颜色快捷编辑</span>
|
||
</div>
|
||
<p className="mt-1 text-[11px] leading-5 text-white/45">
|
||
这里会显示当前模板里所有可编辑文字,直接改色;点击文字名称可进入位置微调。
|
||
</p>
|
||
</div>
|
||
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-white/50">
|
||
{editableTextLayers.length} 个文字
|
||
</span>
|
||
</div>
|
||
<div className="max-h-[240px] space-y-2 overflow-y-auto pr-1">
|
||
{editableTextLayers.map((layer) => {
|
||
const currentColor = getResolvedTextLayerColor(
|
||
layer,
|
||
effectiveTextColorAssignments[layer.id] || layer.textColor
|
||
);
|
||
const previewText = getResolvedTextPreview(
|
||
layer,
|
||
effectiveTextAssignments[layer.id]
|
||
);
|
||
const isActive = selectedLayerId === layer.id;
|
||
|
||
return (
|
||
<div
|
||
className={`grid grid-cols-[minmax(0,1fr)_44px_auto] items-center gap-2 rounded-xl border px-3 py-2.5 transition-all ${
|
||
isActive
|
||
? "border-emerald-500/45 bg-emerald-500/10"
|
||
: "border-white/[0.08] bg-black/15 hover:border-white/15 hover:bg-white/[0.03]"
|
||
}`}
|
||
key={`quick-text-color-${layer.id}`}
|
||
>
|
||
<button
|
||
className="min-w-0 text-left"
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
title="选中后可编辑文字位置"
|
||
type="button"
|
||
>
|
||
<div className="truncate font-medium text-[12px] text-white/85">
|
||
{layer.layerName || layer.placeholder || "文字图层"}
|
||
</div>
|
||
<div className="mt-0.5 truncate text-[11px] text-white/38">
|
||
{previewText}
|
||
</div>
|
||
</button>
|
||
<input
|
||
aria-label={`${layer.layerName || layer.placeholder || "文字图层"}颜色`}
|
||
className="h-9 w-10 cursor-pointer rounded-lg border border-white/15 bg-transparent p-1"
|
||
onChange={(event) =>
|
||
updateTextColorAssignment(layer.id, event.target.value)
|
||
}
|
||
type="color"
|
||
value={currentColor}
|
||
/>
|
||
<button
|
||
className="rounded-lg px-2 py-1 text-[11px] text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/80"
|
||
onClick={() =>
|
||
updateTextColorAssignment(layer.id, layer.textColor)
|
||
}
|
||
type="button"
|
||
>
|
||
原色
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
const fontFaceCss = useMemo(
|
||
() => buildPreviewFontFaces(selectedTemplate),
|
||
[selectedTemplate]
|
||
);
|
||
const selectedTemplateSelectValue = selectedTemplate?.id;
|
||
const hasTemplateOptions = templates.length > 0;
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined" || !templateListPrefetchUrl) {
|
||
return;
|
||
}
|
||
|
||
const preloadTemplates = () => {
|
||
void preload(templateListPrefetchUrl, fetcher);
|
||
};
|
||
|
||
if (typeof window.requestIdleCallback === "function") {
|
||
const idleId = window.requestIdleCallback(preloadTemplates, {
|
||
timeout: 1200,
|
||
});
|
||
return () => window.cancelIdleCallback?.(idleId);
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(preloadTemplates, 300);
|
||
return () => window.clearTimeout(timeoutId);
|
||
}, [templateListPrefetchUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!preferredInitialTemplate) {
|
||
return;
|
||
}
|
||
|
||
const detailUrl = `/api/psd-templates/${encodeURIComponent(preferredInitialTemplate.id)}`;
|
||
void preload(detailUrl, templateDetailFetcher);
|
||
}, [preferredInitialTemplate]);
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
return;
|
||
}
|
||
|
||
setSelectedTemplateId("");
|
||
setSelectedLayerId("");
|
||
setImageAssignments({});
|
||
setImagePlacements({});
|
||
setImageSizes({});
|
||
setTextAssignments({});
|
||
setTextColorAssignments({});
|
||
setTextPositionOffsets({});
|
||
setHiddenLayerIds([]);
|
||
setRenderedResultUrl("");
|
||
setRightPanelView("editor");
|
||
previousSelectedTemplateRef.current = null;
|
||
}, [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 = createTemplateEditorState({
|
||
nextTemplate: selectedTemplate,
|
||
previousTemplate: previousSelectedTemplateRef.current,
|
||
sourceImageUrl: sourceImageUrl || "",
|
||
previousImageAssignments: imageAssignmentsRef.current,
|
||
previousImagePlacements: imagePlacementsRef.current,
|
||
previousLayerPositionOffsets: layerPositionOffsetsRef.current,
|
||
previousTextAssignments: textAssignmentsRef.current,
|
||
previousTextColorAssignments: textColorAssignmentsRef.current,
|
||
previousTextPositionOffsets: textPositionOffsetsRef.current,
|
||
});
|
||
setImageAssignments(initialState.imageAssignments);
|
||
setImagePlacements(initialState.imagePlacements);
|
||
setLayerOrderIds(initialState.layerOrderIds);
|
||
setLayerPositionOffsets(initialState.layerPositionOffsets);
|
||
setTextAssignments(initialState.textAssignments);
|
||
setTextColorAssignments(initialState.textColorAssignments);
|
||
setTextPositionOffsets(initialState.textPositionOffsets);
|
||
setHiddenLayerIds([]);
|
||
setSelectedLayerId(
|
||
resolveTransferredSelectedLayerId({
|
||
nextTemplate: selectedTemplate,
|
||
previousTemplate: previousSelectedTemplateRef.current,
|
||
previousSelectedLayerId: selectedLayerIdRef.current,
|
||
})
|
||
);
|
||
|
||
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, sourceImageUrl]);
|
||
|
||
useEffect(() => {
|
||
if (hiddenLayerIds.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const validLayerIds = new Set(
|
||
selectedTemplateLayers
|
||
.filter((layer) => !isAgentPsdLayerSuperLocked(layer))
|
||
.map((layer) => layer.id)
|
||
);
|
||
setHiddenLayerIds((current) =>
|
||
current.filter((layerId) => validLayerIds.has(layerId))
|
||
);
|
||
}, [hiddenLayerIds.length, selectedTemplateLayers]);
|
||
|
||
useEffect(() => {
|
||
previousSelectedTemplateRef.current = selectedTemplate;
|
||
}, [selectedTemplate]);
|
||
|
||
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]);
|
||
|
||
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 parseActionJsonResponse<{ url?: string }>(
|
||
response,
|
||
"素材上传失败",
|
||
getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试")
|
||
);
|
||
|
||
if (!payload?.url) {
|
||
throw new Error("图片上传失败");
|
||
}
|
||
const uploadedImageUrl = payload.url;
|
||
|
||
setImageAssignments((current) => ({
|
||
...current,
|
||
[slotId]: uploadedImageUrl,
|
||
}));
|
||
setImagePlacements((current) => ({
|
||
...current,
|
||
[slotId]: getDefaultAgentPsdImagePlacement(),
|
||
}));
|
||
setImageSizes((current) => {
|
||
const next = { ...current };
|
||
delete next[slotId];
|
||
return next;
|
||
});
|
||
toast.success("替换图片已上传");
|
||
} catch (error) {
|
||
toast.error(
|
||
getUserFacingRequestErrorMessage(error, "素材上传失败", {
|
||
rateLimitMessage: getFriendlyRateLimitMessage(
|
||
"当前上传过于频繁,请稍后再试"
|
||
),
|
||
})
|
||
);
|
||
} finally {
|
||
setUploadingSlotId(null);
|
||
event.target.value = "";
|
||
}
|
||
}
|
||
|
||
async function handleImageExtend(slotId: string, imageUrl: string) {
|
||
const normalizedImageUrl = String(imageUrl || "").trim();
|
||
if (!normalizedImageUrl) {
|
||
toast.error("请先选择或上传一张替换图片");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setExtendingSlotId(slotId);
|
||
const response = await fetch("/api/extend", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
image_url: normalizedImageUrl,
|
||
prompt: "PSD 套版图片拓展处理",
|
||
includeBase64: false,
|
||
}),
|
||
});
|
||
const payload = await parseActionJsonResponse<{
|
||
original_url?: string;
|
||
thumbnail_url?: string;
|
||
}>(
|
||
response,
|
||
"图片拓展失败",
|
||
getFriendlyRateLimitMessage("当前图片拓展请求过于频繁,请稍后再试")
|
||
);
|
||
|
||
const nextImageUrl = String(
|
||
payload?.original_url || payload?.thumbnail_url || ""
|
||
).trim();
|
||
if (!nextImageUrl) {
|
||
throw new Error("图片拓展成功,但未返回可用图片地址");
|
||
}
|
||
|
||
setImageAssignments((current) => ({
|
||
...current,
|
||
[slotId]: nextImageUrl,
|
||
}));
|
||
setImagePlacements((current) => ({
|
||
...current,
|
||
[slotId]: getDefaultAgentPsdImagePlacement(),
|
||
}));
|
||
setImageSizes((current) => {
|
||
const next = { ...current };
|
||
delete next[slotId];
|
||
return next;
|
||
});
|
||
onDerivedImageCreated?.(nextImageUrl);
|
||
toast.success(
|
||
onDerivedImageCreated
|
||
? "图片拓展完成,已替换当前槽位并同步保存到结果区"
|
||
: "图片拓展完成,已替换当前槽位"
|
||
);
|
||
} catch (error) {
|
||
toast.error(
|
||
getUserFacingRequestErrorMessage(error, "图片拓展失败", {
|
||
rateLimitMessage: getFriendlyRateLimitMessage(
|
||
"当前图片拓展请求过于频繁,请稍后再试"
|
||
),
|
||
})
|
||
);
|
||
} finally {
|
||
setExtendingSlotId(null);
|
||
}
|
||
}
|
||
|
||
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 nudgeImagePlacementOffset(
|
||
layerId: string,
|
||
deltaX: number,
|
||
deltaY: number
|
||
) {
|
||
updateImagePlacement(layerId, (current) => ({
|
||
...current,
|
||
offsetX: Number(((current.offsetX || 0) + deltaX).toFixed(2)),
|
||
offsetY: Number(((current.offsetY || 0) + deltaY).toFixed(2)),
|
||
}));
|
||
}
|
||
|
||
function resetImagePlacement(layerId: string) {
|
||
setImagePlacements((current) => ({
|
||
...current,
|
||
[layerId]: getDefaultAgentPsdImagePlacement(),
|
||
}));
|
||
}
|
||
|
||
function updateLayerPositionOffset(
|
||
layerId: string,
|
||
updater: (
|
||
current: AgentPsdLayerPositionOffset
|
||
) => Partial<AgentPsdLayerPositionOffset>
|
||
) {
|
||
const layer = selectedTemplateLayerById.get(String(layerId || "").trim());
|
||
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
|
||
return;
|
||
}
|
||
setLayerPositionOffsets((current) => ({
|
||
...current,
|
||
[layerId]: normalizeLayerPositionOffset({
|
||
...normalizeLayerPositionOffset(current[layerId]),
|
||
...updater(normalizeLayerPositionOffset(current[layerId])),
|
||
}),
|
||
}));
|
||
}
|
||
|
||
function moveLayerOrder(
|
||
layerId: string,
|
||
direction: "forward" | "backward" | "front" | "back"
|
||
) {
|
||
const normalizedLayerId = String(layerId || "").trim();
|
||
if (!normalizedLayerId) {
|
||
return;
|
||
}
|
||
const layer = selectedTemplateLayerById.get(normalizedLayerId);
|
||
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
|
||
return;
|
||
}
|
||
|
||
setLayerOrderIds((current) => {
|
||
const fallbackOrder = selectedTemplateLayers.map((layer) => layer.id);
|
||
const baseOrder = current.length > 0 ? [...current] : fallbackOrder;
|
||
const currentIndex = baseOrder.indexOf(normalizedLayerId);
|
||
if (currentIndex < 0) {
|
||
return current;
|
||
}
|
||
|
||
const nextOrder = [...baseOrder];
|
||
const [removed] = nextOrder.splice(currentIndex, 1);
|
||
if (!removed) {
|
||
return current;
|
||
}
|
||
|
||
if (direction === "front") {
|
||
nextOrder.push(removed);
|
||
} else if (direction === "back") {
|
||
nextOrder.unshift(removed);
|
||
} else if (direction === "forward") {
|
||
nextOrder.splice(Math.min(baseOrder.length - 1, currentIndex + 1), 0, removed);
|
||
} else {
|
||
nextOrder.splice(Math.max(0, currentIndex - 1), 0, removed);
|
||
}
|
||
|
||
return nextOrder;
|
||
});
|
||
}
|
||
|
||
function updateTextColorAssignment(layerId: string, color: string) {
|
||
setTextColorAssignments((current) => ({
|
||
...current,
|
||
[layerId]: normalizeHexColor(color, current[layerId] || "#111111"),
|
||
}));
|
||
}
|
||
|
||
function updateTextPositionOffset(
|
||
layerId: string,
|
||
updater: (
|
||
current: AgentPsdTextPositionOffset
|
||
) => Partial<AgentPsdTextPositionOffset>
|
||
) {
|
||
setTextPositionOffsets((current) => ({
|
||
...current,
|
||
[layerId]: normalizeTextPositionOffset({
|
||
...normalizeTextPositionOffset(current[layerId]),
|
||
...updater(normalizeTextPositionOffset(current[layerId])),
|
||
}),
|
||
}));
|
||
}
|
||
|
||
function nudgeTextPositionOffset(
|
||
layerId: string,
|
||
deltaX: number,
|
||
deltaY: number
|
||
) {
|
||
updateTextPositionOffset(layerId, (current) => ({
|
||
offsetX: current.offsetX + deltaX,
|
||
offsetY: current.offsetY + deltaY,
|
||
}));
|
||
}
|
||
|
||
function resetTextPositionOffset(layerId: string) {
|
||
setTextPositionOffsets((current) => {
|
||
const next = { ...current };
|
||
delete next[layerId];
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function handleHiddenLayerListScroll(direction: "up" | "down") {
|
||
const viewport = getScrollAreaViewportElement(
|
||
hiddenLayerScrollAreaRef.current
|
||
);
|
||
if (!viewport) {
|
||
return;
|
||
}
|
||
|
||
viewport.scrollBy({
|
||
top:
|
||
direction === "up"
|
||
? -HIDDEN_LAYER_SCROLL_STEP
|
||
: HIDDEN_LAYER_SCROLL_STEP,
|
||
behavior: "smooth",
|
||
});
|
||
}
|
||
|
||
async function archiveRenderImageUrl(
|
||
rawUrl: string,
|
||
label: string
|
||
): Promise<string> {
|
||
const normalizedUrl = String(rawUrl || "").trim();
|
||
if (!normalizedUrl || isLikelyStableRenderImageUrl(normalizedUrl)) {
|
||
return normalizedUrl;
|
||
}
|
||
|
||
const cachedUrl = archivedRenderImageUrlCacheRef.current[normalizedUrl];
|
||
if (cachedUrl) {
|
||
return cachedUrl;
|
||
}
|
||
const cachedPromise =
|
||
archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
|
||
if (cachedPromise) {
|
||
return cachedPromise;
|
||
}
|
||
|
||
const archivePromise = (async () => {
|
||
const fetchCandidates = Array.from(
|
||
new Set(
|
||
[
|
||
normalizedUrl,
|
||
buildImageProxyUrl(normalizedUrl, { delivery: "proxy" }),
|
||
].filter(Boolean)
|
||
)
|
||
);
|
||
|
||
let lastError: unknown = null;
|
||
for (const candidateUrl of fetchCandidates) {
|
||
try {
|
||
const response = await fetch(candidateUrl, {
|
||
method: "GET",
|
||
cache: "no-store",
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`${label}读取失败(${response.status})`);
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
if (!blob.size) {
|
||
throw new Error(`${label}读取结果为空`);
|
||
}
|
||
|
||
const extension = inferImageExtension(
|
||
normalizedUrl,
|
||
blob.type || response.headers.get("content-type") || ""
|
||
);
|
||
const formData = new FormData();
|
||
formData.set(
|
||
"file",
|
||
new File(
|
||
[blob],
|
||
`psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`,
|
||
{
|
||
type: blob.type || "image/png",
|
||
}
|
||
)
|
||
);
|
||
|
||
const uploadResponse = await fetchUploadWithRetry(formData);
|
||
const uploadPayload = await parseActionJsonResponse<{ url?: string }>(
|
||
uploadResponse,
|
||
`${label}转存失败`,
|
||
getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试")
|
||
);
|
||
const uploadedUrl = String(uploadPayload?.url || "").trim();
|
||
if (!uploadedUrl) {
|
||
throw new Error(`${label}转存失败`);
|
||
}
|
||
|
||
archivedRenderImageUrlCacheRef.current[normalizedUrl] = uploadedUrl;
|
||
return uploadedUrl;
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
getUserFacingRequestErrorMessage(
|
||
lastError,
|
||
`${label}无法转存到本站存储,请重新上传图片后再试`
|
||
)
|
||
);
|
||
})();
|
||
|
||
archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl] =
|
||
archivePromise;
|
||
|
||
try {
|
||
return await archivePromise;
|
||
} finally {
|
||
delete archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
|
||
}
|
||
}
|
||
|
||
async function normalizeRenderImageInputs() {
|
||
let nextSourceImageUrl = sourceImageUrl || "";
|
||
const nextImageAssignments = { ...effectiveImageAssignments };
|
||
const layerNameById = new Map(
|
||
editableImageLayers.map((layer) => [layer.id, layer.layerName])
|
||
);
|
||
const archiveJobs = [
|
||
{
|
||
type: "source" as const,
|
||
layerId: "",
|
||
url: sourceImageUrl || "",
|
||
label: "PSD 套版主图",
|
||
},
|
||
...Object.entries(nextImageAssignments).map(([layerId, layerUrl]) => ({
|
||
type: "assignment" as const,
|
||
layerId,
|
||
url: layerUrl,
|
||
label: `PSD 图层素材「${layerNameById.get(layerId) || layerId}」`,
|
||
})),
|
||
].filter((job) => String(job.url || "").trim());
|
||
|
||
const archivedJobs = await mapWithConcurrency(
|
||
archiveJobs,
|
||
PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY,
|
||
async (job) => ({
|
||
...job,
|
||
archivedUrl: await archiveRenderImageUrl(job.url, job.label),
|
||
})
|
||
);
|
||
|
||
for (const job of archivedJobs) {
|
||
if (job.type === "source") {
|
||
nextSourceImageUrl = job.archivedUrl;
|
||
} else {
|
||
nextImageAssignments[job.layerId] = job.archivedUrl;
|
||
}
|
||
}
|
||
|
||
return {
|
||
sourceImageUrl: nextSourceImageUrl,
|
||
imageAssignments: nextImageAssignments,
|
||
};
|
||
}
|
||
|
||
function handleLayerDragStart(
|
||
layer: AgentPsdTemplateLayerSlot,
|
||
event: ReactPointerEvent<HTMLElement>
|
||
) {
|
||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setSelectedLayerId(layer.id);
|
||
|
||
layerDragStateRef.current = {
|
||
layerId: layer.id,
|
||
pointerId: event.pointerId,
|
||
startClientX: event.clientX,
|
||
startClientY: event.clientY,
|
||
startOffset: normalizeLayerPositionOffset(
|
||
effectiveLayerPositionOffsets[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 = layerDragStateRef.current;
|
||
if (!dragState || dragState.layerId !== layer.id) {
|
||
return;
|
||
}
|
||
|
||
updateLayerPositionOffset(layer.id, () => ({
|
||
offsetX:
|
||
dragState.startOffset.offsetX +
|
||
(moveEvent.clientX - dragState.startClientX) / stageScale,
|
||
offsetY:
|
||
dragState.startOffset.offsetY +
|
||
(moveEvent.clientY - dragState.startClientY) / stageScale,
|
||
}));
|
||
};
|
||
|
||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||
if (layerDragStateRef.current?.pointerId === upEvent.pointerId) {
|
||
layerDragStateRef.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);
|
||
}
|
||
|
||
function handleTextDragStart(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
event: ReactPointerEvent<HTMLDivElement>
|
||
) {
|
||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setSelectedLayerId(layer.id);
|
||
|
||
textDragStateRef.current = {
|
||
layerId: layer.id,
|
||
pointerId: event.pointerId,
|
||
startClientX: event.clientX,
|
||
startClientY: event.clientY,
|
||
startOffset: normalizeTextPositionOffset(
|
||
effectiveTextPositionOffsets[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 = textDragStateRef.current;
|
||
if (!dragState || dragState.layerId !== layer.id) {
|
||
return;
|
||
}
|
||
|
||
updateTextPositionOffset(layer.id, () => ({
|
||
offsetX:
|
||
dragState.startOffset.offsetX +
|
||
(moveEvent.clientX - dragState.startClientX) / stageScale,
|
||
offsetY:
|
||
dragState.startOffset.offsetY +
|
||
(moveEvent.clientY - dragState.startClientY) / stageScale,
|
||
}));
|
||
};
|
||
|
||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||
if (textDragStateRef.current?.pointerId === upEvent.pointerId) {
|
||
textDragStateRef.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;
|
||
}
|
||
if (selectedTemplate.layers.length === 0) {
|
||
toast.error("模板详情仍在加载,请稍候再试");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsRendering(true);
|
||
setRenderedResultUrl("");
|
||
const normalizedRenderInputs = await normalizeRenderImageInputs();
|
||
if (normalizedRenderInputs.sourceImageUrl !== (sourceImageUrl || "")) {
|
||
const defaultSourceLayer = getDefaultSourceImageLayer(editableImageLayers);
|
||
if (defaultSourceLayer) {
|
||
setImageAssignments((current) => ({
|
||
...current,
|
||
[defaultSourceLayer.id]: normalizedRenderInputs.sourceImageUrl,
|
||
}));
|
||
}
|
||
}
|
||
setImageAssignments((current) => ({
|
||
...current,
|
||
...normalizedRenderInputs.imageAssignments,
|
||
}));
|
||
const payload = (await requestLongTaskResult({
|
||
endpoint: "/api/psd-templates/render",
|
||
timeoutMs: 8 * 60 * 1000,
|
||
maxPollFailures: 8,
|
||
payload: {
|
||
templateId: selectedTemplate.id,
|
||
sourceImageUrl: normalizedRenderInputs.sourceImageUrl,
|
||
hiddenLayerIds,
|
||
layerOrderIds,
|
||
imageAssignments: normalizedRenderInputs.imageAssignments,
|
||
imagePlacements: effectiveImagePlacements,
|
||
textAssignments: effectiveTextAssignments,
|
||
textColorAssignments: effectiveTextColorAssignments,
|
||
layerPositionOffsets: effectiveLayerPositionOffsets,
|
||
textPositionOffsets: effectiveTextPositionOffsets,
|
||
},
|
||
})) as AgentPsdTemplateRenderResponse;
|
||
if (!payload?.success || !payload.result?.url) {
|
||
throw new Error(payload?.error || "PSD 模板渲染失败");
|
||
}
|
||
|
||
setRenderedResultUrl(payload.result.url);
|
||
setRightPanelView("export");
|
||
onRenderedResultCreated?.(payload.result.url);
|
||
toast.success(
|
||
onRenderedResultCreated
|
||
? "模板替换结果已生成,并已加入结果区"
|
||
: "模板替换结果已生成"
|
||
);
|
||
} catch (error) {
|
||
toast.error(
|
||
getUserFacingRequestErrorMessage(error, "PSD 模板渲染失败", {
|
||
rateLimitMessage: getFriendlyRateLimitMessage(
|
||
"当前 PSD 渲染请求过于频繁,请稍后再试"
|
||
),
|
||
})
|
||
);
|
||
} finally {
|
||
setIsRendering(false);
|
||
}
|
||
}
|
||
|
||
function handleSwitchSelectedTemplateMode(nextMode: AgentPsdTemplateMode) {
|
||
if (selectedTemplateMode === nextMode) {
|
||
return;
|
||
}
|
||
|
||
const nextTemplates =
|
||
nextMode === "sku" ? skuModeTemplates : mainModeTemplates;
|
||
if (nextTemplates.length === 0) {
|
||
toast.error(
|
||
selectedCategorySummaryText
|
||
? nextMode === "sku"
|
||
? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板`
|
||
: `当前品类“${selectedCategorySummaryText}”下暂无主图模式模板`
|
||
: nextMode === "sku"
|
||
? "当前品牌下暂无 SKU 图模式模板"
|
||
: "当前品牌下暂无主图模式模板"
|
||
);
|
||
return;
|
||
}
|
||
|
||
setSelectedTemplateId(nextTemplates[0].id);
|
||
}
|
||
|
||
function renderTemplatePoolSection({
|
||
mode,
|
||
templates: poolTemplates,
|
||
}: {
|
||
mode: AgentPsdTemplateMode;
|
||
templates: AgentPsdTemplate[];
|
||
}) {
|
||
const isSkuMode = mode === "sku";
|
||
const title = isSkuMode ? "SKU图模式模板" : "主图模式模板";
|
||
const description = selectedCategorySummaryText
|
||
? `当前按品类“${selectedCategorySummaryText}”筛选,同时保留“适合所有”的模板。`
|
||
: normalizedBrandName
|
||
? `这里集中展示当前品牌下的${isSkuMode ? " SKU 图" : "主图"}模式模板。`
|
||
: `当前未选择品牌,这里展示全部品牌下的${isSkuMode ? " SKU 图" : "主图"}模式模板。`;
|
||
const emptyMessage = selectedCategorySummaryText
|
||
? `当前品类“${selectedCategorySummaryText}”下暂无${isSkuMode ? " SKU 图" : "主图"}模式模板`
|
||
: normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下暂无${isSkuMode ? " SKU 图" : "主图"}模式模板`
|
||
: `当前暂无可用的${isSkuMode ? " SKU 图" : "主图"}模式模板,请先去后台配置。`;
|
||
const loadingMessage = `正在加载${isSkuMode ? " SKU 图" : "主图"}模式模板...`;
|
||
const isCurrentResultMode = normalizedTemplateMode === mode;
|
||
|
||
return (
|
||
<section className="flex min-h-[560px] min-w-0 flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#141416] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_18px_45px_rgba(0,0,0,0.22)]">
|
||
<div className="flex items-start justify-between gap-4 border-white/[0.07] border-b pb-4">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="font-semibold text-base text-white">{title}</h3>
|
||
{isCurrentResultMode ? (
|
||
<span className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2 py-0.5 font-medium text-[11px] text-emerald-300">
|
||
当前模式
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-2 text-white/50 text-xs leading-relaxed">
|
||
{description}
|
||
</p>
|
||
</div>
|
||
<div className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-medium text-white/70 text-xs">
|
||
{poolTemplates.length} 个模板
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
|
||
{isLoading ? (
|
||
<div className="grid gap-3">
|
||
{Array.from({ length: 3 }).map((_, index) => (
|
||
<div
|
||
className="rounded-xl border border-white/10 border-dashed bg-white/[0.02] px-4 py-5 text-center text-sm text-white/50 shadow-inner"
|
||
key={`${mode}-template-loading-${index}`}
|
||
>
|
||
{loadingMessage}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : poolTemplates.length > 0 ? (
|
||
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
|
||
{poolTemplates.map((template, index) => (
|
||
<button
|
||
className={`rounded-xl border p-3 text-left transition-all duration-200 ${
|
||
selectedTemplateId === template.id
|
||
? "border-emerald-500/50 bg-emerald-500/10 shadow-[0_0_15px_rgba(16,185,129,0.1)]"
|
||
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
|
||
}`}
|
||
key={template.id}
|
||
onClick={() => setSelectedTemplateId(template.id)}
|
||
type="button"
|
||
>
|
||
{renderTemplatePickerCardContent(template, {
|
||
showSecondaryCategory:
|
||
shouldGroupTemplatesBySecondaryCategory,
|
||
showBrand: shouldShowTemplateBrand,
|
||
emphasize: true,
|
||
eager: index < 4,
|
||
})}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex min-h-[260px] flex-1 flex-col items-center justify-center rounded-xl border border-white/10 border-dashed bg-white/[0.02] px-6 py-10 text-center text-sm text-white/50 shadow-inner">
|
||
{emptyMessage}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function renderPendingImageQueue() {
|
||
if (normalizedPendingImages.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3 shadow-inner">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Images className="h-4 w-4 text-emerald-300" />
|
||
<div className="font-semibold text-sm text-white">
|
||
待处理图片
|
||
</div>
|
||
</div>
|
||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-white/60">
|
||
{normalizedPendingImages.length} 张
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
{normalizedPendingImages.map((image, index) => {
|
||
const isActive = activePendingImageId === image.id;
|
||
const renderedResultUrl = pendingRenderedResultUrls?.[image.id];
|
||
return (
|
||
<button
|
||
key={`pending-psd-image-${image.id}`}
|
||
type="button"
|
||
onClick={() => onActivePendingImageIdChange?.(image.id)}
|
||
className={`flex items-center gap-3 rounded-xl border p-2 text-left transition-colors ${
|
||
isActive
|
||
? "border-emerald-400/40 bg-emerald-500/10"
|
||
: "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]"
|
||
}`}
|
||
>
|
||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-black/30">
|
||
<img
|
||
alt={image.title || `待处理图片 ${index + 1}`}
|
||
className="h-full w-full object-cover"
|
||
decoding="async"
|
||
loading={index < 3 ? "eager" : "lazy"}
|
||
src={buildPsdLayerPreviewUrl(image.imageUrl, {
|
||
width: 112,
|
||
quality: 64,
|
||
})}
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-medium text-[12px] text-white/88">
|
||
{image.title || `待处理图片 ${index + 1}`}
|
||
</div>
|
||
<div className="mt-1 flex items-center gap-2 text-[11px] text-white/45">
|
||
<span>{isActive ? "当前编辑中" : "点击切换"}</span>
|
||
{renderedResultUrl ? (
|
||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-200">
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
已导出
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const content = (
|
||
<>
|
||
<style
|
||
dangerouslySetInnerHTML={{
|
||
__html: `
|
||
.psd-preview-viewport::-webkit-scrollbar {
|
||
width: 10px;
|
||
height: 10px;
|
||
}
|
||
.psd-preview-viewport::-webkit-scrollbar-track {
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-radius: 5px;
|
||
}
|
||
.psd-preview-viewport::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border-radius: 5px;
|
||
border: 2px solid transparent;
|
||
background-clip: padding-box;
|
||
}
|
||
.psd-preview-viewport::-webkit-scrollbar-thumb:hover {
|
||
background-color: rgba(16, 185, 129, 0.5);
|
||
}
|
||
.psd-layer-visibility-scroll {
|
||
scrollbar-color: rgba(16, 185, 129, 0.55) rgba(255, 255, 255, 0.08);
|
||
scrollbar-width: thin;
|
||
}
|
||
.psd-layer-visibility-scroll::-webkit-scrollbar {
|
||
width: 10px;
|
||
}
|
||
.psd-layer-visibility-scroll::-webkit-scrollbar-track {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-radius: 999px;
|
||
}
|
||
.psd-layer-visibility-scroll::-webkit-scrollbar-thumb {
|
||
background: rgba(16, 185, 129, 0.55);
|
||
border: 2px solid rgba(10, 10, 10, 0.9);
|
||
border-radius: 999px;
|
||
}
|
||
.psd-layer-visibility-scroll::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(16, 185, 129, 0.75);
|
||
}
|
||
`,
|
||
}}
|
||
/>
|
||
<div className="flex h-full min-h-0 flex-col overflow-hidden overscroll-none rounded-[24px] border border-white/12 bg-[#0a0a0a] p-0 text-white shadow-[0_40px_100px_rgba(0,0,0,0.8)]">
|
||
{fontFaceCss ? <style>{fontFaceCss}</style> : null}
|
||
|
||
{/* Top Header Bar */}
|
||
<div className="flex h-16 shrink-0 items-center justify-between border-white/12 border-b bg-[#0a0a0a] px-6">
|
||
<div className="flex items-center gap-3">
|
||
{standalone ? (
|
||
<Button
|
||
className="h-9 w-9 rounded-full border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() => {
|
||
if (onBack) {
|
||
onBack();
|
||
return;
|
||
}
|
||
onOpenChange(false);
|
||
}}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Button>
|
||
) : null}
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20 text-emerald-400">
|
||
<Sparkles className="h-4 w-4" />
|
||
</div>
|
||
<div>
|
||
<h2 className="font-semibold text-base text-white tracking-wide">
|
||
{standalone ? "PSD 套版工作台" : "PSD 模板编辑器"}
|
||
</h2>
|
||
{standalone && normalizedPendingImages.length > 0 ? (
|
||
<p className="mt-0.5 text-[11px] text-white/45">
|
||
当前待处理 {normalizedPendingImages.length} 张,品牌:{normalizedBrandName || "未选择"}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{selectedTemplate && (
|
||
<Button
|
||
className="h-9 rounded-xl bg-emerald-500 px-4 font-medium text-white shadow-[0_2px_10px_rgba(16,185,129,0.2)] ring-1 ring-emerald-500/50 transition-all hover:bg-emerald-400 hover:shadow-[0_4px_15px_rgba(16,185,129,0.3)] disabled:opacity-50"
|
||
disabled={
|
||
isRendering ||
|
||
isSelectedTemplateLoading ||
|
||
selectedTemplateLayers.length === 0
|
||
}
|
||
onClick={handleRender}
|
||
>
|
||
{isRendering || isSelectedTemplateLoading ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-4 w-4" />
|
||
)}
|
||
{renderedResultUrl ? "重新编辑应用导出" : "应用并导出"}
|
||
</Button>
|
||
)}
|
||
<div className="h-4 w-[1px] bg-white/10" />
|
||
<button
|
||
className="flex h-8 w-8 items-center justify-center rounded-full text-white/50 transition-colors hover:bg-white/10 hover:text-white"
|
||
onClick={() => onOpenChange(false)}
|
||
type="button"
|
||
>
|
||
<svg
|
||
fill="none"
|
||
height="18"
|
||
stroke="currentColor"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth="2"
|
||
viewBox="0 0 24 24"
|
||
width="18"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<line x1="18" x2="6" y1="6" y2="18" />
|
||
<line x1="6" x2="18" y1="6" y2="18" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)_380px]">
|
||
<div
|
||
className={`min-h-0 border-white/12 border-b bg-[#0a0a0a] xl:border-r xl:border-b-0 ${
|
||
selectedTemplate ? "p-5" : "p-5 xl:col-span-2 xl:p-6"
|
||
}`}
|
||
>
|
||
<ScrollArea
|
||
className={selectedTemplate ? "h-full pr-4" : "h-full pr-3"}
|
||
>
|
||
{selectedTemplate ? (
|
||
<div className="space-y-4">
|
||
{renderPendingImageQueue()}
|
||
|
||
<div className="space-y-2">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-semibold text-base text-white">
|
||
选择模板
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedCategorySummaryText ? (
|
||
<div className="rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-white/50 text-xs shadow-inner">
|
||
{`当前按品类“${selectedCategorySummaryText}”筛选模板,同时保留“适合所有”的模板。`}
|
||
</div>
|
||
) : null}
|
||
{hasTemplateOptions ? (
|
||
<div className="space-y-2.5">
|
||
<div className="inline-flex rounded-[14px] bg-white/5 p-1 shadow-inner ring-1 ring-white/10">
|
||
<button
|
||
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
|
||
selectedTemplateMode === "main"
|
||
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
|
||
: "text-white/60 hover:bg-white/5 hover:text-white"
|
||
}`}
|
||
onClick={() =>
|
||
handleSwitchSelectedTemplateMode("main")
|
||
}
|
||
type="button"
|
||
>
|
||
主图模式
|
||
</button>
|
||
<button
|
||
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
|
||
selectedTemplateMode === "sku"
|
||
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
|
||
: "text-white/60 hover:bg-white/5 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 rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2 text-white/90 shadow-inner transition-colors hover:border-white/20 hover:bg-white/[0.04] focus:ring-1 focus:ring-emerald-500/50 data-[state=open]:border-emerald-500/50 [&>span]:contents">
|
||
<div className="min-w-0 flex-1 pr-3">
|
||
{renderTemplateSelectContent(selectedTemplate, {
|
||
showSecondaryCategory:
|
||
shouldGroupTemplatesBySecondaryCategory,
|
||
showBrand: shouldShowTemplateBrand,
|
||
})}
|
||
</div>
|
||
</SelectTrigger>
|
||
<SelectContent
|
||
className="border-white/10 bg-[#141416] text-white shadow-2xl"
|
||
style={{
|
||
backgroundColor: "#141416",
|
||
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,
|
||
showBrand:
|
||
shouldShowTemplateBrand,
|
||
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, {
|
||
showBrand: shouldShowTemplateBrand,
|
||
compact: true,
|
||
})}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mx-auto w-full max-w-[220px] overflow-hidden rounded-2xl border border-white/10 bg-white/5 shadow-inner">
|
||
<div
|
||
style={{
|
||
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
|
||
}}
|
||
>
|
||
<TemplatePreviewImage
|
||
className="h-full w-full object-contain"
|
||
fetchPriority="high"
|
||
loading="eager"
|
||
quality={68}
|
||
template={selectedTemplate}
|
||
width={440}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{sourceImageUrl ? null : (
|
||
<div className="rounded-xl border border-white/10 border-dashed bg-white/[0.02] px-4 py-3 text-white/50 text-xs leading-relaxed shadow-inner">
|
||
当前未从生图结果带入图片。请先在下方图片图层中点击“上传替换图片”,再开始套用
|
||
PSD 模板。
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{renderPendingImageQueue()}
|
||
<div className="grid min-h-[640px] gap-5 xl:grid-cols-2">
|
||
{renderTemplatePoolSection({
|
||
mode: "main",
|
||
templates: mainModeTemplates,
|
||
})}
|
||
{renderTemplatePoolSection({
|
||
mode: "sku",
|
||
templates: skuModeTemplates,
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
<div
|
||
className={
|
||
selectedTemplate
|
||
? "flex min-h-0 flex-col border-white/12 border-b bg-[#0a0a0a] p-6 xl:border-r xl:border-b-0"
|
||
: "hidden"
|
||
}
|
||
>
|
||
<div
|
||
className="psd-preview-viewport relative flex min-h-0 flex-1 items-center justify-center overflow-auto rounded-[28px] border border-white/[0.08] bg-[#141416] p-4 shadow-[inset_0_2px_20px_rgba(0,0,0,0.4)]"
|
||
ref={previewViewportRef}
|
||
>
|
||
{selectedTemplate ? (
|
||
selectedTemplateLayers.length === 0 ? (
|
||
<div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/50 shadow-inner">
|
||
<TemplatePreviewImage
|
||
alt={`${selectedTemplate.name} 快速预览`}
|
||
className="absolute inset-0 h-full w-full object-contain opacity-35 blur-[1px]"
|
||
fetchPriority="high"
|
||
loading="eager"
|
||
quality={58}
|
||
template={selectedTemplate}
|
||
width={720}
|
||
/>
|
||
<div className="absolute inset-0 bg-[#141416]/72" />
|
||
<div className="relative z-10 flex flex-col items-center">
|
||
{selectedTemplateDetailError ? (
|
||
<>
|
||
<div className="font-medium text-white/80">
|
||
模板详情加载失败
|
||
</div>
|
||
<div className="mt-2 max-w-md text-xs leading-relaxed text-white/45">
|
||
{selectedTemplateDetailError}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Loader2 className="mb-4 h-7 w-7 animate-spin text-emerald-400" />
|
||
<div className="font-medium text-white/80">
|
||
正在加载模板图层与编辑配置
|
||
</div>
|
||
<div className="mt-2 max-w-md text-xs leading-relaxed text-white/45">
|
||
模板卡片已经优先显示,当前正在按需加载这个模板的完整图层、字体和编辑底图。
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<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 &&
|
||
orderedVisiblePreviewLayers.length > 0 ? (
|
||
<TemplatePreviewImage
|
||
alt={`${selectedTemplate.name} 编辑底图`}
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
fetchPriority="high"
|
||
loading="eager"
|
||
preferRenderBase
|
||
quality={68}
|
||
style={{ zIndex: renderBaseZIndex }}
|
||
template={selectedTemplate}
|
||
width={960}
|
||
/>
|
||
) : null}
|
||
|
||
<div className="pointer-events-none absolute inset-0">
|
||
{orderedVisiblePreviewLayers.map((layer) => {
|
||
const isLayerSuperLocked =
|
||
isAgentPsdLayerSuperLocked(layer);
|
||
const layerPositionOffset =
|
||
effectiveLayerPositionOffsets[layer.id] ||
|
||
normalizeLayerPositionOffset();
|
||
const baseZIndex =
|
||
layerStackZIndexById.get(layer.id) || 1;
|
||
const layerBoxStyle = {
|
||
left: layer.x + layerPositionOffset.offsetX,
|
||
top: layer.y + layerPositionOffset.offsetY,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
} satisfies CSSProperties;
|
||
|
||
if (isAgentPsdImageLayerSlot(layer)) {
|
||
const assignedUrl =
|
||
effectiveImageAssignments[layer.id];
|
||
const isDefaultAssignedImageLayer =
|
||
layer.id ===
|
||
getDefaultSourceImageLayer(
|
||
selectedTemplateLayers
|
||
)?.id;
|
||
const layerImageUrl =
|
||
assignedUrl ||
|
||
getPsdLayerPreviewSourceUrl(layer);
|
||
if (!layerImageUrl) {
|
||
return null;
|
||
}
|
||
const assignedImageContainerStyle = assignedUrl
|
||
? {
|
||
...layerBoxStyle,
|
||
...getAssignedImageMaskStyle(
|
||
layer,
|
||
selectedTemplateLayers
|
||
),
|
||
}
|
||
: layerBoxStyle;
|
||
const previewZIndex = assignedUrl
|
||
? Math.max(
|
||
baseZIndex,
|
||
renderBaseZIndex + 1,
|
||
isDefaultAssignedImageLayer
|
||
? (orderedPreviewLayers.length + 1) *
|
||
PSD_LAYER_Z_INDEX_STEP
|
||
: 0
|
||
)
|
||
: baseZIndex;
|
||
|
||
return (
|
||
<div
|
||
className={`absolute overflow-hidden ${
|
||
isLayerSuperLocked
|
||
? "pointer-events-none"
|
||
: "pointer-events-auto"
|
||
}`}
|
||
key={layer.id}
|
||
onClick={
|
||
isLayerSuperLocked
|
||
? undefined
|
||
: (event) => {
|
||
event.stopPropagation();
|
||
setSelectedLayerId(layer.id);
|
||
}
|
||
}
|
||
onPointerDown={(event) =>
|
||
isLayerSuperLocked
|
||
? undefined
|
||
: handleLayerDragStart(layer, event)
|
||
}
|
||
style={{
|
||
...assignedImageContainerStyle,
|
||
zIndex: previewZIndex,
|
||
cursor:
|
||
isLayerSuperLocked
|
||
? "default"
|
||
: layerDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: isLayerSuperLocked
|
||
? "auto"
|
||
: "none",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
>
|
||
<img
|
||
alt={layer.layerName}
|
||
className={
|
||
assignedUrl ? "" : "h-full w-full"
|
||
}
|
||
decoding="async"
|
||
src={buildPsdLayerPreviewUrl(layerImageUrl, {
|
||
width: assignedUrl
|
||
? Math.max(layer.width, layer.height)
|
||
: layer.width,
|
||
quality: assignedUrl ? 74 : 68,
|
||
delivery: assignedUrl ? "proxy" : undefined,
|
||
})}
|
||
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 value =
|
||
effectiveTextAssignments[layer.id] ?? "";
|
||
const resolvedTextColor =
|
||
effectiveTextColorAssignments[layer.id] ||
|
||
layer.textColor;
|
||
const textPositionOffset =
|
||
effectiveTextPositionOffsets[layer.id] ||
|
||
normalizeTextPositionOffset();
|
||
const hasRenderableText =
|
||
hasRenderableTextLayerPreview(layer, value);
|
||
const layerPreviewSourceUrl =
|
||
getPsdLayerPreviewSourceUrl(layer);
|
||
|
||
if (
|
||
!hasRenderableText &&
|
||
layerPreviewSourceUrl
|
||
) {
|
||
return (
|
||
<div
|
||
className={`absolute overflow-hidden ${
|
||
isLayerSuperLocked
|
||
? "pointer-events-none"
|
||
: "pointer-events-auto"
|
||
}`}
|
||
key={layer.id}
|
||
onClick={
|
||
isLayerSuperLocked
|
||
? undefined
|
||
: (event) => {
|
||
event.stopPropagation();
|
||
setSelectedLayerId(layer.id);
|
||
}
|
||
}
|
||
onPointerDown={(event) =>
|
||
isLayerSuperLocked
|
||
? undefined
|
||
: handleTextDragStart(layer, event)
|
||
}
|
||
style={{
|
||
...layerBoxStyle,
|
||
zIndex: baseZIndex,
|
||
cursor:
|
||
isLayerSuperLocked
|
||
? "default"
|
||
: textDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: isLayerSuperLocked
|
||
? "auto"
|
||
: "none",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
>
|
||
<img
|
||
alt={layer.layerName}
|
||
className="h-full w-full object-fill"
|
||
decoding="async"
|
||
src={buildPsdLayerPreviewUrl(
|
||
layerPreviewSourceUrl,
|
||
{
|
||
width: layer.width,
|
||
quality: 68,
|
||
}
|
||
)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!hasRenderableText) {
|
||
return null;
|
||
}
|
||
|
||
const textFrame = getTextLayerRenderFrame(
|
||
layer,
|
||
value
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className={`absolute overflow-visible ${
|
||
isLayerSuperLocked
|
||
? "pointer-events-none"
|
||
: "pointer-events-auto"
|
||
}`}
|
||
key={layer.id}
|
||
onClick={
|
||
isLayerSuperLocked
|
||
? undefined
|
||
: (event) => {
|
||
event.stopPropagation();
|
||
setSelectedLayerId(layer.id);
|
||
}
|
||
}
|
||
onPointerDown={(event) =>
|
||
isLayerSuperLocked
|
||
? undefined
|
||
: handleTextDragStart(layer, event)
|
||
}
|
||
style={{
|
||
left:
|
||
layer.x +
|
||
layerPositionOffset.offsetX +
|
||
textPositionOffset.offsetX +
|
||
textFrame.offsetX,
|
||
top:
|
||
layer.y +
|
||
layerPositionOffset.offsetY +
|
||
textPositionOffset.offsetY +
|
||
textFrame.offsetY,
|
||
width: textFrame.width,
|
||
height: textFrame.height,
|
||
zIndex: baseZIndex,
|
||
cursor:
|
||
isLayerSuperLocked
|
||
? "default"
|
||
: textDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: isLayerSuperLocked
|
||
? "auto"
|
||
: "none",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
>
|
||
{renderTextLayerPreviewContent(
|
||
layer,
|
||
value,
|
||
resolvedTextColor
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="absolute inset-0">
|
||
{orderedVisiblePreviewLayers
|
||
.filter(
|
||
(layer) =>
|
||
!layer.isEditable &&
|
||
!isAgentPsdLayerSuperLocked(layer)
|
||
)
|
||
.map((layer) => {
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const baseZIndex =
|
||
layerStackZIndexById.get(layer.id) || 1;
|
||
const frameStyle = getLayerFrameStyle(
|
||
layer,
|
||
isSelected,
|
||
effectiveLayerPositionOffsets[layer.id]
|
||
);
|
||
|
||
return (
|
||
<div
|
||
aria-label={`普通图层 ${layer.layerName}`}
|
||
className="pointer-events-none absolute overflow-hidden rounded-lg transition-all"
|
||
key={`static-layer-frame-${layer.id}`}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex:
|
||
baseZIndex +
|
||
(isSelected
|
||
? SELECTED_LAYER_INTERACTION_Z_BOOST
|
||
: 0),
|
||
background: isSelected
|
||
? "rgba(255,255,255,0.05)"
|
||
: "transparent",
|
||
cursor:
|
||
layerDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: "none",
|
||
}}
|
||
title={`普通图层:${layer.layerName}`}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{orderedEditableImageLayers.map((layer) => {
|
||
if (hiddenLayerIdSet.has(layer.id)) {
|
||
return null;
|
||
}
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const baseZIndex =
|
||
layerStackZIndexById.get(layer.id) || 1;
|
||
const frameStyle = getLayerFrameStyle(
|
||
layer,
|
||
isSelected,
|
||
effectiveLayerPositionOffsets[layer.id]
|
||
);
|
||
const hasAssignedImage = Boolean(
|
||
String(
|
||
effectiveImageAssignments[layer.id] || ""
|
||
).trim()
|
||
);
|
||
|
||
return isSelected && hasAssignedImage ? (
|
||
<div
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
className="pointer-events-none absolute overflow-hidden rounded-lg transition-all"
|
||
key={layer.id}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex:
|
||
baseZIndex +
|
||
SELECTED_LAYER_INTERACTION_Z_BOOST,
|
||
background: "rgba(249,115,22,0.08)",
|
||
cursor:
|
||
layerDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: "none",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
/>
|
||
) : (
|
||
<button
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
className="absolute overflow-hidden rounded-lg transition-all"
|
||
key={layer.id}
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
onPointerDown={(event) =>
|
||
handleLayerDragStart(layer, event)
|
||
}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex:
|
||
baseZIndex +
|
||
(isSelected
|
||
? SELECTED_LAYER_INTERACTION_Z_BOOST
|
||
: 0),
|
||
background: isSelected
|
||
? "rgba(249,115,22,0.08)"
|
||
: "transparent",
|
||
cursor:
|
||
layerDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: "none",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
type="button"
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{orderedEditableImageLayers.map((layer) => {
|
||
if (hiddenLayerIdSet.has(layer.id)) {
|
||
return null;
|
||
}
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const hasAssignedImage = Boolean(
|
||
String(
|
||
effectiveImageAssignments[layer.id] || ""
|
||
).trim()
|
||
);
|
||
const baseZIndex =
|
||
layerStackZIndexById.get(layer.id) || 1;
|
||
if (!isSelected || !hasAssignedImage) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="absolute flex items-center gap-1 rounded-full border border-white/10 bg-black/80 p-1 text-white shadow-xl backdrop-blur-md"
|
||
key={`${layer.id}-controls`}
|
||
style={{
|
||
left:
|
||
layer.x +
|
||
(
|
||
effectiveLayerPositionOffsets[layer.id]
|
||
?.offsetX || 0
|
||
) +
|
||
layer.width -
|
||
112,
|
||
top: Math.max(
|
||
8,
|
||
layer.y +
|
||
(
|
||
effectiveLayerPositionOffsets[layer.id]
|
||
?.offsetY || 0
|
||
) -
|
||
44
|
||
),
|
||
zIndex:
|
||
baseZIndex +
|
||
SELECTED_LAYER_CONTROLS_Z_BOOST,
|
||
}}
|
||
>
|
||
<button
|
||
aria-label="缩小图片"
|
||
className="rounded-full p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||
onClick={() =>
|
||
nudgeImageScale(layer.id, -0.1)
|
||
}
|
||
title="缩小"
|
||
type="button"
|
||
>
|
||
<ZoomOut className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
aria-label="放大图片"
|
||
className="rounded-full p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||
onClick={() => nudgeImageScale(layer.id, 0.1)}
|
||
title="放大"
|
||
type="button"
|
||
>
|
||
<ZoomIn className="h-4 w-4" />
|
||
</button>
|
||
<div className="h-3.5 w-px bg-white/20" />
|
||
<button
|
||
aria-label="重置图片位置和缩放"
|
||
className="rounded-full p-1.5 text-emerald-400 transition-colors hover:bg-white/20 hover:text-emerald-300"
|
||
onClick={() => resetImagePlacement(layer.id)}
|
||
title="重置"
|
||
type="button"
|
||
>
|
||
<RefreshCcw className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{orderedEditableTextLayers.map((layer) => {
|
||
if (hiddenLayerIdSet.has(layer.id)) {
|
||
return null;
|
||
}
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const baseZIndex =
|
||
layerStackZIndexById.get(layer.id) || 1;
|
||
const textPositionOffset =
|
||
effectiveTextPositionOffsets[layer.id] ||
|
||
normalizeTextPositionOffset();
|
||
const layerPositionOffset =
|
||
effectiveLayerPositionOffsets[layer.id] ||
|
||
normalizeLayerPositionOffset();
|
||
const frameStyle = {
|
||
...getLayerFrameStyle(
|
||
layer,
|
||
isSelected,
|
||
layerPositionOffset
|
||
),
|
||
left:
|
||
layer.x +
|
||
layerPositionOffset.offsetX +
|
||
textPositionOffset.offsetX,
|
||
top:
|
||
layer.y +
|
||
layerPositionOffset.offsetY +
|
||
textPositionOffset.offsetY,
|
||
} satisfies CSSProperties;
|
||
const currentValue =
|
||
effectiveTextAssignments[layer.id] ?? "";
|
||
const resolvedTextColor =
|
||
effectiveTextColorAssignments[layer.id] ||
|
||
layer.textColor;
|
||
const layerPreviewSourceUrl =
|
||
getPsdLayerPreviewSourceUrl(layer);
|
||
const hasRenderableText =
|
||
hasRenderableTextLayerPreview(
|
||
layer,
|
||
currentValue
|
||
);
|
||
const preserveSourceAppearance =
|
||
!hasRenderableText &&
|
||
!!layerPreviewSourceUrl &&
|
||
!hasCustomizedTextLayerAppearance(
|
||
layer,
|
||
currentValue,
|
||
resolvedTextColor,
|
||
textPositionOffset
|
||
);
|
||
const editedTextFrame = getTextLayerRenderFrame(
|
||
layer,
|
||
currentValue
|
||
);
|
||
|
||
if (isSelected) {
|
||
return (
|
||
<div
|
||
className="pointer-events-none absolute overflow-visible rounded-lg"
|
||
key={layer.id}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex:
|
||
baseZIndex +
|
||
SELECTED_LAYER_INTERACTION_Z_BOOST,
|
||
background: "transparent",
|
||
cursor:
|
||
textDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: "none",
|
||
}}
|
||
>
|
||
{preserveSourceAppearance &&
|
||
layerPreviewSourceUrl ? (
|
||
<img
|
||
alt={layer.layerName}
|
||
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
|
||
decoding="async"
|
||
src={buildPsdLayerPreviewUrl(
|
||
layerPreviewSourceUrl,
|
||
{
|
||
width: layer.width,
|
||
quality: 68,
|
||
}
|
||
)}
|
||
/>
|
||
) : null}
|
||
{!preserveSourceAppearance &&
|
||
hasRenderableText ? (
|
||
<div
|
||
className="absolute"
|
||
style={{
|
||
left: editedTextFrame.offsetX,
|
||
top: editedTextFrame.offsetY,
|
||
width: editedTextFrame.width,
|
||
height: editedTextFrame.height,
|
||
}}
|
||
>
|
||
{renderTextLayerPreviewContent(
|
||
layer,
|
||
currentValue,
|
||
resolvedTextColor
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</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:
|
||
baseZIndex +
|
||
(isSelected
|
||
? SELECTED_LAYER_INTERACTION_Z_BOOST
|
||
: 0),
|
||
background: isSelected
|
||
? "rgba(56,189,248,0.05)"
|
||
: "transparent",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
type="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-base text-white">
|
||
SKU图模式模板
|
||
</div>
|
||
<div className="mt-1.5 text-white/50 text-xs">
|
||
{normalizedTemplateMode === "sku"
|
||
? "当前结果属于 SKU 图模式,可直接从这里选择并进入编辑。"
|
||
: selectedCategorySummaryText
|
||
? `当前按品类“${selectedCategorySummaryText}”筛选,同时展示“适合所有”的 SKU 图模式模板。`
|
||
: normalizedBrandName
|
||
? "这里集中展示当前品牌下的 SKU 图模式模板。"
|
||
: "当前未选择品牌,这里展示全部品牌下的 SKU 图模式模板。"}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-medium text-white/70 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/10 border-dashed bg-white/[0.02] px-4 py-5 text-center text-sm text-white/50 shadow-inner"
|
||
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, index) => (
|
||
<button
|
||
className={`rounded-2xl border p-3 text-left transition-all duration-200 ${
|
||
selectedTemplateId === template.id
|
||
? "border-emerald-500/50 bg-emerald-500/10 shadow-[0_0_15px_rgba(16,185,129,0.1)]"
|
||
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
|
||
}`}
|
||
key={template.id}
|
||
onClick={() => setSelectedTemplateId(template.id)}
|
||
type="button"
|
||
>
|
||
{renderTemplatePickerCardContent(template, {
|
||
showSecondaryCategory:
|
||
shouldGroupTemplatesBySecondaryCategory,
|
||
showBrand: shouldShowTemplateBrand,
|
||
emphasize: true,
|
||
eager: index < 4,
|
||
})}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] px-6 py-10 text-center text-sm text-white/50 shadow-inner">
|
||
{selectedCategorySummaryText
|
||
? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板`
|
||
: normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下暂无 SKU 图模式模板`
|
||
: "当前暂无可用的 SKU 图模式模板,请先去后台配置。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-col bg-[#0a0a0a] p-5 shadow-[-20px_0_40px_rgba(0,0,0,0.5)] xl:p-6">
|
||
{renderedResultUrl ? (
|
||
<div className="mb-4 flex items-center justify-end gap-3">
|
||
<div className="inline-flex rounded-[14px] bg-white/5 p-1 shadow-inner ring-1 ring-white/10">
|
||
<button
|
||
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
|
||
rightPanelView === "editor"
|
||
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
|
||
: "text-white/60 hover:bg-white/5 hover:text-white"
|
||
}`}
|
||
onClick={() => setRightPanelView("editor")}
|
||
type="button"
|
||
>
|
||
编辑面板
|
||
</button>
|
||
<button
|
||
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
|
||
rightPanelView === "export"
|
||
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
|
||
: "text-white/60 hover:bg-white/5 hover:text-white"
|
||
}`}
|
||
onClick={() => setRightPanelView("export")}
|
||
type="button"
|
||
>
|
||
导出结果
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Input
|
||
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
|
||
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}
|
||
/>
|
||
) : (
|
||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||
{isSelectedLayerHidden && selectedLayer ? (
|
||
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/5 p-8 text-center text-sm text-white/60 shadow-inner">
|
||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-amber-500/20 bg-amber-500/10 shadow-inner">
|
||
<EyeOff className="h-6 w-6 text-amber-300" />
|
||
</div>
|
||
<div className="font-medium text-white">
|
||
图层已临时删除
|
||
</div>
|
||
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
|
||
当前会话里不会再预览或导出图层“{selectedLayer.layerName}
|
||
”,但不会改动模板本身。
|
||
</div>
|
||
<Button
|
||
className="mt-5 rounded-xl bg-emerald-500 px-4 text-white hover:bg-emerald-400"
|
||
onClick={() => restoreHiddenLayer(selectedLayer.id)}
|
||
type="button"
|
||
>
|
||
恢复这个图层
|
||
</Button>
|
||
</div>
|
||
) : selectedImageLayer ? (
|
||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pr-1">
|
||
<div className="sticky top-0 z-20 -mx-1 bg-[#0a0a0a]/95 px-1 pb-3 backdrop-blur-md">
|
||
{selectedImageAdjustmentPanel}
|
||
</div>
|
||
{selectedLayerPositionPanel}
|
||
{textLayerQuickColorPanel}
|
||
</div>
|
||
) : selectedTextLayer ? (
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner xl:p-5">
|
||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pr-1">
|
||
{selectedLayerPositionPanel}
|
||
<Textarea
|
||
className="min-h-[180px] flex-1 resize-none rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-white/90 shadow-inner placeholder:text-white/30 focus-visible:border-emerald-500/50 focus-visible:bg-white/[0.04] focus-visible:ring-1 focus-visible:ring-emerald-500/50 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-x-4 gap-y-2 rounded-xl bg-white/[0.02] px-4 py-3 text-white/60 text-xs shadow-inner">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="text-white/40">字体:</span>
|
||
<span className="font-semibold text-white/80">
|
||
{selectedTextLayer.fontLabel ||
|
||
selectedTextLayer.fontFamily ||
|
||
"系统字体"}
|
||
</span>
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="text-white/40">字号:</span>
|
||
<span className="font-semibold text-white/80">
|
||
{selectedTextLayer.fontSize}px
|
||
</span>
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="text-white/40">行高:</span>
|
||
<span className="font-semibold text-white/80">
|
||
{selectedTextLayer.lineHeight}
|
||
</span>
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="text-white/40">字距:</span>
|
||
<span className="font-semibold text-white/80">
|
||
{selectedTextLayer.letterSpacing}px
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="font-semibold text-sm text-white">
|
||
文字颜色
|
||
</div>
|
||
<div className="mt-1.5 text-white/50 text-xs">
|
||
支持自定义颜色;若后台配置了品牌色,也可以直接套用预设。
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="h-10 w-10 shrink-0 rounded-full border-2 border-white/20 shadow-[0_0_15px_rgba(0,0,0,0.5)]"
|
||
style={{ backgroundColor: selectedTextColor }}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-white/[0.08] bg-black/20 p-3">
|
||
<input
|
||
aria-label="选择文字颜色"
|
||
className="h-10 w-12 cursor-pointer rounded-lg border border-white/15 bg-transparent p-1"
|
||
onChange={(event) =>
|
||
updateTextColorAssignment(
|
||
selectedTextLayer.id,
|
||
event.target.value
|
||
)
|
||
}
|
||
type="color"
|
||
value={selectedTextColor}
|
||
/>
|
||
<div className="rounded-lg bg-white/[0.04] px-3 py-2 font-mono text-[12px] text-white/75 uppercase">
|
||
{selectedTextColor}
|
||
</div>
|
||
<Button
|
||
className="rounded-lg px-3 text-white/60 hover:bg-white/5 hover:text-white"
|
||
onClick={() =>
|
||
updateTextColorAssignment(
|
||
selectedTextLayer.id,
|
||
selectedTextLayer.textColor
|
||
)
|
||
}
|
||
size="sm"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
重置
|
||
</Button>
|
||
</div>
|
||
{textColorConfig.enabled &&
|
||
textColorConfig.colors.length > 0 ? (
|
||
<div className="grid grid-cols-2 gap-2.5">
|
||
{textColorConfig.colors.map((color, index) => {
|
||
const isActive =
|
||
getResolvedTextLayerColor(
|
||
selectedTextLayer,
|
||
color
|
||
) ===
|
||
getResolvedTextLayerColor(
|
||
selectedTextLayer,
|
||
selectedTextColor
|
||
);
|
||
return (
|
||
<button
|
||
className={`group flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition-all duration-200 ${
|
||
isActive
|
||
? "border-emerald-500/50 bg-emerald-500/10 text-white shadow-[0_0_15px_rgba(16,185,129,0.15)] ring-1 ring-emerald-500/50"
|
||
: "border-white/[0.08] bg-white/[0.02] text-white/80 hover:border-white/[0.15] hover:bg-white/[0.04]"
|
||
}`}
|
||
key={`text-color-option-${index + 1}`}
|
||
onClick={() =>
|
||
updateTextColorAssignment(
|
||
selectedTextLayer.id,
|
||
color
|
||
)
|
||
}
|
||
type="button"
|
||
>
|
||
<span
|
||
className={`h-6 w-6 shrink-0 rounded-full border-2 transition-all ${
|
||
isActive
|
||
? "scale-110 border-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)]"
|
||
: "border-white/20 shadow-inner"
|
||
}`}
|
||
style={{ backgroundColor: color }}
|
||
/>
|
||
<span className="flex min-w-0 flex-col">
|
||
<span
|
||
className={`font-medium text-xs transition-colors ${isActive ? "text-emerald-400" : "text-white/90 group-hover:text-white"}`}
|
||
>{`颜色 ${index + 1}`}</span>
|
||
<span
|
||
className={`font-mono text-[11px] uppercase transition-colors ${isActive ? "text-emerald-500/70" : "text-white/50 group-hover:text-white/70"}`}
|
||
>
|
||
{color}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-3 py-2 text-white/45 text-xs">
|
||
当前品牌未配置预设色,可直接使用上方颜色选择器。
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="font-semibold text-sm text-white">
|
||
文字位置
|
||
</div>
|
||
<div className="mt-1.5 text-white/50 text-xs">
|
||
可在中间预览区直接拖动文字,也可以用数值微调。
|
||
</div>
|
||
</div>
|
||
<Move className="h-5 w-5 shrink-0 text-sky-300" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1.5">
|
||
<Label className="text-white/50 text-xs">
|
||
水平 X
|
||
</Label>
|
||
<Input
|
||
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
|
||
inputMode="numeric"
|
||
onChange={(event) => {
|
||
const nextValue = Number(event.target.value);
|
||
if (!Number.isFinite(nextValue)) return;
|
||
updateTextPositionOffset(
|
||
selectedTextLayer.id,
|
||
() => ({ offsetX: nextValue })
|
||
);
|
||
}}
|
||
type="number"
|
||
value={Math.round(
|
||
selectedTextPositionOffset.offsetX
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-white/50 text-xs">
|
||
垂直 Y
|
||
</Label>
|
||
<Input
|
||
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
|
||
inputMode="numeric"
|
||
onChange={(event) => {
|
||
const nextValue = Number(event.target.value);
|
||
if (!Number.isFinite(nextValue)) return;
|
||
updateTextPositionOffset(
|
||
selectedTextLayer.id,
|
||
() => ({ offsetY: nextValue })
|
||
);
|
||
}}
|
||
type="number"
|
||
value={Math.round(
|
||
selectedTextPositionOffset.offsetY
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||
<div />
|
||
<Button
|
||
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
nudgeTextPositionOffset(
|
||
selectedTextLayer.id,
|
||
0,
|
||
-1
|
||
)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
↑
|
||
</Button>
|
||
<div />
|
||
<Button
|
||
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
nudgeTextPositionOffset(
|
||
selectedTextLayer.id,
|
||
-1,
|
||
0
|
||
)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
←
|
||
</Button>
|
||
<Button
|
||
className="h-9 rounded-xl border-white/10 bg-white/[0.04] px-3 text-white/60 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
resetTextPositionOffset(selectedTextLayer.id)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
归零
|
||
</Button>
|
||
<Button
|
||
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
nudgeTextPositionOffset(
|
||
selectedTextLayer.id,
|
||
1,
|
||
0
|
||
)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
→
|
||
</Button>
|
||
<div />
|
||
<Button
|
||
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||
onClick={() =>
|
||
nudgeTextPositionOffset(
|
||
selectedTextLayer.id,
|
||
0,
|
||
1
|
||
)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
↓
|
||
</Button>
|
||
<div />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : selectedLayer ? (
|
||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pr-1">
|
||
{selectedLayerPositionPanel}
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5 text-sm text-white/60 shadow-inner">
|
||
<div className="font-medium text-white/85">
|
||
当前是普通图层
|
||
</div>
|
||
<div className="mt-2 text-xs leading-relaxed text-white/45">
|
||
这个图层当前不支持替换图片或编辑文字,但可以在预览区直接拖动位置,也可以调整上下层级。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : selectedTemplate &&
|
||
selectedTemplateLayers.length === 0 ? (
|
||
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
|
||
{selectedTemplateDetailError ? (
|
||
<>
|
||
<div className="font-medium text-white/80">
|
||
模板详情加载失败
|
||
</div>
|
||
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
|
||
{selectedTemplateDetailError}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Loader2 className="mb-4 h-6 w-6 animate-spin text-emerald-400" />
|
||
<div className="font-medium text-white/80">
|
||
正在加载这个模板的编辑图层
|
||
</div>
|
||
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
|
||
首屏先展示模板列表,完整文字图层、图片图层和字体配置会按需补回来。
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
|
||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-white/10 bg-white/5 shadow-inner">
|
||
<Pointer className="h-6 w-6 text-white/50" />
|
||
</div>
|
||
请在中间预览区点击图层进行编辑
|
||
</div>
|
||
)}
|
||
|
||
{orderedUserManageablePreviewLayers.length > 0 ? (
|
||
<div className="flex shrink-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-semibold text-sm text-white">
|
||
图层显示
|
||
</div>
|
||
<div className="mt-1 text-white/45 text-xs">
|
||
临时删除只影响这次套版预览和导出,不会改动 PSD
|
||
模板本身。
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.04] p-0.5 shadow-inner">
|
||
<button
|
||
className="flex h-7 w-7 items-center justify-center rounded-full text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
|
||
disabled={!canScrollHiddenLayersUp}
|
||
onClick={() => handleHiddenLayerListScroll("up")}
|
||
title="向上滚动图层列表"
|
||
type="button"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className="flex h-7 w-7 items-center justify-center rounded-full text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
|
||
disabled={!canScrollHiddenLayersDown}
|
||
onClick={() =>
|
||
handleHiddenLayerListScroll("down")
|
||
}
|
||
title="向下滚动图层列表"
|
||
type="button"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">
|
||
已隐藏 {hiddenLayerIds.length} /{" "}
|
||
{orderedUserManageablePreviewLayers.length}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="psd-layer-visibility-scroll max-h-[220px] min-h-0 overflow-y-auto overscroll-contain pr-2"
|
||
ref={hiddenLayerScrollAreaRef}
|
||
>
|
||
<div className="space-y-2 pb-1">
|
||
{orderedUserManageablePreviewLayers.map((layer) => {
|
||
const isHidden = hiddenLayerIdSet.has(layer.id);
|
||
const isSelected = selectedLayerId === layer.id;
|
||
return (
|
||
<div
|
||
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ${
|
||
isSelected
|
||
? "border-emerald-500/40 bg-emerald-500/10"
|
||
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
|
||
}`}
|
||
key={`layer-visibility-${layer.id}`}
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
onKeyDown={(event) => {
|
||
if (
|
||
event.key === "Enter" ||
|
||
event.key === " "
|
||
) {
|
||
event.preventDefault();
|
||
setSelectedLayerId(layer.id);
|
||
}
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-medium text-sm text-white/90">
|
||
{layer.layerName}
|
||
</div>
|
||
<div className="mt-0.5 text-[11px] text-white/45">
|
||
{layer.isEditable
|
||
? isAgentPsdImageLayerSlot(layer)
|
||
? "可编辑图片图层"
|
||
: "可编辑文字图层"
|
||
: "普通图层"}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className={`inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2.5 text-xs transition-all ${
|
||
isHidden
|
||
? "bg-emerald-500/15 text-emerald-300 hover:bg-emerald-500/20"
|
||
: "bg-red-500/10 text-red-300 hover:bg-red-500/15"
|
||
}`}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
if (isHidden) {
|
||
restoreHiddenLayer(layer.id);
|
||
return;
|
||
}
|
||
hideLayerTemporarily(layer.id);
|
||
}}
|
||
type="button"
|
||
>
|
||
{isHidden ? (
|
||
<Eye className="h-3.5 w-3.5" />
|
||
) : (
|
||
<EyeOff className="h-3.5 w-3.5" />
|
||
)}
|
||
{isHidden ? "恢复" : "临时删除"}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
if (standalone) {
|
||
return (
|
||
<div className="h-dvh w-full overflow-hidden bg-[#050608]">
|
||
<div className="h-full w-full overflow-hidden p-4">
|
||
{content}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||
<DialogContent
|
||
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden overscroll-none rounded-[24px] border border-white/12 bg-transparent p-0 text-white shadow-none"
|
||
hideCloseButton
|
||
onEscapeKeyDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onInteractOutside={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onPointerDownOutside={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
overlayClassName="bg-black/80 backdrop-blur-md"
|
||
>
|
||
{content}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|