fix: streamline psd editor layout and scaling
This commit is contained in:
parent
8c0ce434dc
commit
a63db2af6b
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user