"use client"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Download, Image as ImageIcon, Loader2, Move, MousePointerClick, RefreshCcw, Sparkles, Type, Upload, ZoomIn, ZoomOut, } from "lucide-react"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { getDefaultAgentPsdImagePlacement, normalizeAgentPsdImagePlacement, resolveAgentPsdImagePlacementFrame, type AgentPsdImagePlacement, type AgentPsdImageSize, } from "@/lib/agents/psd-image-placement"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, parseAgentPsdTemplates, type AgentPsdTemplate, type AgentPsdTemplateImageLayerSlot, type AgentPsdTemplateLayerSlot, type AgentPsdTemplateListResponse, type AgentPsdTemplateRenderResponse, type AgentPsdTemplateTextLayerSlot, } from "@/lib/agents/psd-template-config"; import { resolveTextLayout } from "@/lib/agents/psd-text-layout"; const fetcher = async (url: string): Promise => { try { const response = await fetch(url, { cache: "no-store" }); const payload = (await response.json().catch(() => null)) as | Partial | null; if (!response.ok || !payload || payload.success !== true || !Array.isArray(payload.templates)) { return { success: false, templates: [], error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败", }; } return { success: true, templates: parseAgentPsdTemplates(payload.templates), }; } catch { return { success: false, templates: [], error: "PSD 模板加载失败", }; } }; const PREVIEW_VIEWPORT_PADDING = 56; const PREVIEW_STAGE_MAX_SCALE = 0.82; const PREVIEW_STAGE_TARGET_WIDTH = 760; const PREVIEW_STAGE_TARGET_HEIGHT = 760; function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) { const imageAssignments: Record = {}; const imagePlacements: Record = {}; const textAssignments: Record = {}; if (!template) { return { imageAssignments, imagePlacements, textAssignments }; } for (const layer of template.layers) { if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) { imageAssignments[layer.id] = sourceImageUrl; imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement(); } if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) { textAssignments[layer.id] = layer.defaultText; } } return { imageAssignments, imagePlacements, textAssignments }; } function getLayerLabel(layer: AgentPsdTemplateLayerSlot) { return isAgentPsdImageLayerSlot(layer) ? "可替换图片" : "可编辑文字"; } function getLayerOutlineColor(layer: AgentPsdTemplateLayerSlot) { return isAgentPsdImageLayerSlot(layer) ? "rgba(249,115,22,0.95)" : "rgba(56,189,248,0.95)"; } function buildPreviewFontFaces(template: AgentPsdTemplate | null) { if (!template) return ""; const rules = new Map(); for (const layer of template.layers) { if (!isAgentPsdTextLayerSlot(layer) || !layer.fontFamily || !layer.fontFileUrl) { continue; } if (rules.has(layer.fontFamily)) { continue; } rules.set( layer.fontFamily, `@font-face { font-family: "${layer.fontFamily}"; 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 getPreferredSelectedLayerId(template: AgentPsdTemplate | null) { if (!template || template.layers.length === 0) { return ""; } const editableLayers = template.layers.filter((layer) => layer.isEditable); if (editableLayers.length === 0) { return ""; } const preferredImage = editableLayers.find( (layer) => isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource ); if (preferredImage) { return preferredImage.id; } return editableLayers[0]?.id || ""; } function getImageFit(replaceMode: AgentPsdTemplateImageLayerSlot["replaceMode"]): CSSProperties["objectFit"] { if (replaceMode === "contain") return "contain"; if (replaceMode === "stretch") return "fill"; return "cover"; } function getLayerFrameStyle( layer: AgentPsdTemplateLayerSlot, isSelected: boolean ): CSSProperties { return { left: layer.x, top: layer.y, width: layer.width, height: layer.height, border: `2px solid ${getLayerOutlineColor(layer)}`, boxShadow: isSelected ? `0 0 0 3px rgba(255,255,255,0.16), 0 0 0 6px ${getLayerOutlineColor(layer)}55` : `0 0 0 1px ${getLayerOutlineColor(layer)}66 inset`, }; } function getResolvedTextPreview(layer: AgentPsdTemplateTextLayerSlot, value: string) { return value || layer.placeholder || layer.layerName; } function getTextLayerContentStyle( layer: AgentPsdTemplateTextLayerSlot, value: string ): CSSProperties { 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, }); return { width: "100%", height: "100%", fontFamily: layer.fontFamily || "sans-serif", fontSize: resolvedLayout.fontSize, lineHeight: layer.lineHeight, letterSpacing: `${layer.letterSpacing}px`, color: layer.textColor, fontWeight: layer.fontWeight, textAlign: layer.textAlign, paddingLeft: layer.paddingX, paddingRight: layer.paddingX, paddingTop: layer.paddingY, paddingBottom: layer.paddingY, }; } function getAssignedImageMaskStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProperties { const hasMask = Boolean(layer.maskUrl); return { WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined, maskImage: hasMask ? `url(${layer.maskUrl})` : undefined, WebkitMaskSize: hasMask ? "100% 100%" : undefined, maskSize: hasMask ? "100% 100%" : undefined, WebkitMaskRepeat: hasMask ? "no-repeat" : undefined, maskRepeat: hasMask ? "no-repeat" : undefined, WebkitMaskPosition: hasMask ? "center" : undefined, maskPosition: hasMask ? "center" : undefined, }; } function getPlacedAssignedImageStyle(params: { layer: AgentPsdTemplateImageLayerSlot; imageSize: AgentPsdImageSize; placement: AgentPsdImagePlacement; }): CSSProperties { const { layer, imageSize, placement } = params; const frame = resolveAgentPsdImagePlacementFrame({ containerWidth: layer.width, containerHeight: layer.height, imageWidth: imageSize.width, imageHeight: imageSize.height, replaceMode: layer.replaceMode, placement, }); return { position: "absolute", left: frame.left, top: frame.top, width: frame.width, height: frame.height, ...getAssignedImageMaskStyle(layer), }; } function hasEditedTextValue(layer: AgentPsdTemplateTextLayerSlot, value: string) { return String(value || "").replace(/\r\n/g, "\n") !== String(layer.defaultText || "").replace(/\r\n/g, "\n"); } function getInlineTextEditorStyle( layer: AgentPsdTemplateTextLayerSlot, preserveSourceAppearance: boolean, value: string ): CSSProperties { const baseStyle = getTextLayerContentStyle(layer, value); return { ...baseStyle, background: "transparent", boxSizing: "border-box", overflow: "hidden", color: preserveSourceAppearance ? "transparent" : layer.textColor, caretColor: layer.textColor, WebkitTextFillColor: preserveSourceAppearance ? "transparent" : layer.textColor, }; } function getTextLayerNameBadgeClassName() { return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/78 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg"; } function getTextLayerHintBadgeClassName() { return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg"; } export function AgentPsdTemplateEditorDialog({ open, onOpenChange, sourceImageUrl, brandName, }: { open: boolean; onOpenChange: (open: boolean) => void; sourceImageUrl: string | null; brandName?: string | null; }) { const normalizedBrandName = String(brandName || "").trim(); const { data, isLoading } = useSWR( open && normalizedBrandName ? `/api/psd-templates?brand=${encodeURIComponent(normalizedBrandName)}` : null, fetcher ); const templates = useMemo(() => (data?.success ? data.templates : []), [data]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [selectedLayerId, setSelectedLayerId] = useState(""); const [imageAssignments, setImageAssignments] = useState>({}); const [imagePlacements, setImagePlacements] = useState>({}); const [imageSizes, setImageSizes] = useState>({}); const [textAssignments, setTextAssignments] = useState>({}); const [uploadingSlotId, setUploadingSlotId] = useState(null); const [isRendering, setIsRendering] = useState(false); const [renderedResultUrl, setRenderedResultUrl] = useState(""); const [stageScale, setStageScale] = useState(1); const previewViewportRef = useRef(null); const uploadInputRef = useRef(null); const inlineTextEditorRef = useRef(null); const imageDragStateRef = useRef<{ layerId: string; pointerId: number; startClientX: number; startClientY: number; startPlacement: AgentPsdImagePlacement; } | null>(null); const selectedTemplate = useMemo( () => templates.find((template) => template.id === selectedTemplateId) || null, [selectedTemplateId, templates] ); const selectedTemplateLayers = useMemo(() => { if (!selectedTemplate || !Array.isArray(selectedTemplate.layers)) { return [] as AgentPsdTemplateLayerSlot[]; } return selectedTemplate.layers; }, [selectedTemplate]); const editableImageLayers = useMemo( () => selectedTemplateLayers.filter( (layer): layer is AgentPsdTemplateImageLayerSlot => layer.isEditable && isAgentPsdImageLayerSlot(layer) ), [selectedTemplateLayers] ); const editableTextLayers = useMemo( () => selectedTemplateLayers.filter( (layer): layer is AgentPsdTemplateTextLayerSlot => layer.isEditable && isAgentPsdTextLayerSlot(layer) ), [selectedTemplateLayers] ); const fixedLayerCount = useMemo(() => { if (!selectedTemplate) return 0; return selectedTemplateLayers.filter((layer) => !layer.isEditable).length; }, [selectedTemplate, selectedTemplateLayers]); const orderedPreviewLayers = useMemo( () => [...selectedTemplateLayers].reverse(), [selectedTemplateLayers] ); const orderedEditableLayers = useMemo( () => orderedPreviewLayers.filter((layer) => layer.isEditable), [orderedPreviewLayers] ); const orderedEditableImageLayers = useMemo( () => orderedEditableLayers.filter( (layer): layer is AgentPsdTemplateImageLayerSlot => isAgentPsdImageLayerSlot(layer) ), [orderedEditableLayers] ); const orderedEditableTextLayers = useMemo( () => orderedEditableLayers.filter( (layer): layer is AgentPsdTemplateTextLayerSlot => isAgentPsdTextLayerSlot(layer) ), [orderedEditableLayers] ); const shouldShowCompositeFallback = useMemo( () => orderedPreviewLayers.some((layer) => !String(layer.sourceUrl || "").trim()), [orderedPreviewLayers] ); const effectiveImageAssignments = useMemo(() => { const nextAssignments: Record = {}; if (!selectedTemplate) { return nextAssignments; } for (const layer of editableImageLayers) { const explicit = String(imageAssignments[layer.id] || "").trim(); if (explicit) { nextAssignments[layer.id] = explicit; continue; } if (layer.isDefaultResultSource && sourceImageUrl?.trim()) { nextAssignments[layer.id] = sourceImageUrl.trim(); } } return nextAssignments; }, [editableImageLayers, imageAssignments, selectedTemplate, sourceImageUrl]); const effectiveTextAssignments = useMemo(() => { const nextAssignments: Record = {}; 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 effectiveImagePlacements = useMemo(() => { const nextPlacements: Record = {}; if (!selectedTemplate) { return nextPlacements; } for (const layer of editableImageLayers) { if (!String(effectiveImageAssignments[layer.id] || "").trim()) { continue; } nextPlacements[layer.id] = normalizeAgentPsdImagePlacement(imagePlacements[layer.id]); } return nextPlacements; }, [editableImageLayers, effectiveImageAssignments, imagePlacements, selectedTemplate]); const selectedLayer = useMemo( () => selectedTemplateLayers.find((layer) => layer.id === selectedLayerId) || null, [selectedLayerId, selectedTemplateLayers] ); const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null; const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null; const selectedImagePlacement = selectedImageLayer ? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id]) : getDefaultAgentPsdImagePlacement(); const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]); const selectedTemplateSelectValue = selectedTemplate?.id; useEffect(() => { if (!open) return; if (templates.length === 0) { if (selectedTemplateId) { setSelectedTemplateId(""); } setSelectedLayerId(""); return; } if (!templates.some((template) => template.id === selectedTemplateId)) { setSelectedTemplateId(templates[0].id); } }, [open, selectedTemplateId, templates]); useEffect(() => { if (!open) return; setRenderedResultUrl(""); const initialState = createInitialState(selectedTemplate, sourceImageUrl || ""); setImageAssignments(initialState.imageAssignments); setImagePlacements(initialState.imagePlacements); setTextAssignments(initialState.textAssignments); setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate)); // 自动适配缩放比例 if (selectedTemplate && previewViewportRef.current) { const viewport = previewViewportRef.current; const padding = 32; // 预留边距 const availableWidth = viewport.clientWidth - padding; const availableHeight = viewport.clientHeight - padding; const scaleX = availableWidth / selectedTemplate.width; const scaleY = availableHeight / selectedTemplate.height; const fitScale = Math.min(scaleX, scaleY, 1); // 不超过1倍 setStageScale(fitScale); } }, [open, selectedTemplate, sourceImageUrl]); useEffect(() => { if (!selectedTemplate) { setImageSizes({}); 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; setImageSizes((current) => ({ ...current, [layer.id]: { width: Math.max(1, image.naturalWidth || layer.width), height: Math.max(1, image.naturalHeight || layer.height), }, })); }; image.src = assignedUrl; } return () => { cancelled = true; }; }, [editableImageLayers, effectiveImageAssignments, imageSizes, selectedTemplate]); useEffect(() => { if (!selectedTemplate) { setStageScale(1); return; } const element = previewViewportRef.current; if (!element) return; const updateScale = () => { const rect = element.getBoundingClientRect(); const availableWidth = Math.max( 240, Math.min(PREVIEW_STAGE_TARGET_WIDTH, rect.width - PREVIEW_VIEWPORT_PADDING) ); const availableHeight = Math.max( 240, Math.min(PREVIEW_STAGE_TARGET_HEIGHT, rect.height - PREVIEW_VIEWPORT_PADDING) ); const nextScale = Math.min( availableWidth / Math.max(1, selectedTemplate.width), availableHeight / Math.max(1, selectedTemplate.height), PREVIEW_STAGE_MAX_SCALE ); setStageScale(Number.isFinite(nextScale) && nextScale > 0 ? nextScale : 1); }; updateScale(); let animationFrameId: number | null = null; const scheduleScaleUpdate = () => { if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { if (animationFrameId !== null) { window.cancelAnimationFrame(animationFrameId); } animationFrameId = window.requestAnimationFrame(() => { animationFrameId = null; updateScale(); }); return; } updateScale(); }; if (typeof ResizeObserver !== "undefined") { const observer = new ResizeObserver(scheduleScaleUpdate); observer.observe(element); return () => { observer.disconnect(); if (animationFrameId !== null && typeof window !== "undefined") { window.cancelAnimationFrame(animationFrameId); } }; } if (typeof window !== "undefined") { window.addEventListener("resize", scheduleScaleUpdate); } return () => { if (typeof window !== "undefined") { window.removeEventListener("resize", scheduleScaleUpdate); if (animationFrameId !== null) { window.cancelAnimationFrame(animationFrameId); } } }; }, [selectedTemplate]); useEffect(() => { if (!selectedTextLayer) { return; } const textarea = inlineTextEditorRef.current; if (!textarea) { return; } const focusTextarea = () => { textarea.focus(); const valueLength = textarea.value.length; try { textarea.setSelectionRange(valueLength, valueLength); } catch (error) { console.warn("[AgentPsdTemplateEditorDialog] setSelectionRange failed:", error); } }; if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { const frameId = window.requestAnimationFrame(focusTextarea); return () => { window.cancelAnimationFrame(frameId); }; } const timeoutId = window.setTimeout(focusTextarea, 0); return () => { window.clearTimeout(timeoutId); }; }, [selectedTextLayer?.id]); async function handleSlotUpload(slotId: string, event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) return; try { setUploadingSlotId(slotId); const formData = new FormData(); formData.set("file", file); const response = await fetchUploadWithRetry(formData); const payload = await response.json(); if (!response.ok || !payload.url) { throw new Error(payload.error || "图片上传失败"); } setImageAssignments((current) => ({ ...current, [slotId]: payload.url, })); setImagePlacements((current) => ({ ...current, [slotId]: getDefaultAgentPsdImagePlacement(), })); setImageSizes((current) => { const next = { ...current }; delete next[slotId]; return next; }); toast.success("替换图片已上传"); } catch (error) { toast.error(error instanceof Error ? error.message : "素材上传失败"); } finally { setUploadingSlotId(null); event.target.value = ""; } } function openImageUpload(slotId: string) { setSelectedLayerId(slotId); if (uploadInputRef.current) { uploadInputRef.current.value = ""; uploadInputRef.current.click(); } } function updateImagePlacement( layerId: string, updater: (current: AgentPsdImagePlacement) => AgentPsdImagePlacement ) { setImagePlacements((current) => ({ ...current, [layerId]: normalizeAgentPsdImagePlacement(updater(normalizeAgentPsdImagePlacement(current[layerId]))), })); } function nudgeImageScale(layerId: string, delta: number) { updateImagePlacement(layerId, (current) => ({ ...current, scale: Math.max(0.4, Math.min(3, Number((current.scale + delta).toFixed(2)))), })); } function resetImagePlacement(layerId: string) { setImagePlacements((current) => ({ ...current, [layerId]: getDefaultAgentPsdImagePlacement(), })); } function handleImageDragStart( layer: AgentPsdTemplateImageLayerSlot, event: ReactPointerEvent ) { if (!String(effectiveImageAssignments[layer.id] || "").trim()) { return; } imageDragStateRef.current = { layerId: layer.id, pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startPlacement: normalizeAgentPsdImagePlacement(effectiveImagePlacements[layer.id]), }; const nextTarget = event.currentTarget; try { nextTarget.setPointerCapture?.(event.pointerId); } catch (error) { console.warn("[AgentPsdTemplateEditorDialog] setPointerCapture failed:", error); } const handlePointerMove = (moveEvent: PointerEvent) => { const dragState = imageDragStateRef.current; if (!dragState || dragState.layerId !== layer.id) { return; } updateImagePlacement(layer.id, () => ({ ...dragState.startPlacement, offsetX: dragState.startPlacement.offsetX + (moveEvent.clientX - dragState.startClientX) / stageScale, offsetY: dragState.startPlacement.offsetY + (moveEvent.clientY - dragState.startClientY) / stageScale, })); }; const handlePointerUp = (upEvent: PointerEvent) => { if (imageDragStateRef.current?.pointerId === upEvent.pointerId) { imageDragStateRef.current = null; } window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointercancel", handlePointerUp); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointercancel", handlePointerUp); } async function handleRender() { if (!selectedTemplate) { toast.error("请先选择 PSD 模板"); return; } try { setIsRendering(true); const response = await fetch("/api/psd-templates/render", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ templateId: selectedTemplate.id, sourceImageUrl: sourceImageUrl || "", imageAssignments: effectiveImageAssignments, imagePlacements: effectiveImagePlacements, textAssignments: effectiveTextAssignments, }), }); const payload = (await response.json()) as AgentPsdTemplateRenderResponse; if (!response.ok || !payload.success || !payload.result?.url) { throw new Error(payload.error || "PSD 模板渲染失败"); } setRenderedResultUrl(payload.result.url); toast.success("模板替换结果已生成"); } catch (error) { toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败"); } finally { setIsRendering(false); } } function handleDownload() { if (!renderedResultUrl) return; window.open(renderedResultUrl, "_blank", "noopener,noreferrer"); } return ( 套用 PSD 模板 这里是直接在模板里替换图片和修改文字,不会再次调用 AI 生图模型。 {fontFaceCss ? : null}
{selectedTemplate ? ( <>
{selectedTemplate.name}
{selectedTemplate.brandName} {selectedTemplate.width} x {selectedTemplate.height} {editableImageLayers.length} 个可替换图片 {editableTextLayers.length} 个可编辑文字 {fixedLayerCount > 0 ? ( {fixedLayerCount} 个固定图层 ) : null}
) : (
{isLoading ? "正在加载可用模板..." : normalizedBrandName ? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。` : "当前还没有启用的 PSD 模板,请先去后台配置。"}
)}
模板画布编辑区
{selectedTemplate ? ( <> {editableImageLayers.length} 个图片替换区域 {editableTextLayers.length} 个文字编辑区域 ) : null}
点模板上的橙色框替换图片,点蓝色框直接修改文字。
{selectedTemplate ? (
{shouldShowCompositeFallback ? ( {`${selectedTemplate.name} ) : null}
{orderedPreviewLayers.map((layer) => { const layerBoxStyle = { left: layer.x, top: layer.y, width: layer.width, height: layer.height, } satisfies CSSProperties; if (isAgentPsdImageLayerSlot(layer)) { const assignedUrl = effectiveImageAssignments[layer.id]; const layerImageUrl = assignedUrl || layer.sourceUrl; if (!layerImageUrl) { return null; } return (
{layer.layerName}
); } const isSelected = selectedLayerId === layer.id; if (isSelected) { return null; } const value = effectiveTextAssignments[layer.id] || ""; const shouldUseEditedPreview = hasEditedTextValue(layer, value); if (!shouldUseEditedPreview && layer.sourceUrl) { return (
{layer.layerName}
); } if (!value && !layer.placeholder) { return null; } return (
{value || layer.placeholder || layer.layerName}
); })}
{orderedEditableImageLayers.map((layer) => { const isSelected = selectedLayerId === layer.id; const frameStyle = getLayerFrameStyle(layer, isSelected); const hasAssignedImage = Boolean( String(effectiveImageAssignments[layer.id] || "").trim() ); return isSelected && hasAssignedImage ? (
setSelectedLayerId(layer.id)} onPointerDown={(event) => handleImageDragStart(layer, event)} className="absolute overflow-hidden rounded-lg transition-all" style={{ ...frameStyle, zIndex: 20, background: "rgba(249,115,22,0.08)", cursor: imageDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab", touchAction: "none", }} aria-label={`${getLayerLabel(layer)} ${layer.layerName}`} title={`${getLayerLabel(layer)}:${layer.layerName}`} >
{layer.layerName}
拖动调整
) : ( ); })} {orderedEditableImageLayers.map((layer) => { const isSelected = selectedLayerId === layer.id; const hasAssignedImage = Boolean( String(effectiveImageAssignments[layer.id] || "").trim() ); if (!isSelected || !hasAssignedImage) { return null; } return (
); })} {orderedEditableTextLayers.map((layer) => { const isSelected = selectedLayerId === layer.id; const frameStyle = getLayerFrameStyle(layer, isSelected); const currentValue = effectiveTextAssignments[layer.id] || ""; const preserveSourceAppearance = !!layer.sourceUrl && !hasEditedTextValue(layer, currentValue); if (isSelected) { return (
{preserveSourceAppearance && layer.sourceUrl ? ( {layer.layerName} ) : null}