fix(canvas): restore PSD template replacement entry
This commit is contained in:
parent
a7218f9905
commit
4bb5d3b9f2
@ -1056,6 +1056,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
||||
imageRemoveBackground: Scissors,
|
||||
imageBoxCutout: Scan,
|
||||
imageLocalEdit: Wand2,
|
||||
imagePsdTemplateReplace: ImageIcon,
|
||||
imageRemoveWatermark: Eraser,
|
||||
imageResize: Maximize2,
|
||||
imageTextEdit: Type,
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
FileImage,
|
||||
Flag,
|
||||
GitBranch,
|
||||
Heart,
|
||||
@ -715,8 +716,14 @@ export function CanvasWorkspace({
|
||||
canvasToolbarConfig,
|
||||
"imageLocalEdit"
|
||||
);
|
||||
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"imagePsdTemplateReplace"
|
||||
);
|
||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLocalEditEnabled = true;
|
||||
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
|
||||
const selectedImagePsdTemplateReplaceEnabled = true;
|
||||
|
||||
// Store actions
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
@ -2529,6 +2536,29 @@ 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}
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
Download,
|
||||
Eraser,
|
||||
Expand,
|
||||
FileImage,
|
||||
FlipHorizontal2,
|
||||
FlipVertical2,
|
||||
Globe2,
|
||||
@ -130,10 +131,15 @@ 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,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@ -1347,6 +1353,10 @@ export function ImageNode({
|
||||
);
|
||||
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
|
||||
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
|
||||
const imagePsdTemplateReplaceItem = getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"imagePsdTemplateReplace"
|
||||
);
|
||||
const imageRemoveWatermarkItem = getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"imageRemoveWatermark"
|
||||
@ -1419,6 +1429,8 @@ export function ImageNode({
|
||||
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
|
||||
// 也必须默认展示,避免历史配置把新功能吞掉。
|
||||
const imageLocalEditEnabled = true;
|
||||
// 模版替换的页面和接口已经存在;旧工具栏配置没有这个 key 时也要保留入口。
|
||||
const imagePsdTemplateReplaceEnabled = true;
|
||||
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imageLayerSplit"
|
||||
@ -1484,6 +1496,7 @@ export function ImageNode({
|
||||
{ key: "textEdit", enabled: imageTextEditEnabled },
|
||||
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
||||
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
||||
{ key: "psdTemplate", enabled: imagePsdTemplateReplaceEnabled },
|
||||
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
||||
{ key: "resize", enabled: imageResizeEnabled },
|
||||
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
||||
@ -1519,6 +1532,7 @@ export function ImageNode({
|
||||
imageRemoveBackgroundEnabled ||
|
||||
imageRemoveWatermarkEnabled ||
|
||||
imageEnhanceEnabled ||
|
||||
imagePsdTemplateReplaceEnabled ||
|
||||
imageNoteEnabled ||
|
||||
hasCanvasAgentAnnotations ||
|
||||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
||||
@ -1677,6 +1691,37 @@ export function ImageNode({
|
||||
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
|
||||
);
|
||||
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
||||
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);
|
||||
@ -6052,6 +6097,53 @@ export function ImageNode({
|
||||
await detectTextItems(true);
|
||||
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
||||
|
||||
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;
|
||||
@ -6071,11 +6163,17 @@ export function ImageNode({
|
||||
|
||||
if (imageNodeActionRequest.action === "local-edit") {
|
||||
handleEnterLocalEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageNodeActionRequest.action === "psd-template") {
|
||||
handleOpenPsdTemplateReplace();
|
||||
}
|
||||
}, [
|
||||
clearImageNodeActionRequest,
|
||||
handleEnterBoxCutoutMode,
|
||||
handleEnterLocalEditMode,
|
||||
handleOpenPsdTemplateReplace,
|
||||
handleOpenTextEdit,
|
||||
imageNodeActionRequest,
|
||||
node.id,
|
||||
@ -6804,6 +6902,10 @@ export function ImageNode({
|
||||
handleEnterLocalEditMode();
|
||||
return;
|
||||
}
|
||||
if (action === "psd-template") {
|
||||
handleOpenPsdTemplateReplace();
|
||||
return;
|
||||
}
|
||||
if (action === "scale-adjust") {
|
||||
handleEnterLocalEditMode(undefined, "scale_adjust");
|
||||
return;
|
||||
@ -9649,6 +9751,16 @@ export function ImageNode({
|
||||
{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}
|
||||
{imageExtendEnabled ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||||
<CanvasToolbarIcon
|
||||
@ -9800,6 +9912,90 @@ export function ImageNode({
|
||||
prompt={regeneratePrompt}
|
||||
/>
|
||||
|
||||
<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"
|
||||
@ -10356,7 +10552,7 @@ export function ImageNode({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isSelectedImageActionInline("localEdit") ? (
|
||||
{isSelectedImageActionInline("localEdit") ? (
|
||||
<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"
|
||||
disabled={isProcessing || isSubmittingLocalEdit}
|
||||
@ -10377,6 +10573,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("removeWatermark") ? (
|
||||
<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"
|
||||
@ -10651,6 +10864,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("removeWatermark") ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||||
<CanvasToolbarIcon
|
||||
|
||||
@ -220,6 +220,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
||||
label: "局部编辑",
|
||||
description: "框选多个彩色区域并按区域提示词局部改图",
|
||||
},
|
||||
{
|
||||
key: "imagePsdTemplateReplace",
|
||||
group: "节点工具栏",
|
||||
label: "模版替换",
|
||||
description: "把当前图片带入已配置的 PSD 模版进行素材替换",
|
||||
},
|
||||
{
|
||||
key: "imageRemoveWatermark",
|
||||
group: "节点工具栏",
|
||||
|
||||
@ -207,6 +207,21 @@ export const CANVAS_CAPABILITY_REGISTRY = [
|
||||
inputSchema: promptArgsSchema,
|
||||
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
|
||||
}),
|
||||
defineCapability({
|
||||
id: "image.psd_template_replace",
|
||||
title: "模版替换",
|
||||
description: "把选中图片带入已配置的 PSD 模版工作台进行素材替换。",
|
||||
clientAction: "psd-template",
|
||||
toolbarActionKey: "psdTemplate",
|
||||
toolbarSettingKey: "imagePsdTemplateReplace",
|
||||
executionMode: "interactive",
|
||||
risk: "write",
|
||||
modelPolicy: "none",
|
||||
confirmation: "interactive",
|
||||
inputSchema: emptyArgsSchema,
|
||||
agent: { exposed: false },
|
||||
mcp: { exposed: false },
|
||||
}),
|
||||
defineCapability({
|
||||
id: "image.scale_adjust",
|
||||
title: "比例调整",
|
||||
|
||||
@ -9,6 +9,7 @@ export const CANVAS_CAPABILITY_IDS = [
|
||||
"image.text_edit",
|
||||
"image.box_cutout",
|
||||
"image.local_edit",
|
||||
"image.psd_template_replace",
|
||||
"image.scale_adjust",
|
||||
"image.remove_watermark",
|
||||
"image.resize",
|
||||
@ -38,6 +39,7 @@ export type CanvasCapabilityClientAction =
|
||||
| "text-edit"
|
||||
| "box-cutout"
|
||||
| "local-edit"
|
||||
| "psd-template"
|
||||
| "scale-adjust"
|
||||
| "watermark"
|
||||
| "resize"
|
||||
@ -63,6 +65,7 @@ export type SelectedImageToolbarActionKey =
|
||||
| "textEdit"
|
||||
| "boxCutout"
|
||||
| "localEdit"
|
||||
| "psdTemplate"
|
||||
| "removeWatermark"
|
||||
| "resize"
|
||||
| "multiAngle"
|
||||
|
||||
@ -22,6 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
|
||||
"textEdit",
|
||||
"boxCutout",
|
||||
"localEdit",
|
||||
"psdTemplate",
|
||||
"removeWatermark",
|
||||
"resize",
|
||||
"multiAngle",
|
||||
|
||||
@ -4875,6 +4875,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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user