feat: support dragging and scaling psd image slots
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-31 20:40:06 +08:00
parent d1ee239fd6
commit dc04c1d91b
4 changed files with 426 additions and 37 deletions

View File

@ -9,6 +9,7 @@ import {
type AgentPsdTemplateRenderResponse,
} from "@/lib/agents/psd-template-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
@ -19,6 +20,16 @@ const renderSchema = z.object({
templateId: z.string().trim().min(1),
sourceImageUrl: z.string().trim().optional().nullable(),
imageAssignments: z.record(z.string(), z.string()).default({}),
imagePlacements: z
.record(
z.string(),
z.object({
scale: z.number().positive().max(8).optional(),
offsetX: z.number().optional(),
offsetY: z.number().optional(),
})
)
.default({}),
textAssignments: z.record(z.string(), z.string()).default({}),
});
@ -74,17 +85,20 @@ export async function POST(request: NextRequest) {
}
const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>> = {};
const textAssignments: Record<string, string> = {};
for (const layer of editableLayers) {
if (isAgentPsdImageLayerSlot(layer)) {
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
if (explicitUrl) {
imageAssignments[layer.id] = explicitUrl;
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]);
continue;
}
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
imageAssignments[layer.id] = body.sourceImageUrl.trim();
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]);
}
}
@ -110,6 +124,7 @@ export async function POST(request: NextRequest) {
const uploaded = await renderAgentPsdTemplate({
template: resolvedTemplate,
imageAssignments,
imagePlacements,
textAssignments,
fonts,
});

View File

@ -1,16 +1,18 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent } from "react";
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,
Sparkles,
Type,
Upload,
ZoomIn,
} from "lucide-react";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
@ -26,7 +28,15 @@ 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,
@ -45,22 +55,24 @@ const PREVIEW_STAGE_MAX_SCALE = 0.82;
function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) {
const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
const textAssignments: Record<string, string> = {};
if (!template) {
return { imageAssignments, textAssignments };
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, textAssignments };
return { imageAssignments, imagePlacements, textAssignments };
}
function getLayerLabel(layer: AgentPsdTemplateLayerSlot) {
@ -172,12 +184,9 @@ function getTextLayerContentStyle(
};
}
function getAssignedImageStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProperties {
function getAssignedImageMaskStyle(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,
@ -189,6 +198,31 @@ function getAssignedImageStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProper
};
}
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");
}
@ -241,6 +275,8 @@ export function AgentPsdTemplateEditorDialog({
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
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 [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
@ -250,6 +286,13 @@ export function AgentPsdTemplateEditorDialog({
const previewViewportRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const inlineTextEditorRef = useRef<HTMLTextAreaElement | null>(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,
@ -339,12 +382,31 @@ export function AgentPsdTemplateEditorDialog({
return nextAssignments;
}, [editableTextLayers, selectedTemplate, textAssignments]);
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(
() => 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 selectedImagePlacement = selectedImageLayer
? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id])
: getDefaultAgentPsdImagePlacement();
const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]);
useEffect(() => {
@ -360,10 +422,48 @@ export function AgentPsdTemplateEditorDialog({
setRenderedResultUrl("");
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
setImageAssignments(initialState.imageAssignments);
setImagePlacements(initialState.imagePlacements);
setTextAssignments(initialState.textAssignments);
setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate));
}, [open, selectedTemplate, sourceImageUrl]);
useEffect(() => {
if (!selectedTemplate) {
setImageSizes({});
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);
@ -429,6 +529,15 @@ export function AgentPsdTemplateEditorDialog({
...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 : "素材上传失败");
@ -446,6 +555,62 @@ export function AgentPsdTemplateEditorDialog({
}
}
function updateImagePlacement(
layerId: string,
updater: (current: AgentPsdImagePlacement) => AgentPsdImagePlacement
) {
setImagePlacements((current) => ({
...current,
[layerId]: normalizeAgentPsdImagePlacement(updater(normalizeAgentPsdImagePlacement(current[layerId]))),
}));
}
function handleImageDragStart(
layer: AgentPsdTemplateImageLayerSlot,
event: ReactPointerEvent<HTMLDivElement>
) {
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;
nextTarget.setPointerCapture?.(event.pointerId);
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 模板");
@ -463,6 +628,7 @@ export function AgentPsdTemplateEditorDialog({
templateId: selectedTemplate.id,
sourceImageUrl: sourceImageUrl || "",
imageAssignments: effectiveImageAssignments,
imagePlacements: effectiveImagePlacements,
textAssignments: effectiveTextAssignments,
}),
});
@ -646,15 +812,26 @@ export function AgentPsdTemplateEditorDialog({
<img
src={layerImageUrl}
alt={layer.layerName}
className="h-full w-full"
className={assignedUrl ? "" : "h-full w-full"}
style={
assignedUrl
? getAssignedImageStyle(layer)
: {
width: "100%",
height: "100%",
objectFit: "fill",
}
assignedUrl && imageSizes[layer.id]
? getPlacedAssignedImageStyle({
layer,
imageSize: imageSizes[layer.id],
placement: effectiveImagePlacements[layer.id],
})
: assignedUrl
? {
width: "100%",
height: "100%",
objectFit: getImageFit(layer.replaceMode),
...getAssignedImageMaskStyle(layer),
}
: {
width: "100%",
height: "100%",
objectFit: "fill",
}
}
/>
</div>
@ -714,7 +891,33 @@ export function AgentPsdTemplateEditorDialog({
String(effectiveImageAssignments[layer.id] || "").trim()
);
return (
return isSelected && hasAssignedImage ? (
<div
key={layer.id}
role="button"
tabIndex={0}
onClick={() => 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}`}
>
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
<div className="pointer-events-none absolute bottom-2 right-2 flex items-center gap-1 rounded-full bg-orange-500/85 px-2 py-1 text-[11px] text-white">
<Move className="h-3 w-3" />
拖动调整
</div>
</div>
) : (
<button
key={layer.id}
type="button"
@ -867,6 +1070,50 @@ export function AgentPsdTemplateEditorDialog({
)}
</div>
</div>
{effectiveImageAssignments[selectedImageLayer.id] ? (
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Move className="h-3.5 w-3.5" />
选中画布中的图片后可直接拖动位置
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<ZoomIn className="h-3.5 w-3.5" />
缩放
</div>
<span>{Math.round(selectedImagePlacement.scale * 100)}%</span>
</div>
<Slider
min={0.4}
max={3}
step={0.01}
value={[selectedImagePlacement.scale]}
onValueChange={(value) =>
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
scale: value[0] || 1,
}))
}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}))
}
>
重置位置与缩放
</Button>
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
@ -874,10 +1121,21 @@ export function AgentPsdTemplateEditorDialog({
variant="outline"
size="sm"
onClick={() =>
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}))
{
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}
}
>
使用当前图片
@ -902,13 +1160,23 @@ export function AgentPsdTemplateEditorDialog({
type="button"
variant="ghost"
size="sm"
onClick={() =>
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;
});
}}
>
清空
</Button>

View File

@ -0,0 +1,101 @@
import type { AgentPsdTemplateReplaceMode } from "@/lib/agents/psd-template-config";
export interface AgentPsdImagePlacement {
scale: number;
offsetX: number;
offsetY: number;
}
export interface AgentPsdImageSize {
width: number;
height: number;
}
export function getDefaultAgentPsdImagePlacement(): AgentPsdImagePlacement {
return {
scale: 1,
offsetX: 0,
offsetY: 0,
};
}
export function normalizeAgentPsdImagePlacement(
value: Partial<AgentPsdImagePlacement> | null | undefined
): AgentPsdImagePlacement {
const scale = Number(value?.scale);
const offsetX = Number(value?.offsetX);
const offsetY = Number(value?.offsetY);
return {
scale: Number.isFinite(scale) && scale > 0 ? Math.max(0.1, Math.min(8, scale)) : 1,
offsetX: Number.isFinite(offsetX) ? offsetX : 0,
offsetY: Number.isFinite(offsetY) ? offsetY : 0,
};
}
function resolveBaseFittedSize(params: {
containerWidth: number;
containerHeight: number;
imageWidth: number;
imageHeight: number;
replaceMode: AgentPsdTemplateReplaceMode;
}) {
const { containerWidth, containerHeight, imageWidth, imageHeight, replaceMode } = params;
const safeImageWidth = Math.max(1, imageWidth);
const safeImageHeight = Math.max(1, imageHeight);
if (replaceMode === "stretch") {
return {
width: containerWidth,
height: containerHeight,
left: 0,
top: 0,
};
}
const fitRatio =
replaceMode === "contain"
? Math.min(containerWidth / safeImageWidth, containerHeight / safeImageHeight)
: Math.max(containerWidth / safeImageWidth, containerHeight / safeImageHeight);
const width = safeImageWidth * fitRatio;
const height = safeImageHeight * fitRatio;
return {
width,
height,
left: (containerWidth - width) / 2,
top: (containerHeight - height) / 2,
};
}
export function resolveAgentPsdImagePlacementFrame(params: {
containerWidth: number;
containerHeight: number;
imageWidth: number;
imageHeight: number;
replaceMode: AgentPsdTemplateReplaceMode;
placement?: Partial<AgentPsdImagePlacement> | null;
}) {
const { containerWidth, containerHeight, imageWidth, imageHeight, replaceMode } = params;
const placement = normalizeAgentPsdImagePlacement(params.placement);
const baseFrame = resolveBaseFittedSize({
containerWidth,
containerHeight,
imageWidth,
imageHeight,
replaceMode,
});
const width = baseFrame.width * placement.scale;
const height = baseFrame.height * placement.scale;
const left = baseFrame.left - (width - baseFrame.width) / 2 + placement.offsetX;
const top = baseFrame.top - (height - baseFrame.height) / 2 + placement.offsetY;
return {
width,
height,
left,
top,
};
}

View File

@ -9,6 +9,7 @@ import {
} from "@/lib/agents/psd-template-config";
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime";
import { normalizeAgentPsdImagePlacement, resolveAgentPsdImagePlacementFrame } from "@/lib/agents/psd-image-placement";
import { resolveTextLayout } from "@/lib/agents/psd-text-layout";
async function getSharp() {
@ -298,10 +299,11 @@ async function createTextOverlayBuffer(params: {
export async function renderAgentPsdTemplate(params: {
template: AgentPsdTemplate;
imageAssignments: Record<string, string>;
imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>;
textAssignments: Record<string, string>;
fonts: AgentPsdFont[];
}) {
const { template, imageAssignments, textAssignments, fonts } = params;
const { template, imageAssignments, imagePlacements, textAssignments, fonts } = params;
const sharp = await getSharp();
const source = await loadPsdTemplateSourceFromUrl(template.fileUrl);
const canvasWidth = Math.max(1, Math.round(Number(source.width || template.width || 1)));
@ -361,19 +363,22 @@ export async function renderAgentPsdTemplate(params: {
}
const sourceBuffer = await readCachedBuffer(assignmentUrl);
const fit =
layer.replaceMode === "stretch"
? "fill"
: layer.replaceMode === "contain"
? "contain"
: "cover";
const metadata = await sharp(sourceBuffer).metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width,
containerHeight: layer.height,
imageWidth: Math.max(1, Number(metadata.width || 1)),
imageHeight: Math.max(1, Number(metadata.height || 1)),
replaceMode: layer.replaceMode,
placement: imagePlacements[layer.id],
});
let overlayBuffer = await sharp(sourceBuffer)
.rotate()
.resize({
width: layer.width,
height: layer.height,
fit,
width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)),
fit: "fill",
background: {
r: 0,
g: 0,
@ -399,10 +404,10 @@ export async function renderAgentPsdTemplate(params: {
const fittedOverlay = await fitOverlayBufferIntoCanvas({
input: overlayBuffer,
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
left: layer.x + placementFrame.left,
top: layer.y + placementFrame.top,
width: placementFrame.width,
height: placementFrame.height,
canvasWidth,
canvasHeight,
});