fix(canvas): restore PSD template replacement entry

This commit is contained in:
134 2026-08-07 16:08:11 +08:00
parent 265b667628
commit 6ca7feb1a0
6 changed files with 276 additions and 6 deletions

View File

@ -1053,6 +1053,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
imageBoxCutout: Scan,
imageLocalEdit: Wand2,
imageLayoutReplace: LayoutTemplate,
imagePsdTemplateReplace: ImageIcon,
imageRemoveWatermark: Eraser,
imageResize: Maximize2,
imageTextEdit: Type,

View File

@ -22,6 +22,7 @@ import {
AlertTriangle,
Check,
Download,
FileImage,
Flag,
GitBranch,
Heart,
@ -558,10 +559,15 @@ export function CanvasWorkspace({
canvasToolbarConfig,
"imageLayoutReplace"
);
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
);
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
const selectedImageLayoutReplaceEnabled = true;
const selectedImagePsdTemplateReplaceEnabled = true;
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
@ -2374,6 +2380,28 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label}
</Button>
) : null}
{selectedImagePsdTemplateReplaceEnabled && selectedImageNodes.length === 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
className={cn(
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
isWorkflowCanvas &&
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={() =>
requestImageNodeAction(selectedImageNodes[0].id, "psd-template")
}
title={selectedImagePsdTemplateReplaceItem.label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={FileImage}
item={selectedImagePsdTemplateReplaceItem}
/>
{selectedImagePsdTemplateReplaceItem.label}
</Button>
) : null}
{selectedImageLayoutReplaceEnabled && selectedImageNodes.length === 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}

View File

@ -13,6 +13,7 @@ import {
Download,
Eraser,
Expand,
FileImage,
FlipHorizontal2,
FlipVertical2,
Globe2,
@ -131,6 +132,10 @@ import {
storeCanvasAgentAnnotationEditRequest,
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
import {
createCanvasPsdTemplateLaunchToken,
saveCanvasPsdTemplateLaunchPayload,
} from "@/lib/canvas/psd-template-launch";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -1426,6 +1431,10 @@ export function ImageNode({
canvasToolbarConfig,
"imageLayoutReplace"
);
const imagePsdTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
);
const imageRemoveWatermarkItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveWatermark"
@ -1500,6 +1509,8 @@ export function ImageNode({
const imageLocalEditEnabled = true;
// 排版替换同样需要在旧配置中默认展示,避免历史配置吞掉新入口。
const imageLayoutReplaceEnabled = true;
// 模版替换的页面和接口已经存在;旧工具栏配置没有这个 key 时也要恢复入口。
const imagePsdTemplateReplaceEnabled = true;
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageLayerSplit"
@ -1565,6 +1576,7 @@ export function ImageNode({
{ key: "textEdit", enabled: imageTextEditEnabled },
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
{ key: "localEdit", enabled: imageLocalEditEnabled },
{ key: "psdTemplate", enabled: imagePsdTemplateReplaceEnabled },
{ key: "layoutReplace", enabled: imageLayoutReplaceEnabled },
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
{ key: "resize", enabled: imageResizeEnabled },
@ -1608,6 +1620,7 @@ export function ImageNode({
imageMultiAngleEnabled ||
imagePromptRegenerateEnabled ||
imageTextEditEnabled ||
imagePsdTemplateReplaceEnabled ||
imageLayoutReplaceEnabled ||
imageExtendEnabled ||
imageCropEnabled ||
@ -1766,6 +1779,37 @@ export function ImageNode({
const [isUploadingLayoutReplaceReference, setIsUploadingLayoutReplaceReference] =
useState(false);
const [isSubmittingLayoutReplace, setIsSubmittingLayoutReplace] = useState(false);
const [psdTemplateDialogOpen, setPsdTemplateDialogOpen] = useState(false);
const [psdTemplateBrandName, setPsdTemplateBrandName] = useState("");
const { data: psdTemplateSummary, isLoading: isLoadingPsdTemplateSummary } =
useSWR<{
success?: boolean;
templates?: Array<{ brandName?: string | null }>;
}>(psdTemplateDialogOpen ? "/api/psd-templates?summary=1" : null, fetcher);
const availablePsdTemplateBrands = useMemo(
() =>
Array.from(
new Set(
(psdTemplateSummary?.templates || [])
.map((template) => String(template?.brandName || "").trim())
.filter(Boolean)
)
),
[psdTemplateSummary?.templates]
);
useEffect(() => {
if (
psdTemplateDialogOpen &&
!psdTemplateBrandName.trim() &&
availablePsdTemplateBrands.length === 1
) {
setPsdTemplateBrandName(availablePsdTemplateBrands[0] || "");
}
}, [
availablePsdTemplateBrands,
psdTemplateBrandName,
psdTemplateDialogOpen,
]);
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
@ -6158,6 +6202,53 @@ export function ImageNode({
setLayoutReplaceDialogOpen(true);
}, [resetBoxCutoutState, resetLocalEditState]);
const handleOpenPsdTemplateReplace = useCallback(() => {
if (!canvasId) {
toast.error("当前图片未绑定画布,暂时无法打开模版替换");
return;
}
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("图片上传完成后再进行模版替换");
return;
}
setPsdTemplateDialogOpen(true);
}, [canvasId, data.src]);
const handleLaunchPsdTemplateReplace = useCallback(() => {
const normalizedBrandName = psdTemplateBrandName.trim();
if (!canvasId || !data.src || !isHttpImageUrl(data.src)) {
toast.error("当前图片暂时无法进行模版替换");
return;
}
if (!normalizedBrandName) {
toast.error("请先选择或输入品牌名");
return;
}
const token = createCanvasPsdTemplateLaunchToken();
const returnTo =
typeof window !== "undefined"
? `${window.location.pathname}${window.location.search}`
: `/canvas/${canvasId}`;
saveCanvasPsdTemplateLaunchPayload(token, {
brandName: normalizedBrandName,
pendingImages: [
{
id: node.id,
imageUrl: data.src,
title: displayTitle,
},
],
activeImageId: node.id,
returnTo,
templateMode: "main",
createdAt: Date.now(),
});
setPsdTemplateDialogOpen(false);
router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`);
}, [canvasId, data.src, displayTitle, node.id, psdTemplateBrandName, router]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
@ -6180,6 +6271,11 @@ export function ImageNode({
return;
}
if (imageNodeActionRequest.action === "psd-template") {
handleOpenPsdTemplateReplace();
return;
}
if (imageNodeActionRequest.action === "layout-replace") {
handleOpenLayoutReplace();
}
@ -6188,6 +6284,7 @@ export function ImageNode({
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenLayoutReplace,
handleOpenPsdTemplateReplace,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
@ -7163,6 +7260,10 @@ export function ImageNode({
handleOpenLayoutReplace();
return;
}
if (action === "psd-template") {
handleOpenPsdTemplateReplace();
return;
}
if (action === "scale-adjust") {
handleEnterLocalEditMode(undefined, "scale_adjust");
return;
@ -9998,17 +10099,27 @@ export function ImageNode({
/>
</DropdownMenuItem>
) : null}
{imageTextEditEnabled ? (
<DropdownMenuItem onClick={() => handleAction("text-edit")}>
{imageTextEditEnabled ? (
<DropdownMenuItem onClick={() => handleAction("text-edit")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Type}
item={imageTextEditItem}
/>
{imageTextEditItem.label}
</DropdownMenuItem>
) : null}
{imageLayoutReplaceEnabled ? (
{imageTextEditItem.label}
</DropdownMenuItem>
) : null}
{imagePsdTemplateReplaceEnabled ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{imageLayoutReplaceEnabled ? (
<DropdownMenuItem
onClick={() => handleAction("layout-replace")}
>
@ -10299,6 +10410,90 @@ export function ImageNode({
</DialogContent>
</Dialog>
<Dialog onOpenChange={setPsdTemplateDialogOpen} open={psdTemplateDialogOpen}>
<DialogContent className="w-[calc(100vw-1.5rem)] max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileImage className="h-5 w-5" />
模版替换
</DialogTitle>
<DialogDescription>
当前图片会作为替换素材带入 PSD 模版工作台。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-3 rounded-lg border bg-muted/20 p-3">
<img
alt={displayTitle}
className="aspect-square h-[88px] w-[88px] rounded-md border bg-background object-cover"
src={buildImageProxyUrl(data.src, { width: 240 })}
/>
<div className="min-w-0 self-center">
<div className="truncate font-medium text-sm">{displayTitle}</div>
<div className="mt-1 text-muted-foreground text-xs">
已选择 1 张画布图片
</div>
</div>
</div>
{isLoadingPsdTemplateSummary ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在读取模版品牌...
</div>
) : availablePsdTemplateBrands.length > 0 ? (
<div className="space-y-2">
<Label>快捷选择</Label>
<div className="flex max-h-32 flex-wrap gap-2 overflow-y-auto">
{availablePsdTemplateBrands.map((brand) => {
const active = psdTemplateBrandName.trim() === brand;
return (
<button
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted"
)}
key={brand}
onClick={() => setPsdTemplateBrandName(brand)}
type="button"
>
{brand}
</button>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor={`psd-template-brand-${node.id}`}>品牌名</Label>
<Input
id={`psd-template-brand-${node.id}`}
onChange={(event) => setPsdTemplateBrandName(event.target.value)}
placeholder="选择或输入已配置 PSD 模版的品牌名"
value={psdTemplateBrandName}
/>
</div>
</div>
<DialogFooter>
<Button onClick={() => setPsdTemplateDialogOpen(false)} variant="outline">
取消
</Button>
<Button
disabled={!psdTemplateBrandName.trim()}
onClick={handleLaunchPsdTemplateReplace}
>
<FileImage className="mr-2 h-4 w-4" />
打开模版替换
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ThreeDGenerationDialog
costLabel={
threedGenerationDialogMode === "world"
@ -10877,6 +11072,23 @@ export function ImageNode({
</Button>
) : null}
{isSelectedImageActionInline("psdTemplate") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("psd-template")}
title={imagePsdTemplateReplaceItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
{imagePsdTemplateReplaceItem.label}
</Button>
) : null}
{isSelectedImageActionInline("layoutReplace") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
@ -11176,6 +11388,16 @@ export function ImageNode({
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("psdTemplate") ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("layoutReplace") ? (
<DropdownMenuItem
onClick={() => handleAction("layout-replace")}

View File

@ -226,6 +226,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
label: "排版替换",
description: "参考详情图的文字排版、视觉层级和尺寸比例生成替换结果",
},
{
key: "imagePsdTemplateReplace",
group: "节点工具栏",
label: "模版替换",
description: "把当前图片带入已配置的 PSD 模版进行素材替换",
},
{
key: "imageRemoveWatermark",
group: "节点工具栏",

View File

@ -77,6 +77,7 @@ export type ImageNodeActionType =
| 'box-cutout'
| 'text-edit'
| 'local-edit'
| 'psd-template'
| 'layout-replace';
export interface ImageNodeActionRequest {

View File

@ -4855,6 +4855,18 @@ test.describe("canvas chat pure helpers", () => {
});
});
test("keeps the PSD template replacement action enabled by default", () => {
expect(
getCanvasToolbarItem(
defaultCanvasToolbarConfig,
"imagePsdTemplateReplace"
)
).toMatchObject({
label: "模版替换",
enabled: true,
});
});
test("syncs image notes into structured canvas annotations", () => {
const first = syncCanvasNodeNoteAnnotationMetadata({
metadata: {