fix: streamline psd editor layout and scaling
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-04-02 10:40:26 +08:00
parent 8c0ce434dc
commit a63db2af6b
3 changed files with 131 additions and 95 deletions

View File

@ -43,7 +43,7 @@ import { Textarea } from "@/components/ui/textarea";
import { import {
getDefaultAgentPsdImagePlacement, getDefaultAgentPsdImagePlacement,
normalizeAgentPsdImagePlacement, normalizeAgentPsdImagePlacement,
resolveAgentPsdImagePlacementFrame, resolveAgentPsdImageBaseFrame,
type AgentPsdImagePlacement, type AgentPsdImagePlacement,
type AgentPsdImageSize, type AgentPsdImageSize,
} from "@/lib/agents/psd-image-placement"; } from "@/lib/agents/psd-image-placement";
@ -344,22 +344,25 @@ function getPlacedAssignedImageStyle(params: {
placement: AgentPsdImagePlacement; placement: AgentPsdImagePlacement;
}): CSSProperties { }): CSSProperties {
const { layer, imageSize, placement } = params; const { layer, imageSize, placement } = params;
const frame = resolveAgentPsdImagePlacementFrame({ const normalizedPlacement = normalizeAgentPsdImagePlacement(placement);
const baseFrame = resolveAgentPsdImageBaseFrame({
containerWidth: layer.width, containerWidth: layer.width,
containerHeight: layer.height, containerHeight: layer.height,
imageWidth: imageSize.width, imageWidth: imageSize.width,
imageHeight: imageSize.height, imageHeight: imageSize.height,
replaceMode: layer.replaceMode, replaceMode: layer.replaceMode,
contentBounds: layer.contentBounds, contentBounds: layer.contentBounds,
placement,
}); });
return { return {
position: "absolute", position: "absolute",
left: frame.left, left: baseFrame.centerX + normalizedPlacement.offsetX,
top: frame.top, top: baseFrame.centerY + normalizedPlacement.offsetY,
width: frame.width, width: baseFrame.width,
height: frame.height, height: baseFrame.height,
transform: `translate(-50%, -50%) scale(${normalizedPlacement.scale})`,
transformOrigin: "center center",
willChange: "transform",
}; };
} }
@ -442,6 +445,7 @@ export function AgentPsdTemplateEditorDialog({
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null); const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false); const [isRendering, setIsRendering] = useState(false);
const [renderedResultUrl, setRenderedResultUrl] = useState<string>(""); const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
const [rightPanelView, setRightPanelView] = useState<"editor" | "export">("editor");
const [stageScale, setStageScale] = useState(1); const [stageScale, setStageScale] = useState(1);
const previewViewportRef = useRef<HTMLDivElement | null>(null); const previewViewportRef = useRef<HTMLDivElement | null>(null);
@ -639,6 +643,7 @@ export function AgentPsdTemplateEditorDialog({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setRenderedResultUrl(""); setRenderedResultUrl("");
setRightPanelView("editor");
const initialState = createInitialState(selectedTemplate, sourceImageUrl || ""); const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
setImageAssignments(initialState.imageAssignments); setImageAssignments(initialState.imageAssignments);
setImagePlacements(initialState.imagePlacements); setImagePlacements(initialState.imagePlacements);
@ -978,6 +983,7 @@ export function AgentPsdTemplateEditorDialog({
} }
setRenderedResultUrl(payload.result.url); setRenderedResultUrl(payload.result.url);
setRightPanelView("export");
toast.success("模板替换结果已生成"); toast.success("模板替换结果已生成");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败"); toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
@ -1544,7 +1550,37 @@ export function AgentPsdTemplateEditorDialog({
</div> </div>
<div className="flex min-h-0 flex-col bg-[#09090b] p-4 xl:p-5"> <div className="flex min-h-0 flex-col bg-[#09090b] p-4 xl:p-5">
<div className="mb-3 text-sm font-medium">当前选中图层</div> <div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium">
{renderedResultUrl && rightPanelView === "export" ? "导出结果" : "当前选中图层"}
</div>
{renderedResultUrl ? (
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.03] p-1">
<button
type="button"
onClick={() => setRightPanelView("editor")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${
rightPanelView === "editor"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
}`}
>
编辑面板
</button>
<button
type="button"
onClick={() => setRightPanelView("export")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${
rightPanelView === "export"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
}`}
>
导出结果
</button>
</div>
) : null}
</div>
<Input <Input
ref={uploadInputRef} ref={uploadInputRef}
@ -1557,10 +1593,35 @@ export function AgentPsdTemplateEditorDialog({
}} }}
/> />
<div className="grid min-h-0 flex-1 gap-4 xl:grid-rows-[minmax(0,1fr)_minmax(0,0.78fr)]"> {renderedResultUrl && rightPanelView === "export" ? (
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4"> <div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
<div className="flex h-full min-h-0 flex-col justify-between gap-4">
<div className="mx-auto w-full max-w-[230px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: selectedTemplate
? `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`
: "1 / 1",
}}
>
<img
src={renderedResultUrl}
alt="PSD 模板导出结果"
className="h-full w-full object-contain"
/>
</div>
</div>
<Button onClick={handleDownload} className="w-full">
<Download className="mr-2 h-4 w-4" />
下载结果图
</Button>
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
{selectedImageLayer ? ( {selectedImageLayer ? (
<div className="min-h-0 space-y-3 overflow-y-auto pr-1"> <div className="flex h-full flex-col gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="default">可替换图片</Badge> <Badge variant="default">可替换图片</Badge>
<div className="text-sm font-medium">{selectedImageLayer.layerName}</div> <div className="text-sm font-medium">{selectedImageLayer.layerName}</div>
@ -1570,7 +1631,7 @@ export function AgentPsdTemplateEditorDialog({
</div> </div>
<div className="text-xs text-muted-foreground">{selectedImageLayer.layerPath}</div> <div className="text-xs text-muted-foreground">{selectedImageLayer.layerPath}</div>
<div className="overflow-hidden rounded-xl border border-white/10 bg-white/5"> <div className="overflow-hidden rounded-xl border border-white/10 bg-white/5">
<div className="h-[150px] xl:h-[160px]"> <div className="flex h-[160px] items-center justify-center p-3 xl:h-[180px]">
{effectiveImageAssignments[selectedImageLayer.id] ? ( {effectiveImageAssignments[selectedImageLayer.id] ? (
<img <img
src={effectiveImageAssignments[selectedImageLayer.id]} src={effectiveImageAssignments[selectedImageLayer.id]}
@ -1622,7 +1683,6 @@ export function AgentPsdTemplateEditorDialog({
thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]" thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]"
onValueChange={(value) => { onValueChange={(value) => {
const newScale = clampImageScale(value[0] || 1); const newScale = clampImageScale(value[0] || 1);
// offset 本身就是相对中心位移,缩放时不应按比例放大,否则会导致画面偏移看起来像“变形”。
updateImagePlacement(selectedImageLayer.id, (current) => ({ updateImagePlacement(selectedImageLayer.id, (current) => ({
...current, ...current,
scale: newScale, scale: newScale,
@ -1658,29 +1718,27 @@ export function AgentPsdTemplateEditorDialog({
</div> </div>
</div> </div>
) : null} ) : null}
<div className="flex flex-wrap gap-2"> <div className="mt-auto flex flex-wrap gap-2">
{sourceImageUrl ? ( {sourceImageUrl ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => {
{ setImageAssignments((current) => ({
setImageAssignments((current) => ({ ...current,
...current, [selectedImageLayer.id]: sourceImageUrl,
[selectedImageLayer.id]: sourceImageUrl, }));
})); setImagePlacements((current) => ({
setImagePlacements((current) => ({ ...current,
...current, [selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(), }));
})); setImageSizes((current) => {
setImageSizes((current) => { const next = { ...current };
const next = { ...current }; delete next[selectedImageLayer.id];
delete next[selectedImageLayer.id]; return next;
return next; });
}); }}
}
}
> >
使用当前图片 使用当前图片
</Button> </Button>
@ -1728,7 +1786,7 @@ export function AgentPsdTemplateEditorDialog({
</div> </div>
</div> </div>
) : selectedTextLayer ? ( ) : selectedTextLayer ? (
<div className="min-h-0 space-y-3 overflow-y-auto pr-1"> <div className="flex h-full flex-col gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="default">可编辑文字</Badge> <Badge variant="default">可编辑文字</Badge>
<div className="text-sm font-medium">{selectedTextLayer.layerName}</div> <div className="text-sm font-medium">{selectedTextLayer.layerName}</div>
@ -1741,7 +1799,7 @@ export function AgentPsdTemplateEditorDialog({
rows={4} rows={4}
value={effectiveTextAssignments[selectedTextLayer.id] || ""} value={effectiveTextAssignments[selectedTextLayer.id] || ""}
placeholder={selectedTextLayer.placeholder || "请输入文字"} placeholder={selectedTextLayer.placeholder || "请输入文字"}
className="min-h-[150px] resize-none xl:min-h-[132px]" className="min-h-[180px] flex-1 resize-none xl:min-h-[200px]"
onChange={(event) => onChange={(event) =>
setTextAssignments((current) => ({ setTextAssignments((current) => ({
...current, ...current,
@ -1817,49 +1875,7 @@ export function AgentPsdTemplateEditorDialog({
</div> </div>
)} )}
</div> </div>
)}
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
<div className="mb-3 text-sm font-medium">导出结果</div>
{renderedResultUrl ? (
<div className="flex h-full min-h-0 flex-col justify-between gap-4">
<div className="mx-auto w-full max-w-[210px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: selectedTemplate
? `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`
: "1 / 1",
}}
>
<img
src={renderedResultUrl}
alt="PSD 模板导出结果"
className="h-full w-full object-contain"
/>
</div>
</div>
<Button onClick={handleDownload} className="w-full">
<Download className="mr-2 h-4 w-4" />
下载结果图
</Button>
</div>
) : (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-white/[0.02] px-5 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5">
{selectedTextLayer ? (
<Type className="h-7 w-7 text-muted-foreground" />
) : (
<ImageIcon className="h-7 w-7 text-muted-foreground" />
)}
</div>
<div className="mt-3 text-sm font-medium">还未导出成品图</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
在模板画布里完成替换后,点击“应用模板并导出”,这里会显示最终图片。
</div>
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>

View File

@ -14,6 +14,15 @@ export interface AgentPsdImageSize {
height: number; height: number;
} }
export interface AgentPsdImageFrame {
width: number;
height: number;
left: number;
top: number;
centerX: number;
centerY: number;
}
export function getDefaultAgentPsdImagePlacement(): AgentPsdImagePlacement { export function getDefaultAgentPsdImagePlacement(): AgentPsdImagePlacement {
return { return {
scale: 1, scale: 1,
@ -106,17 +115,15 @@ function normalizeContainerBounds(params: {
}; };
} }
export function resolveAgentPsdImagePlacementFrame(params: { export function resolveAgentPsdImageBaseFrame(params: {
containerWidth: number; containerWidth: number;
containerHeight: number; containerHeight: number;
imageWidth: number; imageWidth: number;
imageHeight: number; imageHeight: number;
replaceMode: AgentPsdTemplateReplaceMode; replaceMode: AgentPsdTemplateReplaceMode;
contentBounds?: AgentPsdTemplateImageContentBounds; contentBounds?: AgentPsdTemplateImageContentBounds;
placement?: Partial<AgentPsdImagePlacement> | null; }): AgentPsdImageFrame {
}) {
const { containerWidth, containerHeight, imageWidth, imageHeight, replaceMode } = params; const { containerWidth, containerHeight, imageWidth, imageHeight, replaceMode } = params;
const placement = normalizeAgentPsdImagePlacement(params.placement);
const contentBounds = normalizeContainerBounds({ const contentBounds = normalizeContainerBounds({
containerWidth, containerWidth,
containerHeight, containerHeight,
@ -130,23 +137,36 @@ export function resolveAgentPsdImagePlacementFrame(params: {
replaceMode, replaceMode,
}); });
return {
width: baseFrame.width,
height: baseFrame.height,
left: contentBounds.left + baseFrame.left,
top: contentBounds.top + baseFrame.top,
centerX: contentBounds.left + baseFrame.left + baseFrame.width / 2,
centerY: contentBounds.top + baseFrame.top + baseFrame.height / 2,
};
}
export function resolveAgentPsdImagePlacementFrame(params: {
containerWidth: number;
containerHeight: number;
imageWidth: number;
imageHeight: number;
replaceMode: AgentPsdTemplateReplaceMode;
contentBounds?: AgentPsdTemplateImageContentBounds;
placement?: Partial<AgentPsdImagePlacement> | null;
}): AgentPsdImageFrame {
const placement = normalizeAgentPsdImagePlacement(params.placement);
const baseFrame = resolveAgentPsdImageBaseFrame(params);
const width = baseFrame.width * placement.scale; const width = baseFrame.width * placement.scale;
const height = baseFrame.height * placement.scale; const height = baseFrame.height * placement.scale;
const left =
contentBounds.left +
baseFrame.left -
(width - baseFrame.width) / 2 +
placement.offsetX;
const top =
contentBounds.top +
baseFrame.top -
(height - baseFrame.height) / 2 +
placement.offsetY;
return { return {
width, width,
height, height,
left, left: baseFrame.centerX + placement.offsetX - width / 2,
top, top: baseFrame.centerY + placement.offsetY - height / 2,
centerX: baseFrame.centerX + placement.offsetX,
centerY: baseFrame.centerY + placement.offsetY,
}; };
} }

View File

@ -459,7 +459,8 @@ export async function renderAgentPsdTemplate(params: {
} }
const sourceBuffer = await readCachedBuffer(assignmentUrl); const sourceBuffer = await readCachedBuffer(assignmentUrl);
const metadata = await sharp(sourceBuffer).metadata(); const normalizedSourceBuffer = await sharp(sourceBuffer).rotate().png().toBuffer();
const metadata = await sharp(normalizedSourceBuffer).metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({ const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width, containerWidth: layer.width,
containerHeight: layer.height, containerHeight: layer.height,
@ -470,8 +471,7 @@ export async function renderAgentPsdTemplate(params: {
placement: imagePlacements[layer.id], placement: imagePlacements[layer.id],
}); });
const resizedOverlayBuffer = await sharp(sourceBuffer) const resizedOverlayBuffer = await sharp(normalizedSourceBuffer)
.rotate()
.resize({ .resize({
width: Math.max(1, Math.round(placementFrame.width)), width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)), height: Math.max(1, Math.round(placementFrame.height)),