fix: preserve psd layer stack in editor preview
This commit is contained in:
parent
1a82296133
commit
80db6cd48d
@ -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) =>
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user