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 {
getDefaultAgentPsdImagePlacement,
normalizeAgentPsdImagePlacement,
resolveAgentPsdImagePlacementFrame,
resolveAgentPsdImageBaseFrame,
type AgentPsdImagePlacement,
type AgentPsdImageSize,
} from "@/lib/agents/psd-image-placement";
@ -344,22 +344,25 @@ function getPlacedAssignedImageStyle(params: {
placement: AgentPsdImagePlacement;
}): CSSProperties {
const { layer, imageSize, placement } = params;
const frame = resolveAgentPsdImagePlacementFrame({
const normalizedPlacement = normalizeAgentPsdImagePlacement(placement);
const baseFrame = resolveAgentPsdImageBaseFrame({
containerWidth: layer.width,
containerHeight: layer.height,
imageWidth: imageSize.width,
imageHeight: imageSize.height,
replaceMode: layer.replaceMode,
contentBounds: layer.contentBounds,
placement,
});
return {
position: "absolute",
left: frame.left,
top: frame.top,
width: frame.width,
height: frame.height,
left: baseFrame.centerX + normalizedPlacement.offsetX,
top: baseFrame.centerY + normalizedPlacement.offsetY,
width: baseFrame.width,
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 [isRendering, setIsRendering] = useState(false);
const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
const [rightPanelView, setRightPanelView] = useState<"editor" | "export">("editor");
const [stageScale, setStageScale] = useState(1);
const previewViewportRef = useRef<HTMLDivElement | null>(null);
@ -639,6 +643,7 @@ export function AgentPsdTemplateEditorDialog({
useEffect(() => {
if (!open) return;
setRenderedResultUrl("");
setRightPanelView("editor");
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
setImageAssignments(initialState.imageAssignments);
setImagePlacements(initialState.imagePlacements);
@ -978,6 +983,7 @@ export function AgentPsdTemplateEditorDialog({
}
setRenderedResultUrl(payload.result.url);
setRightPanelView("export");
toast.success("模板替换结果已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
@ -1544,7 +1550,37 @@ export function AgentPsdTemplateEditorDialog({
</div>
<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
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)]">
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
{renderedResultUrl && rightPanelView === "export" ? (
<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 ? (
<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">
<Badge variant="default">可替换图片</Badge>
<div className="text-sm font-medium">{selectedImageLayer.layerName}</div>
@ -1570,7 +1631,7 @@ export function AgentPsdTemplateEditorDialog({
</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="h-[150px] xl:h-[160px]">
<div className="flex h-[160px] items-center justify-center p-3 xl:h-[180px]">
{effectiveImageAssignments[selectedImageLayer.id] ? (
<img
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)]"
onValueChange={(value) => {
const newScale = clampImageScale(value[0] || 1);
// offset 本身就是相对中心位移,缩放时不应按比例放大,否则会导致画面偏移看起来像“变形”。
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
scale: newScale,
@ -1658,29 +1718,27 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<div className="mt-auto flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
{
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}
}
onClick={() => {
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
使用当前图片
</Button>
@ -1728,7 +1786,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
) : 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">
<Badge variant="default">可编辑文字</Badge>
<div className="text-sm font-medium">{selectedTextLayer.layerName}</div>
@ -1741,7 +1799,7 @@ export function AgentPsdTemplateEditorDialog({
rows={4}
value={effectiveTextAssignments[selectedTextLayer.id] || ""}
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) =>
setTextAssignments((current) => ({
...current,
@ -1817,49 +1875,7 @@ export function AgentPsdTemplateEditorDialog({
</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>
</DialogContent>

View File

@ -14,6 +14,15 @@ export interface AgentPsdImageSize {
height: number;
}
export interface AgentPsdImageFrame {
width: number;
height: number;
left: number;
top: number;
centerX: number;
centerY: number;
}
export function getDefaultAgentPsdImagePlacement(): AgentPsdImagePlacement {
return {
scale: 1,
@ -106,17 +115,15 @@ function normalizeContainerBounds(params: {
};
}
export function resolveAgentPsdImagePlacementFrame(params: {
export function resolveAgentPsdImageBaseFrame(params: {
containerWidth: number;
containerHeight: number;
imageWidth: number;
imageHeight: number;
replaceMode: AgentPsdTemplateReplaceMode;
contentBounds?: AgentPsdTemplateImageContentBounds;
placement?: Partial<AgentPsdImagePlacement> | null;
}) {
}): AgentPsdImageFrame {
const { containerWidth, containerHeight, imageWidth, imageHeight, replaceMode } = params;
const placement = normalizeAgentPsdImagePlacement(params.placement);
const contentBounds = normalizeContainerBounds({
containerWidth,
containerHeight,
@ -130,23 +137,36 @@ export function resolveAgentPsdImagePlacementFrame(params: {
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 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 {
width,
height,
left,
top,
left: baseFrame.centerX + placement.offsetX - width / 2,
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 metadata = await sharp(sourceBuffer).metadata();
const normalizedSourceBuffer = await sharp(sourceBuffer).rotate().png().toBuffer();
const metadata = await sharp(normalizedSourceBuffer).metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width,
containerHeight: layer.height,
@ -470,8 +471,7 @@ export async function renderAgentPsdTemplate(params: {
placement: imagePlacements[layer.id],
});
const resizedOverlayBuffer = await sharp(sourceBuffer)
.rotate()
const resizedOverlayBuffer = await sharp(normalizedSourceBuffer)
.resize({
width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)),