fix(canvas): restore AI layout replacement
This commit is contained in:
parent
bf6bd01fcb
commit
71b3ce56cf
@ -38,6 +38,7 @@ import {
|
||||
ImagePlus,
|
||||
Key,
|
||||
Layers,
|
||||
LayoutTemplate,
|
||||
Layers3,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
@ -1061,7 +1062,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
||||
imageRemoveBackground: Scissors,
|
||||
imageBoxCutout: Scan,
|
||||
imageLocalEdit: Wand2,
|
||||
imageTemplateReplace: ImageIcon,
|
||||
imageLayoutReplace: LayoutTemplate,
|
||||
imageRemoveWatermark: Eraser,
|
||||
imageResize: Maximize2,
|
||||
imageTextEdit: Type,
|
||||
|
||||
@ -1,930 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
CircleAlert,
|
||||
ImagePlus,
|
||||
Images,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createCanvasTemplateReplaceSlots,
|
||||
MAX_CANVAS_TEMPLATE_REPLACE_SOURCES,
|
||||
normalizeCanvasTemplateReplaceSources,
|
||||
replaceCanvasTemplateSourceSlot,
|
||||
type CanvasTemplateReplaceSource,
|
||||
} from "@/lib/canvas/template-replace-batch";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
|
||||
const TEMPLATE_REPLACE_POLL_INTERVAL_MS = 2500;
|
||||
|
||||
type TemplateReplaceStatus =
|
||||
| "waiting"
|
||||
| "submitting"
|
||||
| "queued"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
type TemplateReplaceQueueStatus = {
|
||||
queuePosition?: number;
|
||||
sameModelRunningCount?: number;
|
||||
concurrencyLimit?: number;
|
||||
estimatedWaitMs?: number;
|
||||
};
|
||||
|
||||
export type CanvasTemplateReplaceResult = {
|
||||
id: string;
|
||||
batchId: string;
|
||||
source: CanvasTemplateReplaceSource;
|
||||
referenceImageUrl: string;
|
||||
resultImageUrl?: string;
|
||||
taskId?: string;
|
||||
status: TemplateReplaceStatus;
|
||||
queueStatus?: TemplateReplaceQueueStatus;
|
||||
error?: string;
|
||||
addedToCanvas?: boolean;
|
||||
};
|
||||
|
||||
type PersistedTemplateReplaceBatch = {
|
||||
version: 1;
|
||||
slots: Array<CanvasTemplateReplaceSource | null>;
|
||||
referenceImageUrl: string;
|
||||
supplementalPrompt: string;
|
||||
results: CanvasTemplateReplaceResult[];
|
||||
};
|
||||
|
||||
type CanvasTemplateReplaceDialogProps = {
|
||||
availableSources: CanvasTemplateReplaceSource[];
|
||||
canvasId: string;
|
||||
initialSources: CanvasTemplateReplaceSource[];
|
||||
open: boolean;
|
||||
projectId?: string | null;
|
||||
triggerNodeId: string;
|
||||
onAddToCanvas: (
|
||||
result: CanvasTemplateReplaceResult,
|
||||
resultIndex: number
|
||||
) => void | Promise<void>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function isHttpImageUrl(value: unknown): value is string {
|
||||
return /^https?:\/\//i.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function createLocalId(prefix: string) {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function normalizeQueueStatus(value: unknown): TemplateReplaceQueueStatus | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const queue = value as Record<string, unknown>;
|
||||
const normalizeNumber = (entry: unknown) => {
|
||||
const number = Number(entry);
|
||||
return Number.isFinite(number) ? Math.max(0, number) : undefined;
|
||||
};
|
||||
return {
|
||||
queuePosition: normalizeNumber(queue.queuePosition),
|
||||
sameModelRunningCount: normalizeNumber(queue.sameModelRunningCount),
|
||||
concurrencyLimit: normalizeNumber(queue.concurrencyLimit),
|
||||
estimatedWaitMs: normalizeNumber(queue.estimatedWaitMs),
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusLabel(result: CanvasTemplateReplaceResult) {
|
||||
if (result.status === "waiting") return "等待上一张";
|
||||
if (result.status === "submitting") return "正在提交";
|
||||
if (result.status === "queued") {
|
||||
const position = Math.max(0, Number(result.queueStatus?.queuePosition || 0));
|
||||
return position > 0 ? `排队中(前方 ${position} 个)` : "排队中";
|
||||
}
|
||||
if (result.status === "processing") return "处理中";
|
||||
if (result.status === "completed") return "已完成";
|
||||
return "失败";
|
||||
}
|
||||
|
||||
function getPersistedBatch(
|
||||
storageKey: string
|
||||
): PersistedTemplateReplaceBatch | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = JSON.parse(window.localStorage.getItem(storageKey) || "null") as
|
||||
| Partial<PersistedTemplateReplaceBatch>
|
||||
| null;
|
||||
if (!raw || raw.version !== 1) return null;
|
||||
|
||||
const slots = createCanvasTemplateReplaceSlots(
|
||||
Array.isArray(raw.slots) ? raw.slots : []
|
||||
);
|
||||
const referenceImageUrl = isHttpImageUrl(raw.referenceImageUrl)
|
||||
? raw.referenceImageUrl.trim()
|
||||
: "";
|
||||
const results = (Array.isArray(raw.results) ? raw.results : [])
|
||||
.map((result): CanvasTemplateReplaceResult | null => {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
const source = normalizeCanvasTemplateReplaceSources([result.source])[0];
|
||||
if (!source || !isHttpImageUrl(result.referenceImageUrl)) return null;
|
||||
const taskId = String(result.taskId || "").trim() || undefined;
|
||||
const resultImageUrl = isHttpImageUrl(result.resultImageUrl)
|
||||
? result.resultImageUrl.trim()
|
||||
: undefined;
|
||||
let status: TemplateReplaceStatus = "waiting";
|
||||
if (resultImageUrl) status = "completed";
|
||||
else if (result.status === "failed") status = "failed";
|
||||
else if (taskId) status = "queued";
|
||||
|
||||
return {
|
||||
id: String(result.id || createLocalId("template-result")),
|
||||
batchId: String(result.batchId || createLocalId("template-batch")),
|
||||
source,
|
||||
referenceImageUrl: result.referenceImageUrl.trim(),
|
||||
resultImageUrl,
|
||||
taskId,
|
||||
status,
|
||||
queueStatus: normalizeQueueStatus(result.queueStatus),
|
||||
error: String(result.error || "").trim() || undefined,
|
||||
addedToCanvas: result.addedToCanvas === true,
|
||||
};
|
||||
})
|
||||
.filter((result): result is CanvasTemplateReplaceResult => Boolean(result));
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
slots,
|
||||
referenceImageUrl,
|
||||
supplementalPrompt: String(raw.supplementalPrompt || "").slice(0, 2000),
|
||||
results,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function CanvasTemplateReplaceDialog({
|
||||
availableSources,
|
||||
canvasId,
|
||||
initialSources,
|
||||
open,
|
||||
projectId,
|
||||
triggerNodeId,
|
||||
onAddToCanvas,
|
||||
onOpenChange,
|
||||
}: CanvasTemplateReplaceDialogProps) {
|
||||
const uploadIdPrefix = useId();
|
||||
const storageKey = `canvas-template-replace:${canvasId}:${triggerNodeId}`;
|
||||
const [slots, setSlots] = useState<Array<CanvasTemplateReplaceSource | null>>(
|
||||
() => createCanvasTemplateReplaceSlots(initialSources)
|
||||
);
|
||||
const [referenceImageUrl, setReferenceImageUrl] = useState("");
|
||||
const [supplementalPrompt, setSupplementalPrompt] = useState("");
|
||||
const [results, setResults] = useState<CanvasTemplateReplaceResult[]>([]);
|
||||
const [canvasPickerSlot, setCanvasPickerSlot] = useState<number | null>(null);
|
||||
const [canvasSearch, setCanvasSearch] = useState("");
|
||||
const [uploadingSlots, setUploadingSlots] = useState<number[]>([]);
|
||||
const [isUploadingReference, setIsUploadingReference] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const hydratedRef = useRef(false);
|
||||
const skipNextPersistRef = useRef(false);
|
||||
const runningRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const resultsRef = useRef<CanvasTemplateReplaceResult[]>([]);
|
||||
|
||||
const setResultsWithRef = useCallback(
|
||||
(
|
||||
updater:
|
||||
| CanvasTemplateReplaceResult[]
|
||||
| ((current: CanvasTemplateReplaceResult[]) => CanvasTemplateReplaceResult[])
|
||||
) => {
|
||||
setResults((current) => {
|
||||
const next = typeof updater === "function" ? updater(current) : updater;
|
||||
resultsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || hydratedRef.current) return;
|
||||
hydratedRef.current = true;
|
||||
const persisted = getPersistedBatch(storageKey);
|
||||
if (persisted) {
|
||||
skipNextPersistRef.current = true;
|
||||
setSlots(persisted.slots);
|
||||
setReferenceImageUrl(persisted.referenceImageUrl);
|
||||
setSupplementalPrompt(persisted.supplementalPrompt);
|
||||
resultsRef.current = persisted.results;
|
||||
setResults(persisted.results);
|
||||
return;
|
||||
}
|
||||
setSlots(createCanvasTemplateReplaceSlots(initialSources));
|
||||
}, [initialSources, open, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydratedRef.current || typeof window === "undefined") return;
|
||||
if (skipNextPersistRef.current) {
|
||||
skipNextPersistRef.current = false;
|
||||
return;
|
||||
}
|
||||
const payload: PersistedTemplateReplaceBatch = {
|
||||
version: 1,
|
||||
slots,
|
||||
referenceImageUrl,
|
||||
supplementalPrompt,
|
||||
results,
|
||||
};
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
}, [referenceImageUrl, results, slots, storageKey, supplementalPrompt]);
|
||||
|
||||
const selectedSources = useMemo(
|
||||
() => normalizeCanvasTemplateReplaceSources(slots),
|
||||
[slots]
|
||||
);
|
||||
const selectedUrls = useMemo(
|
||||
() => new Set(selectedSources.map((source) => source.imageUrl)),
|
||||
[selectedSources]
|
||||
);
|
||||
const filteredCanvasSources = useMemo(() => {
|
||||
const query = canvasSearch.trim().toLowerCase();
|
||||
return availableSources.filter(
|
||||
(source) =>
|
||||
!query ||
|
||||
source.title.toLowerCase().includes(query) ||
|
||||
source.imageUrl.toLowerCase().includes(query)
|
||||
);
|
||||
}, [availableSources, canvasSearch]);
|
||||
const completedCount = results.filter((result) => result.status === "completed").length;
|
||||
const canEditSources = !isProcessing && results.length === 0;
|
||||
const canStart =
|
||||
selectedSources.length > 0 &&
|
||||
isHttpImageUrl(referenceImageUrl) &&
|
||||
!isProcessing &&
|
||||
results.length === 0 &&
|
||||
uploadingSlots.length === 0 &&
|
||||
!isUploadingReference;
|
||||
|
||||
const updateResult = useCallback(
|
||||
(id: string, patch: Partial<CanvasTemplateReplaceResult>) => {
|
||||
setResultsWithRef((current) =>
|
||||
current.map((result) => (result.id === id ? { ...result, ...patch } : result))
|
||||
);
|
||||
},
|
||||
[setResultsWithRef]
|
||||
);
|
||||
|
||||
const uploadImage = useCallback(async (file: File, fallbackMessage: string) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("请上传图片文件");
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
const payload = await response.json().catch(() => null);
|
||||
const uploadedUrl = String(payload?.url || "").trim();
|
||||
if (!response.ok || !isHttpImageUrl(uploadedUrl)) {
|
||||
throw new Error(payload?.error || fallbackMessage);
|
||||
}
|
||||
return uploadedUrl;
|
||||
}, []);
|
||||
|
||||
const handleSourceUpload = useCallback(
|
||||
async (slotIndex: number, file?: File) => {
|
||||
if (!file || !canEditSources) return;
|
||||
setUploadingSlots((current) => [...new Set([...current, slotIndex])]);
|
||||
try {
|
||||
const imageUrl = await uploadImage(file, "替换素材上传失败");
|
||||
setSlots((current) =>
|
||||
replaceCanvasTemplateSourceSlot(current, slotIndex, {
|
||||
id: createLocalId("template-upload"),
|
||||
imageUrl,
|
||||
title: file.name || `上传素材${slotIndex + 1}`,
|
||||
origin: "upload",
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "替换素材上传失败");
|
||||
} finally {
|
||||
setUploadingSlots((current) => current.filter((index) => index !== slotIndex));
|
||||
}
|
||||
},
|
||||
[canEditSources, uploadImage]
|
||||
);
|
||||
|
||||
const handleReferenceUpload = useCallback(
|
||||
async (file?: File) => {
|
||||
if (!file || !canEditSources) return;
|
||||
setIsUploadingReference(true);
|
||||
try {
|
||||
setReferenceImageUrl(await uploadImage(file, "目标模版图上传失败"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "目标模版图上传失败");
|
||||
} finally {
|
||||
setIsUploadingReference(false);
|
||||
}
|
||||
},
|
||||
[canEditSources, uploadImage]
|
||||
);
|
||||
|
||||
const pollTask = useCallback(
|
||||
async (resultId: string, taskId: string) => {
|
||||
while (mountedRef.current) {
|
||||
const taskResponse = await imageGenerationService.getImageTask(taskId);
|
||||
const task = taskResponse.data?.task;
|
||||
if (!taskResponse.success || !task) {
|
||||
await sleep(TEMPLATE_REPLACE_POLL_INTERVAL_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = String(task.status || "").toLowerCase();
|
||||
const queueStatus = normalizeQueueStatus(task.queueStatus);
|
||||
if (status === "pending") {
|
||||
updateResult(resultId, { status: "queued", queueStatus, error: undefined });
|
||||
} else if (status === "running" || status === "processing") {
|
||||
updateResult(resultId, { status: "processing", queueStatus, error: undefined });
|
||||
} else if (status === "succeeded" || status === "completed") {
|
||||
const image = task.result?.images?.[0];
|
||||
const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim();
|
||||
if (!isHttpImageUrl(imageUrl)) {
|
||||
updateResult(resultId, { taskId: undefined });
|
||||
throw new Error("任务已完成,但没有返回结果图片");
|
||||
}
|
||||
updateResult(resultId, {
|
||||
status: "completed",
|
||||
resultImageUrl: imageUrl,
|
||||
queueStatus: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
return;
|
||||
} else if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||||
updateResult(resultId, { taskId: undefined });
|
||||
throw new Error(task.error || "模版替换生成失败");
|
||||
}
|
||||
|
||||
await sleep(TEMPLATE_REPLACE_POLL_INTERVAL_MS);
|
||||
}
|
||||
},
|
||||
[updateResult]
|
||||
);
|
||||
|
||||
const runSequential = useCallback(
|
||||
async (resultIds?: string[]) => {
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
setIsProcessing(true);
|
||||
const requestedIds = resultIds ? new Set(resultIds) : null;
|
||||
|
||||
try {
|
||||
const orderedIds = resultsRef.current
|
||||
.filter((result) => !requestedIds || requestedIds.has(result.id))
|
||||
.map((result) => result.id);
|
||||
|
||||
for (const resultId of orderedIds) {
|
||||
if (!mountedRef.current) return;
|
||||
const current = resultsRef.current.find((result) => result.id === resultId);
|
||||
if (!current || current.status === "completed") continue;
|
||||
|
||||
try {
|
||||
let taskId = current.taskId;
|
||||
if (!taskId) {
|
||||
updateResult(resultId, {
|
||||
status: "submitting",
|
||||
error: undefined,
|
||||
queueStatus: undefined,
|
||||
});
|
||||
const submitResult = await imageGenerationService.layoutReplace({
|
||||
sourceImageUrl: current.source.imageUrl,
|
||||
referenceImageUrl: current.referenceImageUrl,
|
||||
supplementalPrompt: supplementalPrompt.trim() || undefined,
|
||||
sourceNodeId: current.source.sourceNodeId,
|
||||
agentProjectId: projectId || undefined,
|
||||
referenceLabel: "无限画布批量模版替换目标图",
|
||||
imageSize: "2K",
|
||||
debugMeta: {
|
||||
operation: "canvas-template-replace-batch",
|
||||
batchId: current.batchId,
|
||||
canvasId,
|
||||
sourceIndex: resultsRef.current.findIndex(
|
||||
(result) => result.id === resultId
|
||||
),
|
||||
},
|
||||
});
|
||||
taskId = String(submitResult.data?.taskId || "").trim();
|
||||
if (!submitResult.success || !taskId) {
|
||||
throw new Error(submitResult.error || "模版替换提交失败");
|
||||
}
|
||||
updateResult(resultId, { taskId, status: "queued" });
|
||||
}
|
||||
await pollTask(resultId, taskId);
|
||||
} catch (error) {
|
||||
updateResult(resultId, {
|
||||
status: "failed",
|
||||
queueStatus: undefined,
|
||||
error: error instanceof Error ? error.message : "模版替换生成失败",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runningRef.current = false;
|
||||
if (mountedRef.current) setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[canvasId, pollTask, projectId, supplementalPrompt, updateResult]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
!hydratedRef.current ||
|
||||
runningRef.current ||
|
||||
!results.some((result) =>
|
||||
["waiting", "submitting", "queued", "processing"].includes(result.status)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void runSequential();
|
||||
}, [open, results, runSequential]);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
if (!canStart) return;
|
||||
const batchId = createLocalId("template-batch");
|
||||
const nextResults = selectedSources.map(
|
||||
(source, index): CanvasTemplateReplaceResult => ({
|
||||
id: createLocalId("template-result"),
|
||||
batchId,
|
||||
source,
|
||||
referenceImageUrl: referenceImageUrl.trim(),
|
||||
status: index === 0 ? "submitting" : "waiting",
|
||||
})
|
||||
);
|
||||
resultsRef.current = nextResults;
|
||||
setResults(nextResults);
|
||||
toast.success(`已开始处理 ${nextResults.length} 张图片`);
|
||||
void runSequential(nextResults.map((result) => result.id));
|
||||
}, [canStart, referenceImageUrl, runSequential, selectedSources]);
|
||||
|
||||
const handleRetry = useCallback(
|
||||
(result: CanvasTemplateReplaceResult) => {
|
||||
if (isProcessing) return;
|
||||
updateResult(result.id, {
|
||||
status: result.taskId ? "queued" : "waiting",
|
||||
error: undefined,
|
||||
queueStatus: undefined,
|
||||
});
|
||||
window.setTimeout(() => void runSequential([result.id]), 0);
|
||||
},
|
||||
[isProcessing, runSequential, updateResult]
|
||||
);
|
||||
|
||||
const handleAddResult = useCallback(
|
||||
async (result: CanvasTemplateReplaceResult, resultIndex: number) => {
|
||||
if (!result.resultImageUrl || result.addedToCanvas) return;
|
||||
try {
|
||||
await onAddToCanvas(result, resultIndex);
|
||||
updateResult(result.id, { addedToCanvas: true });
|
||||
toast.success("结果已添加到画布");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "添加到画布失败");
|
||||
}
|
||||
},
|
||||
[onAddToCanvas, updateResult]
|
||||
);
|
||||
|
||||
const handleAddAll = useCallback(async () => {
|
||||
const completedResults = resultsRef.current.filter(
|
||||
(result) => result.status === "completed" && !result.addedToCanvas
|
||||
);
|
||||
for (const result of completedResults) {
|
||||
const resultIndex = resultsRef.current.findIndex((item) => item.id === result.id);
|
||||
await handleAddResult(result, resultIndex);
|
||||
}
|
||||
}, [handleAddResult]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (isProcessing) return;
|
||||
const nextSlots = createCanvasTemplateReplaceSlots(initialSources);
|
||||
resultsRef.current = [];
|
||||
setResults([]);
|
||||
setSlots(nextSlots);
|
||||
setReferenceImageUrl("");
|
||||
setSupplementalPrompt("");
|
||||
setCanvasPickerSlot(null);
|
||||
if (typeof window !== "undefined") window.localStorage.removeItem(storageKey);
|
||||
}, [initialSources, isProcessing, storageKey]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1080px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
模版替换
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
最多 5 张替换素材,共用 1 张目标模版图。
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{results.length > 0 && !isProcessing ? (
|
||||
<Button className="shrink-0" onClick={handleReset} size="sm" variant="outline">
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
新建一批
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>替换素材</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{selectedSources.length}/{MAX_CANVAS_TEMPLATE_REPLACE_SOURCES}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
{slots.map((source, slotIndex) => {
|
||||
const isUploading = uploadingSlots.includes(slotIndex);
|
||||
const uploadId = `${uploadIdPrefix}-source-${slotIndex}`;
|
||||
return (
|
||||
<div
|
||||
className="group relative aspect-square min-w-0 overflow-hidden rounded-lg border bg-muted/30"
|
||||
key={`source-slot-${slotIndex}`}
|
||||
>
|
||||
{source ? (
|
||||
<>
|
||||
<AgentPreviewImage
|
||||
alt={source.title}
|
||||
className="h-full w-full object-cover"
|
||||
previewQuality={78}
|
||||
previewWidth={360}
|
||||
src={source.imageUrl}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-2 py-1.5 text-[11px] text-white">
|
||||
{slotIndex + 1}. {source.title}
|
||||
</div>
|
||||
{canEditSources ? (
|
||||
<button
|
||||
aria-label={`移除素材 ${slotIndex + 1}`}
|
||||
className="absolute top-1.5 right-1.5 flex h-7 w-7 items-center justify-center rounded-md bg-black/70 text-white opacity-100 hover:bg-black sm:opacity-0 sm:group-hover:opacity-100"
|
||||
onClick={() =>
|
||||
setSlots((current) =>
|
||||
replaceCanvasTemplateSourceSlot(current, slotIndex, null)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-2 text-muted-foreground">
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
)}
|
||||
<span className="text-xs">素材 {slotIndex + 1}</span>
|
||||
{canEditSources ? (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setCanvasPickerSlot(slotIndex)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Images className="mr-1 h-3 w-3" />
|
||||
画布
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="h-7 px-2 text-xs"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<label htmlFor={uploadId}>
|
||||
<Upload className="h-3 w-3" />
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||||
className="sr-only"
|
||||
disabled={!canEditSources || isUploading}
|
||||
id={uploadId}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
void handleSourceUpload(slotIndex, file);
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{canvasPickerSlot !== null && canEditSources ? (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Input
|
||||
className="h-9"
|
||||
onChange={(event) => setCanvasSearch(event.currentTarget.value)}
|
||||
placeholder="搜索画布图片"
|
||||
value={canvasSearch}
|
||||
/>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => setCanvasPickerSlot(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid max-h-52 grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-5 md:grid-cols-7">
|
||||
{filteredCanvasSources.map((source) => {
|
||||
const alreadySelected = selectedUrls.has(source.imageUrl);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"relative aspect-square overflow-hidden rounded-lg border bg-muted/30 text-left",
|
||||
alreadySelected
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: "hover:border-primary"
|
||||
)}
|
||||
disabled={alreadySelected}
|
||||
key={source.id}
|
||||
onClick={() => {
|
||||
setSlots((current) =>
|
||||
replaceCanvasTemplateSourceSlot(
|
||||
current,
|
||||
canvasPickerSlot,
|
||||
source
|
||||
)
|
||||
);
|
||||
setCanvasPickerSlot(null);
|
||||
setCanvasSearch("");
|
||||
}}
|
||||
title={source.title}
|
||||
type="button"
|
||||
>
|
||||
<AgentPreviewImage
|
||||
alt={source.title}
|
||||
className="h-full w-full object-cover"
|
||||
previewQuality={72}
|
||||
previewWidth={240}
|
||||
src={source.imageUrl}
|
||||
/>
|
||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-1.5 py-1 text-[10px] text-white">
|
||||
{source.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${uploadIdPrefix}-reference`}>目标模版图</Label>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Input
|
||||
className="min-w-0"
|
||||
disabled={!canEditSources}
|
||||
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
|
||||
placeholder="填写图片链接或上传"
|
||||
value={referenceImageUrl}
|
||||
/>
|
||||
<Button
|
||||
asChild
|
||||
className="shrink-0"
|
||||
variant="outline"
|
||||
>
|
||||
<label htmlFor={`${uploadIdPrefix}-reference`}>
|
||||
{isUploadingReference ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
上传
|
||||
</label>
|
||||
</Button>
|
||||
<input
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||||
className="sr-only"
|
||||
disabled={!canEditSources || isUploadingReference}
|
||||
id={`${uploadIdPrefix}-reference`}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
void handleReferenceUpload(file);
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-[clamp(160px,28dvh,280px)] items-center justify-center overflow-hidden rounded-lg border border-dashed bg-muted/20">
|
||||
{isUploadingReference ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : isHttpImageUrl(referenceImageUrl) ? (
|
||||
<AgentPreviewImage
|
||||
alt="目标模版图"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
previewQuality={82}
|
||||
previewWidth={720}
|
||||
src={referenceImageUrl}
|
||||
/>
|
||||
) : (
|
||||
<LayoutTemplate className="h-8 w-8 text-muted-foreground/45" />
|
||||
)}
|
||||
</div>
|
||||
<Label htmlFor={`${uploadIdPrefix}-prompt`}>补充要求</Label>
|
||||
<Textarea
|
||||
className="min-h-20 max-h-36"
|
||||
disabled={!canEditSources}
|
||||
id={`${uploadIdPrefix}-prompt`}
|
||||
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
|
||||
placeholder="可选:补充材质、颜色或替换细节"
|
||||
value={supplementalPrompt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>生图结果</Label>
|
||||
{results.length > 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{completedCount}/{results.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{results.length === 0 ? (
|
||||
<div className="flex h-[clamp(220px,42dvh,420px)] items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Images className="mx-auto mb-2 h-7 w-7 opacity-45" />
|
||||
<p className="text-sm">待生成</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-[clamp(300px,52dvh,560px)] grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||
{results.map((result, resultIndex) => {
|
||||
const active = ["submitting", "queued", "processing"].includes(
|
||||
result.status
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 overflow-hidden rounded-lg border bg-background"
|
||||
key={result.id}
|
||||
>
|
||||
<div className="relative aspect-[4/3] bg-muted/30">
|
||||
<AgentPreviewImage
|
||||
alt={`${result.source.title}结果`}
|
||||
className="h-full w-full object-contain"
|
||||
previewQuality={82}
|
||||
previewWidth={640}
|
||||
src={result.resultImageUrl || result.source.imageUrl}
|
||||
/>
|
||||
{result.status !== "completed" ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-[1px]">
|
||||
<div className="max-w-[85%] text-center">
|
||||
{active ? (
|
||||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
||||
) : result.status === "failed" ? (
|
||||
<CircleAlert className="mx-auto mb-2 h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<Loader2 className="mx-auto mb-2 h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<p className="text-sm">{getStatusLabel(result)}</p>
|
||||
{result.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-destructive text-xs">
|
||||
{result.error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 p-2">
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{resultIndex + 1}. {result.source.title}
|
||||
</span>
|
||||
{result.status === "completed" ? (
|
||||
<Button
|
||||
className="h-8 shrink-0 px-2 text-xs"
|
||||
disabled={result.addedToCanvas}
|
||||
onClick={() => void handleAddResult(result, resultIndex)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{result.addedToCanvas ? (
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{result.addedToCanvas ? "已添加" : "添加到画布"}
|
||||
</Button>
|
||||
) : result.status === "failed" ? (
|
||||
<Button
|
||||
className="h-8 shrink-0 px-2 text-xs"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleRetry(result)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
重试
|
||||
</Button>
|
||||
) : (
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{getStatusLabel(result)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 gap-2 border-t bg-background px-4 py-3 sm:px-6">
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
|
||||
{isProcessing ? "收起" : "关闭"}
|
||||
</Button>
|
||||
{completedCount > 0 ? (
|
||||
<Button
|
||||
disabled={!results.some(
|
||||
(result) => result.status === "completed" && !result.addedToCanvas
|
||||
)}
|
||||
onClick={() => void handleAddAll()}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
全部添加到画布
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={!canStart} onClick={handleStart} type="button">
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LayoutTemplate className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
{isProcessing
|
||||
? `处理中 ${completedCount}/${results.length}`
|
||||
: `批量处理${selectedSources.length ? `(${selectedSources.length})` : ""}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -20,12 +20,12 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
FileImage,
|
||||
Flag,
|
||||
GitBranch,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
ImagePlus,
|
||||
LayoutTemplate,
|
||||
LayoutGrid,
|
||||
Lightbulb,
|
||||
Minus,
|
||||
@ -716,14 +716,14 @@ export function CanvasWorkspace({
|
||||
canvasToolbarConfig,
|
||||
"imageLocalEdit"
|
||||
);
|
||||
const selectedImageTemplateReplaceItem = getCanvasToolbarItem(
|
||||
const selectedImageLayoutReplaceItem = getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"imageTemplateReplace"
|
||||
"imageLayoutReplace"
|
||||
);
|
||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLocalEditEnabled = true;
|
||||
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
|
||||
const selectedImageTemplateReplaceEnabled = true;
|
||||
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLayoutReplaceEnabled = true;
|
||||
|
||||
// Store actions
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
@ -2536,7 +2536,7 @@ export function CanvasWorkspace({
|
||||
{selectedImageLocalEditItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedImageTemplateReplaceEnabled &&
|
||||
{selectedImageLayoutReplaceEnabled &&
|
||||
selectedImageNodes.length === 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
@ -2547,16 +2547,16 @@ export function CanvasWorkspace({
|
||||
"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, "template-replace")
|
||||
requestImageNodeAction(selectedImageNodes[0].id, "layout-replace")
|
||||
}
|
||||
title={selectedImageTemplateReplaceItem.label}
|
||||
title={selectedImageLayoutReplaceItem.label}
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-3.5 w-3.5"
|
||||
fallback={FileImage}
|
||||
item={selectedImageTemplateReplaceItem}
|
||||
fallback={LayoutTemplate}
|
||||
item={selectedImageLayoutReplaceItem}
|
||||
/>
|
||||
{selectedImageTemplateReplaceItem.label}
|
||||
{selectedImageLayoutReplaceItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
Globe2,
|
||||
ImagePlus,
|
||||
Layers,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
Link2,
|
||||
Maximize2,
|
||||
@ -52,10 +53,6 @@ import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceO
|
||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||
import {
|
||||
CanvasTemplateReplaceDialog,
|
||||
type CanvasTemplateReplaceResult,
|
||||
} from "@/components/canvas/CanvasTemplateReplaceDialog";
|
||||
import {
|
||||
areImageAdjustmentsEqual,
|
||||
buildImageAdjustmentFilterStyle,
|
||||
@ -140,6 +137,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@ -225,7 +223,6 @@ import {
|
||||
} from "@/lib/ai-wash/config";
|
||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||
import type { CanvasTemplateReplaceSource } from "@/lib/canvas/template-replace-batch";
|
||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||
@ -660,6 +657,8 @@ const CANVAS_3D_POLL_INTERVAL_MS = 2000;
|
||||
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
|
||||
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
|
||||
const AI_WASH_TASK_POLL_LIMIT = 80;
|
||||
const LAYOUT_REPLACE_TASK_POLL_INTERVAL_MS = 2500;
|
||||
const LAYOUT_REPLACE_TASK_POLL_LIMIT = 240;
|
||||
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
|
||||
const HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
|
||||
const HYDRATED_IMAGE_TASK_POLL_LIMIT = 240;
|
||||
@ -805,6 +804,76 @@ async function pollAiWashQuickTask(taskId: string) {
|
||||
throw new Error("AI洗图任务轮询超时,请稍后重试");
|
||||
}
|
||||
|
||||
async function pollLayoutReplaceTask(taskId: string) {
|
||||
for (let attempt = 0; attempt < LAYOUT_REPLACE_TASK_POLL_LIMIT; attempt += 1) {
|
||||
await sleep(LAYOUT_REPLACE_TASK_POLL_INTERVAL_MS);
|
||||
const taskResult = await imageGenerationService.getImageTask(taskId);
|
||||
const task = taskResult.data?.task;
|
||||
const status = String(task?.status || "").toLowerCase();
|
||||
|
||||
if (!taskResult.success || !task) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === "succeeded" || status === "completed") {
|
||||
const image = task.result?.images?.[0];
|
||||
const imageUrl = image?.originalUrl || image?.thumbnailUrl || "";
|
||||
if (!imageUrl) {
|
||||
throw new Error("排版替换已完成但未返回图片");
|
||||
}
|
||||
return {
|
||||
imageUrl,
|
||||
thumbnailUrl: image?.thumbnailUrl || imageUrl,
|
||||
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
|
||||
prompt: String(task.result?.prompt || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||||
throw new Error(task.error || "排版替换生成失败");
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("排版替换任务等待超时,请稍后重试");
|
||||
}
|
||||
|
||||
async function resolveImageNaturalDimensions(imageUrl: string) {
|
||||
const source = String(imageUrl || "").trim();
|
||||
if (!source || typeof window === "undefined") {
|
||||
throw new Error("参考图地址不可用");
|
||||
}
|
||||
|
||||
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||||
const image = new window.Image();
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
};
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("读取参考图尺寸超时"));
|
||||
}, 8000);
|
||||
|
||||
image.onload = () => {
|
||||
const width = image.naturalWidth || image.width;
|
||||
const height = image.naturalHeight || image.height;
|
||||
cleanup();
|
||||
if (width > 0 && height > 0) {
|
||||
resolve({ width, height });
|
||||
return;
|
||||
}
|
||||
reject(new Error("参考图尺寸不可用"));
|
||||
};
|
||||
image.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error("读取参考图尺寸失败"));
|
||||
};
|
||||
image.decoding = "async";
|
||||
image.src = buildImageProxyUrl(source, { delivery: "proxy" });
|
||||
});
|
||||
}
|
||||
|
||||
const isTerminal3DFailureStatus = (status?: string | null) =>
|
||||
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
|
||||
|
||||
@ -1357,9 +1426,9 @@ export function ImageNode({
|
||||
);
|
||||
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
|
||||
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
|
||||
const imageTemplateReplaceItem = getCanvasToolbarItem(
|
||||
const imageLayoutReplaceItem = getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"imageTemplateReplace"
|
||||
"imageLayoutReplace"
|
||||
);
|
||||
const imageRemoveWatermarkItem = getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
@ -1433,8 +1502,8 @@ export function ImageNode({
|
||||
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
|
||||
// 也必须默认展示,避免历史配置把新功能吞掉。
|
||||
const imageLocalEditEnabled = true;
|
||||
// 模版替换是无限画布图片的固定能力,历史配置缺少新 key 时仍保持可见。
|
||||
const imageTemplateReplaceEnabled = true;
|
||||
// 排版替换是无限画布图片的核心 AI 入口;旧配置缺少该 key 时也保持可见。
|
||||
const imageLayoutReplaceEnabled = true;
|
||||
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imageLayerSplit"
|
||||
@ -1500,7 +1569,7 @@ export function ImageNode({
|
||||
{ key: "textEdit", enabled: imageTextEditEnabled },
|
||||
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
||||
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
||||
{ key: "templateReplace", enabled: imageTemplateReplaceEnabled },
|
||||
{ key: "layoutReplace", enabled: imageLayoutReplaceEnabled },
|
||||
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
||||
{ key: "resize", enabled: imageResizeEnabled },
|
||||
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
||||
@ -1536,7 +1605,7 @@ export function ImageNode({
|
||||
imageRemoveBackgroundEnabled ||
|
||||
imageRemoveWatermarkEnabled ||
|
||||
imageEnhanceEnabled ||
|
||||
imageTemplateReplaceEnabled ||
|
||||
imageLayoutReplaceEnabled ||
|
||||
imageNoteEnabled ||
|
||||
hasCanvasAgentAnnotations ||
|
||||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
||||
@ -1695,7 +1764,12 @@ export function ImageNode({
|
||||
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
|
||||
);
|
||||
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
||||
const [templateReplaceDialogOpen, setTemplateReplaceDialogOpen] = useState(false);
|
||||
const [layoutReplaceDialogOpen, setLayoutReplaceDialogOpen] = useState(false);
|
||||
const [layoutReplaceReferenceUrl, setLayoutReplaceReferenceUrl] = useState("");
|
||||
const [layoutReplacePrompt, setLayoutReplacePrompt] = useState("");
|
||||
const [isUploadingLayoutReplaceReference, setIsUploadingLayoutReplaceReference] =
|
||||
useState(false);
|
||||
const [isSubmittingLayoutReplace, setIsSubmittingLayoutReplace] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||
@ -2324,51 +2398,6 @@ export function ImageNode({
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const displayTitle = getImageNodeTitle(data, node.id);
|
||||
const availableTemplateReplaceSources = useMemo<CanvasTemplateReplaceSource[]>(
|
||||
() =>
|
||||
allNodes.flatMap((canvasNode) => {
|
||||
if (canvasNode.type !== "image") return [];
|
||||
const imageData = canvasNode.data as ImageNodeData;
|
||||
if (!isHttpImageUrl(imageData.src)) return [];
|
||||
return [
|
||||
{
|
||||
id: canvasNode.id,
|
||||
imageUrl: imageData.src,
|
||||
title: getImageNodeTitle(imageData, canvasNode.id),
|
||||
origin: "canvas" as const,
|
||||
sourceNodeId: canvasNode.id,
|
||||
},
|
||||
];
|
||||
}),
|
||||
[allNodes]
|
||||
);
|
||||
const initialTemplateReplaceSources = useMemo<CanvasTemplateReplaceSource[]>(() => {
|
||||
const sourceByNodeId = new Map(
|
||||
availableTemplateReplaceSources.map((source) => [source.sourceNodeId, source])
|
||||
);
|
||||
const currentSource = sourceByNodeId.get(node.id) ||
|
||||
(isHttpImageUrl(data.src)
|
||||
? {
|
||||
id: node.id,
|
||||
imageUrl: data.src,
|
||||
title: displayTitle,
|
||||
origin: "canvas" as const,
|
||||
sourceNodeId: node.id,
|
||||
}
|
||||
: null);
|
||||
return [
|
||||
currentSource,
|
||||
...selectedNodeIds
|
||||
.filter((selectedNodeId) => selectedNodeId !== node.id)
|
||||
.map((selectedNodeId) => sourceByNodeId.get(selectedNodeId) || null),
|
||||
].filter((source): source is CanvasTemplateReplaceSource => Boolean(source));
|
||||
}, [
|
||||
availableTemplateReplaceSources,
|
||||
data.src,
|
||||
displayTitle,
|
||||
node.id,
|
||||
selectedNodeIds,
|
||||
]);
|
||||
const agentExecutionSnapshotId =
|
||||
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
||||
? data.metadata.agentExecutionSnapshotId.trim()
|
||||
@ -6113,17 +6142,22 @@ export function ImageNode({
|
||||
await detectTextItems(true);
|
||||
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
||||
|
||||
const handleOpenTemplateReplace = useCallback(() => {
|
||||
if (!canvasId) {
|
||||
toast.error("当前图片未绑定画布,暂时无法进行模版替换");
|
||||
return;
|
||||
}
|
||||
if (!data.src || !isHttpImageUrl(data.src)) {
|
||||
toast.error("图片上传完成后再进行模版替换");
|
||||
return;
|
||||
}
|
||||
setTemplateReplaceDialogOpen(true);
|
||||
}, [canvasId, data.src]);
|
||||
const handleOpenLayoutReplace = useCallback(() => {
|
||||
setCropPanelOpen(false);
|
||||
setCropInteraction(null);
|
||||
setImageAdjustPanelOpen(false);
|
||||
setFlipRotatePanelOpen(false);
|
||||
setAnglePanelOpen(false);
|
||||
setTextEditPanelOpen(false);
|
||||
setNotePanelOpen(false);
|
||||
setPromptDialogOpen(false);
|
||||
setAiWashQuickPanelOpen(false);
|
||||
resetBoxCutoutState();
|
||||
resetLocalEditState();
|
||||
setLayoutReplaceReferenceUrl("");
|
||||
setLayoutReplacePrompt("");
|
||||
setLayoutReplaceDialogOpen(true);
|
||||
}, [resetBoxCutoutState, resetLocalEditState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
||||
@ -6147,14 +6181,14 @@ export function ImageNode({
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageNodeActionRequest.action === "template-replace") {
|
||||
handleOpenTemplateReplace();
|
||||
if (imageNodeActionRequest.action === "layout-replace") {
|
||||
handleOpenLayoutReplace();
|
||||
}
|
||||
}, [
|
||||
clearImageNodeActionRequest,
|
||||
handleEnterBoxCutoutMode,
|
||||
handleEnterLocalEditMode,
|
||||
handleOpenTemplateReplace,
|
||||
handleOpenLayoutReplace,
|
||||
handleOpenTextEdit,
|
||||
imageNodeActionRequest,
|
||||
node.id,
|
||||
@ -6444,6 +6478,248 @@ export function ImageNode({
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleLayoutReplaceReferenceUpload = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("请上传图片文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingLayoutReplaceReference(true);
|
||||
try {
|
||||
const uploaded = await uploadChatAttachmentBlob(file, file.name);
|
||||
setLayoutReplaceReferenceUrl(uploaded.url);
|
||||
toast.success("参考图已上传");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "参考图上传失败");
|
||||
} finally {
|
||||
setIsUploadingLayoutReplaceReference(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmitLayoutReplace = useCallback(async () => {
|
||||
const sourceUrl = String(data.src || "").trim();
|
||||
const referenceUrl = layoutReplaceReferenceUrl.trim();
|
||||
|
||||
if (!sourceUrl) {
|
||||
toast.error("请先上传图片");
|
||||
return;
|
||||
}
|
||||
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(sourceUrl)) {
|
||||
toast.error("图片仍在本地,上传完成后再进行 AI 处理");
|
||||
return;
|
||||
}
|
||||
if (!isHttpImageUrl(referenceUrl)) {
|
||||
toast.error("请先上传或填写可访问的参考图地址");
|
||||
return;
|
||||
}
|
||||
if (isProcessing || isSubmittingLayoutReplace) {
|
||||
toast.info("正在处理中,请稍候...");
|
||||
return;
|
||||
}
|
||||
|
||||
const actionName = "排版替换";
|
||||
const resultTitle = `${displayTitle}-${actionName}`;
|
||||
const displayPrompt = `${actionName}处理中...`;
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
setLayoutReplaceDialogOpen(false);
|
||||
setIsCreatingDetachedResultNode(true);
|
||||
setIsProcessing(true);
|
||||
setIsSubmittingLayoutReplace(true);
|
||||
setAiWashQuickPanelOpen(false);
|
||||
|
||||
const newNodeId = addNode({
|
||||
type: "image",
|
||||
position: {
|
||||
x: node.position.x + node.size.width + 20,
|
||||
y: node.position.y,
|
||||
},
|
||||
size: { width: node.size.width, height: node.size.height },
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: displayPrompt,
|
||||
title: resultTitle,
|
||||
originalSrc: sourceUrl,
|
||||
metadata: {
|
||||
editMode: "layout-replace",
|
||||
sourceNodeId: node.id,
|
||||
layoutReferenceUrl: referenceUrl,
|
||||
layoutReplaceStartedAt: createdAt,
|
||||
},
|
||||
},
|
||||
connections: [node.id],
|
||||
});
|
||||
selectNode(newNodeId);
|
||||
|
||||
let aspectRatio = guessNearestAspectRatio(node.size.width, node.size.height);
|
||||
let referenceDimensions: { width: number; height: number } | null = null;
|
||||
|
||||
try {
|
||||
try {
|
||||
referenceDimensions = await resolveImageNaturalDimensions(referenceUrl);
|
||||
aspectRatio = guessNearestAspectRatio(
|
||||
referenceDimensions.width,
|
||||
referenceDimensions.height
|
||||
);
|
||||
const resultSize = fitSizeWithinBox({
|
||||
maxHeight: Math.max(
|
||||
320,
|
||||
Math.min(1800, Math.round(Math.max(node.size.height, node.size.width) * 3.2))
|
||||
),
|
||||
maxWidth: Math.max(160, Math.min(900, Math.round(node.size.width))),
|
||||
minHeight: 120,
|
||||
minWidth: 120,
|
||||
sourceHeight: referenceDimensions.height,
|
||||
sourceWidth: referenceDimensions.width,
|
||||
});
|
||||
updateNode(newNodeId, {
|
||||
size: resultSize,
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: displayPrompt,
|
||||
title: resultTitle,
|
||||
originalSrc: sourceUrl,
|
||||
metadata: {
|
||||
editMode: "layout-replace",
|
||||
sourceNodeId: node.id,
|
||||
layoutReferenceUrl: referenceUrl,
|
||||
layoutReferenceWidth: referenceDimensions.width,
|
||||
layoutReferenceHeight: referenceDimensions.height,
|
||||
layoutAspectRatio: aspectRatio,
|
||||
layoutReplaceStartedAt: createdAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (dimensionError) {
|
||||
console.warn("[Canvas Layout Replace] 读取参考图尺寸失败:", dimensionError);
|
||||
}
|
||||
|
||||
const submitResult = await imageGenerationService.layoutReplace({
|
||||
sourceImageUrl: sourceUrl,
|
||||
referenceImageUrl: referenceUrl,
|
||||
supplementalPrompt: layoutReplacePrompt,
|
||||
imageSize: "2K",
|
||||
sourceNodeId: node.id,
|
||||
referenceLabel: "画布排版参考图",
|
||||
debugMeta: {
|
||||
layoutReferenceUrl: referenceUrl,
|
||||
layoutReferenceWidth: referenceDimensions?.width,
|
||||
layoutReferenceHeight: referenceDimensions?.height,
|
||||
layoutAspectRatio: aspectRatio,
|
||||
},
|
||||
});
|
||||
|
||||
if (!submitResult.success) {
|
||||
throw new Error(submitResult.error || "排版替换提交失败");
|
||||
}
|
||||
|
||||
const taskId = submitResult.data?.taskId;
|
||||
if (!taskId) {
|
||||
throw new Error("排版替换未返回任务ID");
|
||||
}
|
||||
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: displayPrompt,
|
||||
title: resultTitle,
|
||||
originalSrc: sourceUrl,
|
||||
metadata: {
|
||||
editMode: "layout-replace",
|
||||
sourceNodeId: node.id,
|
||||
taskId,
|
||||
layoutReferenceUrl: referenceUrl,
|
||||
layoutReferenceWidth: referenceDimensions?.width,
|
||||
layoutReferenceHeight: referenceDimensions?.height,
|
||||
layoutAspectRatio: aspectRatio,
|
||||
layoutReplaceStartedAt: createdAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completed = await pollLayoutReplaceTask(taskId);
|
||||
|
||||
if (!completed?.imageUrl) {
|
||||
throw new Error("排版替换未返回结果图片");
|
||||
}
|
||||
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: completed.imageUrl,
|
||||
status: "completed",
|
||||
prompt: `${actionName}结果`,
|
||||
title: resultTitle,
|
||||
originalSrc: sourceUrl,
|
||||
model: completed.modelId || undefined,
|
||||
metadata: {
|
||||
editMode: "layout-replace",
|
||||
sourceNodeId: node.id,
|
||||
taskId,
|
||||
thumbnailUrl: completed.thumbnailUrl || completed.imageUrl,
|
||||
layoutReferenceUrl: referenceUrl,
|
||||
layoutReferenceWidth: referenceDimensions?.width,
|
||||
layoutReferenceHeight: referenceDimensions?.height,
|
||||
layoutAspectRatio: aspectRatio,
|
||||
normalizedImageSize: referenceDimensions
|
||||
? `${referenceDimensions.width}x${referenceDimensions.height}`
|
||||
: undefined,
|
||||
layoutReplaceStartedAt: createdAt,
|
||||
layoutReplaceCompletedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
toast.success("排版替换结果已生成到画布");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "排版替换失败";
|
||||
console.error("[Canvas Layout Replace] failed:", error);
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: "",
|
||||
status: "failed",
|
||||
prompt: `${actionName}失败`,
|
||||
title: `${resultTitle}-失败`,
|
||||
originalSrc: sourceUrl,
|
||||
metadata: {
|
||||
editMode: "layout-replace",
|
||||
sourceNodeId: node.id,
|
||||
error: message,
|
||||
layoutReferenceUrl: referenceUrl,
|
||||
layoutReferenceWidth: referenceDimensions?.width,
|
||||
layoutReferenceHeight: referenceDimensions?.height,
|
||||
layoutAspectRatio: aspectRatio,
|
||||
layoutReplaceStartedAt: createdAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsCreatingDetachedResultNode(false);
|
||||
setIsProcessing(false);
|
||||
setIsSubmittingLayoutReplace(false);
|
||||
}
|
||||
}, [
|
||||
addNode,
|
||||
data.src,
|
||||
displayTitle,
|
||||
isProcessing,
|
||||
isSubmittingLayoutReplace,
|
||||
layoutReplacePrompt,
|
||||
layoutReplaceReferenceUrl,
|
||||
node,
|
||||
selectNode,
|
||||
updateNode,
|
||||
]);
|
||||
|
||||
// 获取操作的中文名称
|
||||
const getActionName = (action: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
@ -6464,6 +6740,7 @@ export function ImageNode({
|
||||
"prompt-regenerate": "重绘",
|
||||
"box-cutout": "框选抠图",
|
||||
"local-edit": "局部编辑",
|
||||
"layout-replace": "排版替换",
|
||||
};
|
||||
return names[action] || action;
|
||||
};
|
||||
@ -6906,8 +7183,8 @@ export function ImageNode({
|
||||
handleEnterLocalEditMode();
|
||||
return;
|
||||
}
|
||||
if (action === "template-replace") {
|
||||
handleOpenTemplateReplace();
|
||||
if (action === "layout-replace") {
|
||||
handleOpenLayoutReplace();
|
||||
return;
|
||||
}
|
||||
if (action === "scale-adjust") {
|
||||
@ -7078,55 +7355,6 @@ export function ImageNode({
|
||||
]
|
||||
);
|
||||
|
||||
const handleTemplateReplaceAddToCanvas = useCallback(
|
||||
(result: CanvasTemplateReplaceResult, resultIndex: number) => {
|
||||
const resultImageUrl = String(result.resultImageUrl || "").trim();
|
||||
if (!isHttpImageUrl(resultImageUrl)) {
|
||||
throw new Error("模版替换结果地址不可用");
|
||||
}
|
||||
|
||||
const gap = 24;
|
||||
const column = resultIndex % 2;
|
||||
const row = Math.floor(resultIndex / 2);
|
||||
const addedNodeId = addNode({
|
||||
type: "image",
|
||||
position: {
|
||||
x: node.position.x + node.size.width + gap + column * (node.size.width + gap),
|
||||
y: node.position.y + row * (node.size.height + gap),
|
||||
},
|
||||
size: { width: node.size.width, height: node.size.height },
|
||||
data: {
|
||||
src: resultImageUrl,
|
||||
status: "completed",
|
||||
prompt: "模版替换结果",
|
||||
title: `${result.source.title}-模版替换`,
|
||||
originalSrc: result.source.imageUrl,
|
||||
metadata: {
|
||||
operation: "canvas-template-replace-batch",
|
||||
batchId: result.batchId,
|
||||
taskId: result.taskId,
|
||||
sourceNodeId: result.source.sourceNodeId,
|
||||
sourceImageUrl: result.source.imageUrl,
|
||||
referenceImageUrl: result.referenceImageUrl,
|
||||
},
|
||||
},
|
||||
connections: result.source.sourceNodeId
|
||||
? [result.source.sourceNodeId]
|
||||
: [node.id],
|
||||
});
|
||||
selectNode(addedNodeId);
|
||||
},
|
||||
[
|
||||
addNode,
|
||||
node.id,
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.height,
|
||||
node.size.width,
|
||||
selectNode,
|
||||
]
|
||||
);
|
||||
|
||||
const handleLayerSplitComplete = useCallback(
|
||||
(
|
||||
layers: Array<{
|
||||
@ -9724,16 +9952,23 @@ export function ImageNode({
|
||||
{imageTextEditItem.label}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{imageTemplateReplaceEnabled ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("template-replace")}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={FileImage}
|
||||
item={imageTemplateReplaceItem}
|
||||
/>
|
||||
<span>{imageTemplateReplaceItem.label}</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{imageLayoutReplaceEnabled ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAction("layout-replace")}
|
||||
>
|
||||
{isSubmittingLayoutReplace ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
/>
|
||||
)}
|
||||
<span>{imageLayoutReplaceItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{imageExtendEnabled ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||||
<CanvasToolbarIcon
|
||||
@ -9885,16 +10120,128 @@ export function ImageNode({
|
||||
prompt={regeneratePrompt}
|
||||
/>
|
||||
|
||||
<CanvasTemplateReplaceDialog
|
||||
availableSources={availableTemplateReplaceSources}
|
||||
canvasId={canvasId}
|
||||
initialSources={initialTemplateReplaceSources}
|
||||
onAddToCanvas={handleTemplateReplaceAddToCanvas}
|
||||
onOpenChange={setTemplateReplaceDialogOpen}
|
||||
open={templateReplaceDialogOpen}
|
||||
projectId={projectId}
|
||||
triggerNodeId={node.id}
|
||||
/>
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!isSubmittingLayoutReplace) {
|
||||
setLayoutReplaceDialogOpen(open);
|
||||
}
|
||||
}}
|
||||
open={layoutReplaceDialogOpen}
|
||||
>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[760px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-[760px]">
|
||||
<DialogHeader className="shrink-0 border-b border-slate-200 px-4 py-3 pr-12 text-left sm:px-6 sm:py-4 dark:border-white/10">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
排版替换
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
选择版式参考图,保留参考图文字图标排版并仅替换产品主体
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label>当前图片</Label>
|
||||
<div className="flex h-[clamp(160px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-zinc-900">
|
||||
{data.src ? (
|
||||
<img
|
||||
alt={displayTitle}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
draggable={false}
|
||||
src={buildImageProxyUrl(data.src, { width: 560 })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label htmlFor={`layout-replace-reference-url-${node.id}`}>
|
||||
参考图
|
||||
</Label>
|
||||
<Input
|
||||
id={`layout-replace-reference-url-${node.id}`}
|
||||
onChange={(event) =>
|
||||
setLayoutReplaceReferenceUrl(event.currentTarget.value)
|
||||
}
|
||||
placeholder="https://..."
|
||||
value={layoutReplaceReferenceUrl}
|
||||
/>
|
||||
<Input
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||||
disabled={isUploadingLayoutReplaceReference}
|
||||
onChange={(event) => {
|
||||
void handleLayoutReplaceReferenceUpload(event);
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
<div className="flex h-[clamp(160px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-dashed border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-zinc-900">
|
||||
{isUploadingLayoutReplaceReference ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : isHttpImageUrl(layoutReplaceReferenceUrl.trim()) ? (
|
||||
<img
|
||||
alt="排版参考图"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
draggable={false}
|
||||
src={buildImageProxyUrl(layoutReplaceReferenceUrl.trim(), {
|
||||
width: 560,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<LayoutTemplate className="h-6 w-6 text-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor={`layout-replace-prompt-${node.id}`}>补充要求</Label>
|
||||
<Textarea
|
||||
className="min-h-[80px] max-h-[180px]"
|
||||
id={`layout-replace-prompt-${node.id}`}
|
||||
onChange={(event) =>
|
||||
setLayoutReplacePrompt(event.currentTarget.value)
|
||||
}
|
||||
placeholder="可选,只写产品替换细节;不会改写参考图文字、图标和排版"
|
||||
value={layoutReplacePrompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 gap-2 border-t border-slate-200 bg-background px-4 py-3 sm:px-6 dark:border-white/10">
|
||||
<Button
|
||||
disabled={isSubmittingLayoutReplace}
|
||||
onClick={() => setLayoutReplaceDialogOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
isProcessing ||
|
||||
isSubmittingLayoutReplace ||
|
||||
isUploadingLayoutReplaceReference ||
|
||||
!isHttpImageUrl(layoutReplaceReferenceUrl.trim())
|
||||
}
|
||||
onClick={() => void handleSubmitLayoutReplace()}
|
||||
type="button"
|
||||
>
|
||||
{isSubmittingLayoutReplace ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
开始生成
|
||||
<ImageActionCreditBadge
|
||||
className="ml-1 bg-white/18 text-white"
|
||||
compact
|
||||
label={localEditCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ThreeDGenerationDialog
|
||||
costLabel={
|
||||
@ -10055,6 +10402,7 @@ export function ImageNode({
|
||||
!isEditingTitle &&
|
||||
!sceneFusionDialogOpen &&
|
||||
!promptDialogOpen &&
|
||||
!layoutReplaceDialogOpen &&
|
||||
!threedGenerationDialogMode &&
|
||||
!notePanelOpen &&
|
||||
!boxCutoutMode &&
|
||||
@ -10473,21 +10821,29 @@ export function ImageNode({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isSelectedImageActionInline("templateReplace") ? (
|
||||
{isSelectedImageActionInline("layoutReplace") ? (
|
||||
<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("template-replace")}
|
||||
title={imageTemplateReplaceItem.label}
|
||||
variant="ghost"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={FileImage}
|
||||
item={imageTemplateReplaceItem}
|
||||
/>
|
||||
{imageTemplateReplaceItem.label}
|
||||
</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 || isSubmittingLayoutReplace}
|
||||
onClick={() => handleAction("layout-replace")}
|
||||
title={imageLayoutReplaceItem.label}
|
||||
variant="ghost"
|
||||
>
|
||||
{isSubmittingLayoutReplace ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
/>
|
||||
)}
|
||||
{imageLayoutReplaceItem.label}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={localEditCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isSelectedImageActionInline("removeWatermark") ? (
|
||||
@ -10764,16 +11120,23 @@ export function ImageNode({
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("templateReplace") ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("template-replace")}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={FileImage}
|
||||
item={imageTemplateReplaceItem}
|
||||
/>
|
||||
<span>{imageTemplateReplaceItem.label}</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("layoutReplace") ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAction("layout-replace")}
|
||||
>
|
||||
{isSubmittingLayoutReplace ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
/>
|
||||
)}
|
||||
<span>{imageLayoutReplaceItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("removeWatermark") ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||||
<CanvasToolbarIcon
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export const CANVAS_TOOLBAR_CONFIG_SETTING_KEY = "canvas_toolbar_config";
|
||||
export const DEFAULT_SELECTED_IMAGE_INLINE_ACTION_COUNT = 7;
|
||||
export const DEFAULT_SELECTED_IMAGE_INLINE_ACTION_COUNT = 8;
|
||||
export const MIN_SELECTED_IMAGE_INLINE_ACTION_COUNT = 3;
|
||||
export const MAX_SELECTED_IMAGE_INLINE_ACTION_COUNT = 12;
|
||||
|
||||
@ -221,10 +221,10 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
||||
description: "框选多个彩色区域并按区域提示词局部改图",
|
||||
},
|
||||
{
|
||||
key: "imageTemplateReplace",
|
||||
key: "imageLayoutReplace",
|
||||
group: "节点工具栏",
|
||||
label: "模版替换",
|
||||
description: "用当前图片和最多四张补充图片批量替换目标模版中的主体",
|
||||
label: "排版替换",
|
||||
description: "参考详情图的文字排版、视觉层级和尺寸比例生成替换结果",
|
||||
},
|
||||
{
|
||||
key: "imageRemoveWatermark",
|
||||
@ -785,16 +785,11 @@ export function normalizeCanvasToolbarConfig(
|
||||
const items = {} as Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
|
||||
|
||||
DEFAULT_CANVAS_TOOLBAR_ITEMS.forEach((defaultItem) => {
|
||||
const legacyRawItem =
|
||||
defaultItem.key === "imageTemplateReplace"
|
||||
? rawItems.imagePsdTemplateReplace
|
||||
: undefined;
|
||||
const rawItemValue = rawItems[defaultItem.key] ?? legacyRawItem;
|
||||
const rawItem =
|
||||
rawItemValue &&
|
||||
typeof rawItemValue === "object" &&
|
||||
!Array.isArray(rawItemValue)
|
||||
? (rawItemValue as Record<string, unknown>)
|
||||
rawItems[defaultItem.key] &&
|
||||
typeof rawItems[defaultItem.key] === "object" &&
|
||||
!Array.isArray(rawItems[defaultItem.key])
|
||||
? (rawItems[defaultItem.key] as Record<string, unknown>)
|
||||
: {};
|
||||
const label = normalizeString(rawItem.label, 40) || defaultItem.label;
|
||||
const iconUrl = normalizeString(rawItem.iconUrl, 2000);
|
||||
|
||||
@ -208,15 +208,15 @@ export const CANVAS_CAPABILITY_REGISTRY = [
|
||||
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
|
||||
}),
|
||||
defineCapability({
|
||||
id: "image.template_replace",
|
||||
title: "模版替换",
|
||||
description: "使用选中图片与目标模版图执行批量主体替换。",
|
||||
clientAction: "template-replace",
|
||||
toolbarActionKey: "templateReplace",
|
||||
toolbarSettingKey: "imageTemplateReplace",
|
||||
id: "image.layout_replace",
|
||||
title: "排版替换",
|
||||
description: "参考详情图的排版与尺寸比例生成新的 AI 图片结果。",
|
||||
clientAction: "layout-replace",
|
||||
toolbarActionKey: "layoutReplace",
|
||||
toolbarSettingKey: "imageLayoutReplace",
|
||||
executionMode: "interactive",
|
||||
risk: "write",
|
||||
modelPolicy: "none",
|
||||
risk: "external",
|
||||
modelPolicy: "feature-setting",
|
||||
confirmation: "interactive",
|
||||
inputSchema: emptyArgsSchema,
|
||||
agent: { exposed: false },
|
||||
|
||||
@ -9,7 +9,7 @@ export const CANVAS_CAPABILITY_IDS = [
|
||||
"image.text_edit",
|
||||
"image.box_cutout",
|
||||
"image.local_edit",
|
||||
"image.template_replace",
|
||||
"image.layout_replace",
|
||||
"image.scale_adjust",
|
||||
"image.remove_watermark",
|
||||
"image.resize",
|
||||
@ -39,7 +39,7 @@ export type CanvasCapabilityClientAction =
|
||||
| "text-edit"
|
||||
| "box-cutout"
|
||||
| "local-edit"
|
||||
| "template-replace"
|
||||
| "layout-replace"
|
||||
| "scale-adjust"
|
||||
| "watermark"
|
||||
| "resize"
|
||||
@ -65,7 +65,7 @@ export type SelectedImageToolbarActionKey =
|
||||
| "textEdit"
|
||||
| "boxCutout"
|
||||
| "localEdit"
|
||||
| "templateReplace"
|
||||
| "layoutReplace"
|
||||
| "removeWatermark"
|
||||
| "resize"
|
||||
| "multiAngle"
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
export const MAX_CANVAS_TEMPLATE_REPLACE_SOURCES = 5;
|
||||
|
||||
export type CanvasTemplateReplaceSource = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
origin: "canvas" | "upload";
|
||||
sourceNodeId?: string;
|
||||
};
|
||||
|
||||
function normalizeSource(
|
||||
source: CanvasTemplateReplaceSource | null | undefined
|
||||
): CanvasTemplateReplaceSource | null {
|
||||
if (!source) return null;
|
||||
|
||||
const imageUrl = String(source.imageUrl || "").trim();
|
||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||
|
||||
return {
|
||||
id: String(source.id || imageUrl).trim() || imageUrl,
|
||||
imageUrl,
|
||||
title: String(source.title || "替换素材").trim() || "替换素材",
|
||||
origin: source.origin === "upload" ? "upload" : "canvas",
|
||||
sourceNodeId: String(source.sourceNodeId || "").trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCanvasTemplateReplaceSources(
|
||||
sources: Array<CanvasTemplateReplaceSource | null | undefined>
|
||||
): CanvasTemplateReplaceSource[] {
|
||||
const seenUrls = new Set<string>();
|
||||
const normalized: CanvasTemplateReplaceSource[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
const nextSource = normalizeSource(source);
|
||||
if (!nextSource || seenUrls.has(nextSource.imageUrl)) continue;
|
||||
seenUrls.add(nextSource.imageUrl);
|
||||
normalized.push(nextSource);
|
||||
if (normalized.length >= MAX_CANVAS_TEMPLATE_REPLACE_SOURCES) break;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createCanvasTemplateReplaceSlots(
|
||||
sources: Array<CanvasTemplateReplaceSource | null | undefined>
|
||||
): Array<CanvasTemplateReplaceSource | null> {
|
||||
const slots = normalizeCanvasTemplateReplaceSources(sources);
|
||||
return Array.from(
|
||||
{ length: MAX_CANVAS_TEMPLATE_REPLACE_SOURCES },
|
||||
(_, index) => slots[index] || null
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceCanvasTemplateSourceSlot(
|
||||
slots: Array<CanvasTemplateReplaceSource | null>,
|
||||
slotIndex: number,
|
||||
source: CanvasTemplateReplaceSource | null
|
||||
): Array<CanvasTemplateReplaceSource | null> {
|
||||
const nextSlots = createCanvasTemplateReplaceSlots([]);
|
||||
slots.slice(0, MAX_CANVAS_TEMPLATE_REPLACE_SOURCES).forEach((item, index) => {
|
||||
nextSlots[index] = normalizeSource(item);
|
||||
});
|
||||
|
||||
if (slotIndex < 0 || slotIndex >= MAX_CANVAS_TEMPLATE_REPLACE_SOURCES) {
|
||||
return nextSlots;
|
||||
}
|
||||
|
||||
const normalizedSource = normalizeSource(source);
|
||||
if (normalizedSource) {
|
||||
nextSlots.forEach((item, index) => {
|
||||
if (index !== slotIndex && item?.imageUrl === normalizedSource.imageUrl) {
|
||||
nextSlots[index] = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
nextSlots[slotIndex] = normalizedSource;
|
||||
return nextSlots;
|
||||
}
|
||||
@ -6,7 +6,6 @@ export default defineConfig({
|
||||
/routes\/canvas-chat-helpers\.test\.ts/,
|
||||
/routes\/agent-main-ai-template-entry\.test\.ts/,
|
||||
/routes\/canvas-agent-operations\.test\.ts/,
|
||||
/routes\/canvas-capability-runtime\.test\.ts/,
|
||||
/routes\/canvas-overlay-density\.test\.ts/,
|
||||
/routes\/image-message-contracts\.test\.ts/,
|
||||
/routes\/production-local-fallbacks\.test\.ts/,
|
||||
@ -14,7 +13,7 @@ export default defineConfig({
|
||||
/routes\/settings-visual-consistency\.test\.ts/,
|
||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||
/routes\/canvas-template-replace-entry\.test\.ts/,
|
||||
/routes\/canvas-layout-replace-entry\.test\.ts/,
|
||||
/routes\/model-classification\.test\.ts/,
|
||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||
|
||||
@ -38,13 +38,8 @@ expectIncludes(
|
||||
);
|
||||
expectIncludes(
|
||||
helpersConfig,
|
||||
"/routes\\/canvas-template-replace-entry\\.test\\.ts/",
|
||||
"canvas template replacement regression target"
|
||||
);
|
||||
expectIncludes(
|
||||
helpersConfig,
|
||||
"/routes\\/canvas-capability-runtime\\.test\\.ts/",
|
||||
"canvas capability runtime regression target"
|
||||
"/routes\\/canvas-layout-replace-entry\\.test\\.ts/",
|
||||
"canvas PSD template regression target"
|
||||
);
|
||||
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
|
||||
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");
|
||||
|
||||
@ -22,7 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
|
||||
"textEdit",
|
||||
"boxCutout",
|
||||
"localEdit",
|
||||
"templateReplace",
|
||||
"layoutReplace",
|
||||
"removeWatermark",
|
||||
"resize",
|
||||
"multiAngle",
|
||||
|
||||
@ -47,7 +47,6 @@ import {
|
||||
import {
|
||||
defaultCanvasToolbarConfig,
|
||||
getCanvasToolbarItem,
|
||||
normalizeCanvasToolbarConfig,
|
||||
} from "@/lib/canvas-toolbar-config";
|
||||
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
|
||||
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
|
||||
@ -4876,36 +4875,18 @@ test.describe("canvas chat pure helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the canvas template replacement action enabled by default", () => {
|
||||
test("keeps the AI layout replacement action enabled by default", () => {
|
||||
expect(
|
||||
getCanvasToolbarItem(
|
||||
defaultCanvasToolbarConfig,
|
||||
"imageTemplateReplace"
|
||||
"imageLayoutReplace"
|
||||
)
|
||||
).toMatchObject({
|
||||
label: "模版替换",
|
||||
label: "排版替换",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("migrates the legacy PSD toolbar setting into canvas template replacement", () => {
|
||||
const normalized = normalizeCanvasToolbarConfig({
|
||||
items: {
|
||||
imagePsdTemplateReplace: {
|
||||
enabled: false,
|
||||
label: "旧模版入口",
|
||||
iconUrl: "https://example.com/template-icon.png",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.items.imageTemplateReplace).toEqual({
|
||||
enabled: false,
|
||||
label: "旧模版入口",
|
||||
iconUrl: "https://example.com/template-icon.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("syncs image notes into structured canvas annotations", () => {
|
||||
const first = syncCanvasNodeNoteAnnotationMetadata({
|
||||
metadata: {
|
||||
|
||||
43
tests/routes/canvas-layout-replace-entry.test.ts
Normal file
43
tests/routes/canvas-layout-replace-entry.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function readSource(path: string) {
|
||||
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
}
|
||||
|
||||
test.describe("canvas AI layout replacement entry", () => {
|
||||
test("keeps the historical AI generation flow wired without routing to PSD", () => {
|
||||
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
|
||||
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
|
||||
const toolbarConfig = readSource("lib/canvas-toolbar-config.ts");
|
||||
const capabilityRegistry = readSource("lib/canvas/capabilities/registry.ts");
|
||||
const route = readSource("app/api/canvas/layout-replace/route.ts");
|
||||
|
||||
expect(toolbarConfig).toContain('key: "imageLayoutReplace"');
|
||||
expect(toolbarConfig).toContain('label: "排版替换"');
|
||||
expect(toolbarConfig).not.toContain('key: "imagePsdTemplateReplace"');
|
||||
|
||||
expect(imageNode).toContain(
|
||||
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imageLayoutReplace"'
|
||||
);
|
||||
expect(imageNode).toContain('isSelectedImageActionInline("layoutReplace")');
|
||||
expect(imageNode).toContain('isSelectedImageActionInMore("layoutReplace")');
|
||||
expect(imageNode).toContain('handleAction("layout-replace")');
|
||||
expect(imageNode).toContain("handleOpenLayoutReplace");
|
||||
expect(imageNode).toContain("handleSubmitLayoutReplace");
|
||||
expect(imageNode).toContain("imageGenerationService.layoutReplace({");
|
||||
expect(imageNode).toContain("pollLayoutReplaceTask(taskId)");
|
||||
expect(imageNode).toContain('editMode: "layout-replace"');
|
||||
expect(imageNode).toContain("max-h-[calc(100dvh-1rem)]");
|
||||
expect(imageNode).not.toContain("/psd-template?launch=");
|
||||
|
||||
expect(workspace).toContain(
|
||||
'requestImageNodeAction(selectedImageNodes[0].id, "layout-replace")'
|
||||
);
|
||||
expect(capabilityRegistry).toContain('id: "image.layout_replace"');
|
||||
expect(capabilityRegistry).toContain('clientAction: "layout-replace"');
|
||||
expect(route).toContain("createCanvasImageTask({");
|
||||
expect(route).toContain("await enqueueCanvasImageTask(task.id)");
|
||||
});
|
||||
});
|
||||
@ -1,73 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
createCanvasTemplateReplaceSlots,
|
||||
MAX_CANVAS_TEMPLATE_REPLACE_SOURCES,
|
||||
replaceCanvasTemplateSourceSlot,
|
||||
type CanvasTemplateReplaceSource,
|
||||
} from "@/lib/canvas/template-replace-batch";
|
||||
|
||||
function readSource(path: string) {
|
||||
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
}
|
||||
|
||||
function source(id: string): CanvasTemplateReplaceSource {
|
||||
return {
|
||||
id,
|
||||
imageUrl: `https://example.com/${id}.png`,
|
||||
title: id,
|
||||
origin: "canvas",
|
||||
sourceNodeId: id,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("canvas template replacement entry", () => {
|
||||
test("keeps the selected-image toolbar action wired to the in-canvas batch dialog", () => {
|
||||
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
|
||||
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
|
||||
const dialog = readSource("components/canvas/CanvasTemplateReplaceDialog.tsx");
|
||||
|
||||
expect(imageNode).toContain(
|
||||
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imageTemplateReplace"'
|
||||
);
|
||||
expect(imageNode).toContain('isSelectedImageActionInline("templateReplace")');
|
||||
expect(imageNode).toContain('isSelectedImageActionInMore("templateReplace")');
|
||||
expect(imageNode).toContain('handleAction("template-replace")');
|
||||
expect(imageNode).toContain("<CanvasTemplateReplaceDialog");
|
||||
expect(imageNode).not.toContain("/psd-template?launch=");
|
||||
expect(imageNode).not.toContain("/api/psd-templates?summary=1");
|
||||
expect(workspace).toContain(
|
||||
'requestImageNodeAction(selectedImageNodes[0].id, "template-replace")'
|
||||
);
|
||||
|
||||
expect(dialog).toContain("MAX_CANVAS_TEMPLATE_REPLACE_SOURCES");
|
||||
expect(dialog).toContain("imageGenerationService.layoutReplace");
|
||||
expect(dialog).toContain("for (const resultId of orderedIds)");
|
||||
expect(dialog).toContain('return "等待上一张"');
|
||||
expect(dialog).toContain("排队中(前方");
|
||||
expect(dialog).toContain("全部添加到画布");
|
||||
expect(dialog).toContain("canvas-template-replace:");
|
||||
expect(dialog).toContain("max-h-[calc(100dvh-1rem)]");
|
||||
expect(dialog).toContain("min-h-0 flex-1 overflow-y-auto");
|
||||
expect(dialog).toContain("shrink-0 gap-2 border-t");
|
||||
expect(dialog).not.toContain("PSD");
|
||||
});
|
||||
|
||||
test("keeps five fixed slots and deduplicates a source moved between slots", () => {
|
||||
const slots = createCanvasTemplateReplaceSlots([
|
||||
source("a"),
|
||||
source("b"),
|
||||
source("c"),
|
||||
source("d"),
|
||||
source("e"),
|
||||
source("f"),
|
||||
]);
|
||||
expect(slots).toHaveLength(MAX_CANVAS_TEMPLATE_REPLACE_SOURCES);
|
||||
expect(slots.map((item) => item?.id)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
|
||||
const moved = replaceCanvasTemplateSourceSlot(slots, 4, source("a"));
|
||||
expect(moved[0]).toBeNull();
|
||||
expect(moved[4]?.id).toBe("a");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user