fix: preserve psd layer stack in editor preview
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-28 20:53:00 +08:00
parent 1a82296133
commit 80db6cd48d
6 changed files with 280 additions and 128 deletions

View File

@ -45,7 +45,10 @@ async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable<Awaited
return template;
}
const needsRepair = template.layers.some((layer) => !String(layer.previewUrl || "").trim());
const needsRepair = template.layers.some(
(layer) =>
!String(layer.previewUrl || "").trim() || !String(layer.sourceUrl || "").trim()
);
const fonts = await readAgentPsdFonts().catch(() => []);
const needsFontDetection = template.layers.some(
(layer) =>

View File

@ -70,7 +70,10 @@ async function repairTemplateLayerPreviewsIfNeeded(params: {
return template;
}
const needsRepair = template.layers.some((layer) => !String(layer.previewUrl || "").trim());
const needsRepair = template.layers.some(
(layer) =>
!String(layer.previewUrl || "").trim() || !String(layer.sourceUrl || "").trim()
);
const fonts = await readAgentPsdFonts().catch(() => []);
const needsFontDetection = template.layers.some(
(layer) =>

View File

@ -37,9 +37,10 @@ export async function GET(request: NextRequest) {
.filter((template) =>
requestedBrand ? template.brandName === requestedBrand : true
)
.filter((template) => getEditableAgentPsdTemplateLayers(template).length > 0)
.map((template) => ({
...template,
layers: getEditableAgentPsdTemplateLayers(template).map((layer) => {
layers: template.layers.map((layer) => {
if (!isAgentPsdTextLayerSlot(layer) || !layer.fontId) {
return layer;
}
@ -55,8 +56,7 @@ export async function GET(request: NextRequest) {
fontContentType: font.contentType,
};
}),
}))
.filter((template) => template.layers.length > 0);
}));
return NextResponse.json({
success: true,

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
@ -95,14 +95,76 @@ function getPreferredSelectedLayerId(template: AgentPsdTemplate | null) {
return "";
}
const preferredImage = template.layers.find(
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 template.layers[0]?.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 getTextLayerContentStyle(layer: AgentPsdTemplateTextLayerSlot): CSSProperties {
return {
width: "100%",
height: "100%",
fontFamily: layer.fontFamily || "sans-serif",
fontSize: layer.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 getAssignedImageStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProperties {
const hasMask = Boolean(layer.maskUrl);
return {
width: "100%",
height: "100%",
objectFit: getImageFit(layer.replaceMode),
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,
};
}
export function AgentPsdTemplateEditorDialog({
@ -144,22 +206,36 @@ export function AgentPsdTemplateEditorDialog({
const editableImageLayers = useMemo(
() =>
(selectedTemplate?.layers || []).filter((layer) =>
isAgentPsdImageLayerSlot(layer)
(selectedTemplate?.layers || []).filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
),
[selectedTemplate]
);
const editableTextLayers = useMemo(
() =>
(selectedTemplate?.layers || []).filter((layer) =>
isAgentPsdTextLayerSlot(layer)
(selectedTemplate?.layers || []).filter(
(layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable && isAgentPsdTextLayerSlot(layer)
),
[selectedTemplate]
);
const fixedLayerCount = useMemo(() => {
if (!selectedTemplate) return 0;
return Math.max(0, selectedTemplate.layerCount - selectedTemplate.layers.length);
return selectedTemplate.layers.filter((layer) => !layer.isEditable).length;
}, [selectedTemplate]);
const orderedPreviewLayers = useMemo(
() => (selectedTemplate ? [...selectedTemplate.layers].reverse() : []),
[selectedTemplate]
);
const orderedEditableLayers = useMemo(
() => orderedPreviewLayers.filter((layer) => layer.isEditable),
[orderedPreviewLayers]
);
const shouldShowCompositeFallback = useMemo(
() => orderedPreviewLayers.some((layer) => !String(layer.sourceUrl || "").trim()),
[orderedPreviewLayers]
);
const effectiveImageAssignments = useMemo(() => {
const nextAssignments: Record<string, string> = {};
@ -459,129 +535,176 @@ export function AgentPsdTemplateEditorDialog({
transformOrigin: "top left",
}}
>
<img
src={selectedTemplate.renderBaseUrl || selectedTemplate.previewUrl}
alt={`${selectedTemplate.name} 编辑底图`}
className="absolute inset-0 h-full w-full object-cover"
/>
{shouldShowCompositeFallback ? (
<img
src={selectedTemplate.renderBaseUrl || selectedTemplate.previewUrl}
alt={`${selectedTemplate.name} 编辑底图`}
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
{editableImageLayers.map((layer) => {
const assignedUrl = effectiveImageAssignments[layer.id];
const isSelected = selectedLayerId === layer.id;
<div className="pointer-events-none absolute inset-0">
{orderedPreviewLayers.map((layer) => {
const layerBoxStyle = {
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
} satisfies CSSProperties;
return (
<button
key={layer.id}
type="button"
onClick={() => setSelectedLayerId(layer.id)}
className="absolute overflow-hidden rounded-lg transition-all"
style={{
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`,
background: assignedUrl ? "rgba(255,255,255,0.02)" : "rgba(249,115,22,0.12)",
}}
>
{assignedUrl ? (
<img
src={assignedUrl}
alt={layer.layerName}
className="h-full w-full"
style={{
objectFit:
layer.replaceMode === "contain"
? "contain"
: layer.replaceMode === "stretch"
? "fill"
: "cover",
WebkitMaskImage: layer.maskUrl ? `url(${layer.maskUrl})` : undefined,
maskImage: layer.maskUrl ? `url(${layer.maskUrl})` : undefined,
WebkitMaskSize: layer.maskUrl ? "100% 100%" : undefined,
maskSize: layer.maskUrl ? "100% 100%" : undefined,
WebkitMaskRepeat: layer.maskUrl ? "no-repeat" : undefined,
maskRepeat: layer.maskUrl ? "no-repeat" : undefined,
}}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-orange-500/10 text-[13px] font-medium text-orange-100">
点击替换图片
</div>
)}
if (isAgentPsdImageLayerSlot(layer)) {
const assignedUrl = effectiveImageAssignments[layer.id];
const layerImageUrl = assignedUrl || layer.sourceUrl;
if (!layerImageUrl) {
return null;
}
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
</button>
);
})}
{editableTextLayers.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const value = effectiveTextAssignments[layer.id] || "";
const sharedStyle = {
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
fontFamily: layer.fontFamily || "sans-serif",
fontSize: layer.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,
} as const;
return (
<div
key={layer.id}
className="absolute overflow-hidden rounded-lg"
style={{
...sharedStyle,
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`,
background: isSelected ? "rgba(56,189,248,0.09)" : "rgba(56,189,248,0.03)",
}}
onClick={() => setSelectedLayerId(layer.id)}
>
{isSelected ? (
<textarea
value={value}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
className="h-full w-full resize-none border-none bg-transparent outline-none"
style={sharedStyle}
/>
) : (
return (
<div
className="pointer-events-none h-full w-full whitespace-pre-wrap break-words"
style={sharedStyle}
key={layer.id}
className="absolute overflow-hidden"
style={layerBoxStyle}
>
<img
src={layerImageUrl}
alt={layer.layerName}
className="h-full w-full"
style={
assignedUrl
? getAssignedImageStyle(layer)
: {
width: "100%",
height: "100%",
objectFit: "fill",
}
}
/>
</div>
);
}
const isSelected = selectedLayerId === layer.id;
if (isSelected) {
return null;
}
const value = effectiveTextAssignments[layer.id] || "";
const defaultText = String(layer.defaultText || "");
const shouldUseEditedPreview =
value.trim().length > 0 && value.trim() !== defaultText.trim();
if (!shouldUseEditedPreview && layer.sourceUrl) {
return (
<div
key={layer.id}
className="absolute overflow-hidden"
style={layerBoxStyle}
>
<img
src={layer.sourceUrl}
alt={layer.layerName}
className="h-full w-full object-fill"
/>
</div>
);
}
if (!value && !layer.placeholder) {
return null;
}
return (
<div
key={layer.id}
className="absolute overflow-hidden"
style={layerBoxStyle}
>
<div
className="h-full w-full whitespace-pre-wrap break-words"
style={getTextLayerContentStyle(layer)}
>
{value || layer.placeholder || layer.layerName}
</div>
)}
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
</div>
);
})}
);
})}
</div>
<div className="absolute inset-0">
{orderedEditableLayers.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const frameStyle = getLayerFrameStyle(layer, isSelected);
if (isAgentPsdTextLayerSlot(layer) && isSelected) {
return (
<div
key={layer.id}
className="absolute overflow-hidden rounded-lg"
style={{
...frameStyle,
zIndex: 40,
background: "rgba(56,189,248,0.08)",
}}
>
<textarea
value={effectiveTextAssignments[layer.id] || ""}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
className="h-full w-full resize-none border-none bg-transparent outline-none"
style={getTextLayerContentStyle(layer)}
/>
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
</div>
);
}
const hasAssignedImage =
isAgentPsdImageLayerSlot(layer) &&
Boolean(String(effectiveImageAssignments[layer.id] || "").trim());
return (
<button
key={layer.id}
type="button"
onClick={() => setSelectedLayerId(layer.id)}
className="absolute overflow-hidden rounded-lg transition-all"
style={{
...frameStyle,
zIndex: isSelected ? 35 : 30,
background: isSelected
? isAgentPsdImageLayerSlot(layer)
? "rgba(249,115,22,0.08)"
: "rgba(56,189,248,0.05)"
: "transparent",
}}
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
{isAgentPsdImageLayerSlot(layer) ? (
!hasAssignedImage ? (
<div className="pointer-events-none absolute bottom-2 right-2 rounded-full bg-orange-500/85 px-2 py-1 text-[11px] text-white">
点击替换图片
</div>
) : null
) : (
<div className="pointer-events-none absolute bottom-2 right-2 rounded-full bg-sky-500/85 px-2 py-1 text-[11px] text-white">
点击编辑文字
</div>
)}
</button>
);
})}
</div>
</div>
</div>
) : (

View File

@ -24,6 +24,8 @@ export interface AgentPsdTemplateBaseLayerSlot {
isRequired: boolean;
previewUrl?: string;
previewPath?: string;
sourceUrl?: string;
sourcePath?: string;
}
export interface AgentPsdTemplateImageLayerSlot
@ -244,6 +246,8 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
isRequired: isEditable && record.isRequired === true,
previewUrl: normalizeOptionalUrl(record.previewUrl),
previewPath: normalizeText(record.previewPath) || undefined,
sourceUrl: normalizeOptionalUrl(record.sourceUrl),
sourcePath: normalizeText(record.sourcePath) || undefined,
} satisfies AgentPsdTemplateBaseLayerSlot;
if (slotType === "text") {

View File

@ -508,6 +508,25 @@ export async function preparePsdTemplateRenderAssets(params: {
}
}
if (!String(targetLayer.sourceUrl || "").trim()) {
const sourceBuffer = await rgbaToPngBuffer(rgba, entry.layer.width, entry.layer.height);
const uploadedSource = await uploadFile(
`agent-psd-layer-source-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`,
sourceBuffer.buffer.slice(
sourceBuffer.byteOffset,
sourceBuffer.byteOffset + sourceBuffer.byteLength
) as ArrayBuffer
);
const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id);
if (layerIndex >= 0) {
nextLayers[layerIndex] = {
...nextLayers[layerIndex],
sourceUrl: uploadedSource.url,
sourcePath: uploadedSource.pathname,
};
}
}
if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable || targetLayer.maskUrl) {
continue;
}