From 2b2b2bd228be5b56433f6221a08c4f805689dcd6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 27 Mar 2026 23:05:45 +0800 Subject: [PATCH] feat: add in-canvas PSD template editing --- .../admin/psd-templates/[id]/parse/route.ts | 2 + app/api/admin/psd-templates/[id]/route.ts | 6 + app/api/admin/psd-templates/route.ts | 2 + app/api/psd-templates/route.ts | 21 +- .../agents/AgentPsdTemplateEditorDialog.tsx | 700 ++++++++++++------ lib/agents/psd-template-config.ts | 4 + 6 files changed, 499 insertions(+), 236 deletions(-) diff --git a/app/api/admin/psd-templates/[id]/parse/route.ts b/app/api/admin/psd-templates/[id]/parse/route.ts index a7252e18e..f82195b27 100644 --- a/app/api/admin/psd-templates/[id]/parse/route.ts +++ b/app/api/admin/psd-templates/[id]/parse/route.ts @@ -147,6 +147,8 @@ function buildInitialTemplateLayers(params: { fontId: matchedFont?.font.id, fontFamily: matchedFont?.font.family, fontLabel: matchedFont?.font.name, + fontFileUrl: matchedFont?.font.fileUrl, + fontContentType: matchedFont?.font.contentType, fontSize: Math.max( 10, Math.round( diff --git a/app/api/admin/psd-templates/[id]/route.ts b/app/api/admin/psd-templates/[id]/route.ts index e293d2c7d..8e65d1299 100644 --- a/app/api/admin/psd-templates/[id]/route.ts +++ b/app/api/admin/psd-templates/[id]/route.ts @@ -103,6 +103,8 @@ async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable []); + const fontMap = new Map(fonts.map((font) => [font.id, font])); const templates = (await readAgentPsdTemplates()) .filter((template) => template.status === "active") .filter(isAgentPsdTemplateReady) @@ -35,7 +39,22 @@ export async function GET(request: NextRequest) { ) .map((template) => ({ ...template, - layers: getEditableAgentPsdTemplateLayers(template), + layers: getEditableAgentPsdTemplateLayers(template).map((layer) => { + if (!isAgentPsdTextLayerSlot(layer) || !layer.fontId) { + return layer; + } + + const font = fontMap.get(layer.fontId); + if (!font) { + return layer; + } + + return { + ...layer, + fontFileUrl: font.fileUrl, + fontContentType: font.contentType, + }; + }), })) .filter((template) => template.layers.length > 0); diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index 12e6689c9..b85ce6f96 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -1,12 +1,13 @@ "use client"; -import { useEffect, useMemo, useState, type ChangeEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Download, Image as ImageIcon, Loader2, + MousePointerClick, Sparkles, Type, Upload, @@ -30,8 +31,11 @@ import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, type AgentPsdTemplate, + type AgentPsdTemplateImageLayerSlot, + type AgentPsdTemplateLayerSlot, type AgentPsdTemplateListResponse, type AgentPsdTemplateRenderResponse, + type AgentPsdTemplateTextLayerSlot, } from "@/lib/agents/psd-template-config"; const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json()); @@ -56,6 +60,51 @@ function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: s return { imageAssignments, 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 preferredImage = template.layers.find( + (layer) => isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource + ); + if (preferredImage) { + return preferredImage.id; + } + + return template.layers[0]?.id || ""; +} + export function AgentPsdTemplateEditorDialog({ open, onOpenChange, @@ -77,11 +126,16 @@ export function AgentPsdTemplateEditorDialog({ const templates = useMemo(() => (data?.success ? data.templates : []), [data]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [selectedLayerId, setSelectedLayerId] = useState(""); const [imageAssignments, setImageAssignments] = 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 selectedTemplate = useMemo( () => templates.find((template) => template.id === selectedTemplateId) || null, @@ -107,6 +161,50 @@ export function AgentPsdTemplateEditorDialog({ return Math.max(0, selectedTemplate.layerCount - selectedTemplate.layers.length); }, [selectedTemplate]); + 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 selectedLayer = useMemo( + () => selectedTemplate?.layers.find((layer) => layer.id === selectedLayerId) || null, + [selectedLayerId, selectedTemplate] + ); + const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null; + const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null; + const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]); + useEffect(() => { if (!open) return; @@ -121,8 +219,38 @@ export function AgentPsdTemplateEditorDialog({ const initialState = createInitialState(selectedTemplate, sourceImageUrl || ""); setImageAssignments(initialState.imageAssignments); setTextAssignments(initialState.textAssignments); + setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate)); }, [open, selectedTemplate, sourceImageUrl]); + useEffect(() => { + if (!selectedTemplate) { + setStageScale(1); + return; + } + + const element = previewViewportRef.current; + if (!element) return; + + const updateScale = () => { + const rect = element.getBoundingClientRect(); + const availableWidth = Math.max(240, rect.width - 32); + const availableHeight = Math.max(240, rect.height - 32); + const nextScale = Math.min( + availableWidth / Math.max(1, selectedTemplate.width), + availableHeight / Math.max(1, selectedTemplate.height), + 1 + ); + setStageScale(Number.isFinite(nextScale) && nextScale > 0 ? nextScale : 1); + }; + + updateScale(); + const observer = new ResizeObserver(updateScale); + observer.observe(element); + return () => { + observer.disconnect(); + }; + }, [selectedTemplate]); + async function handleSlotUpload(slotId: string, event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) return; @@ -151,6 +279,14 @@ export function AgentPsdTemplateEditorDialog({ } } + function openImageUpload(slotId: string) { + setSelectedLayerId(slotId); + if (uploadInputRef.current) { + uploadInputRef.current.value = ""; + uploadInputRef.current.click(); + } + } + async function handleRender() { if (!selectedTemplate) { toast.error("请先选择 PSD 模板"); @@ -167,8 +303,8 @@ export function AgentPsdTemplateEditorDialog({ body: JSON.stringify({ templateId: selectedTemplate.id, sourceImageUrl: sourceImageUrl || "", - imageAssignments, - textAssignments, + imageAssignments: effectiveImageAssignments, + textAssignments: effectiveTextAssignments, }), }); @@ -195,7 +331,7 @@ export function AgentPsdTemplateEditorDialog({ @@ -203,12 +339,14 @@ export function AgentPsdTemplateEditorDialog({ 套用 PSD 模板 - 这里是直接把当前图片和输入文字替换进 PSD 模板,不会再次调用 AI 生图模型。 + 这里是直接在模板里替换图片和修改文字,不会再次调用 AI 生图模型。 -
-
+ {fontFaceCss ? : null} + +
+
@@ -229,7 +367,7 @@ export function AgentPsdTemplateEditorDialog({ {selectedTemplate ? ( <> -
+
- {selectedTemplate.description ? ( -
- {selectedTemplate.description} -
- ) : null} -
- 用户侧这里只会显示后台已经开放的槽位: - 图片图层显示为“可替换图片”,文字图层显示为“可编辑文字”,其余固定图层不会出现在这里。 + 操作方式更接近 PS: + 直接点击模板里的图片区域去替换图片,点击模板里的文字区域直接改现有文字。
- ) : null} - - {layer.allowUserUploadOverride ? ( - <> - handleSlotUpload(layer.id, event)} - /> - - - ) : null} - - {assignedUrl ? ( - - ) : null} -
-
- ); - }) - ) : ( -
- 这个模板当前没有开放可替换图片槽位。 -
- )} - - -
-
-
可编辑文字
- {editableTextLayers.length} -
- {editableTextLayers.length > 0 ? ( - editableTextLayers.map((layer) => { - const textValue = textAssignments[layer.id] || ""; - return ( -
-
-
{layer.layerName}
- 可编辑文字 - {layer.isRequired ? 必填 : null} - - {layer.width} x {layer.height} - - {layer.fontLabel || layer.fontFamily ? ( - {layer.fontLabel || layer.fontFamily} - ) : null} -
-
{layer.layerPath}
- -
-