diff --git a/components/canvas/CanvasLayoutReplaceBatchDialog.tsx b/components/canvas/CanvasLayoutReplaceBatchDialog.tsx index 364593a35..bc1a82b82 100644 --- a/components/canvas/CanvasLayoutReplaceBatchDialog.tsx +++ b/components/canvas/CanvasLayoutReplaceBatchDialog.tsx @@ -30,25 +30,24 @@ import { Progress } from "@/components/ui/progress"; import { Textarea } from "@/components/ui/textarea"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; +import { + getAvailableCanvasLayoutReplaceSources, + getLayoutReplaceSourceIdentity, + getReadyCanvasLayoutReplaceSource, + type LayoutReplaceSource, + mergeUniqueLayoutReplaceSources, +} from "@/lib/canvas/layout-replace-source-selection"; import { imageGenerationService, type CanvasLayoutReplaceBatchItemSnapshot, type CanvasLayoutReplaceBatchSnapshot, } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; -import type { CanvasNode, ImageNodeData, Size } from "@/types/canvas"; +import type { CanvasNode, Size } from "@/types/canvas"; const MAX_BATCH_SOURCES = 5; const ACTIVE_BATCH_STATUSES = new Set(["pending", "running"]); -type LayoutReplaceSource = { - id: string; - imageUrl: string; - kind: "canvas" | "upload"; - nodeId?: string; - title: string; -}; - type CanvasLayoutReplaceBatchDialogProps = { costLabel?: string | null; onOpenChange: (open: boolean) => void; @@ -60,20 +59,6 @@ function isHttpImageUrl(value: unknown) { return /^https?:\/\//i.test(String(value || "").trim()); } -function getReadyCanvasImage(node: CanvasNode): LayoutReplaceSource | null { - if (node.type !== "image") return null; - const data = node.data as ImageNodeData; - const imageUrl = String(data.src || "").trim(); - if (data.status !== "completed" || !isHttpImageUrl(imageUrl)) return null; - return { - id: `canvas:${node.id}`, - imageUrl, - kind: "canvas", - nodeId: node.id, - title: String(data.title || "画布图片").trim() || "画布图片", - }; -} - function buildInitialSources( primaryNode: CanvasNode, nodes: CanvasNode[], @@ -84,9 +69,13 @@ function buildInitialSources( const sources: LayoutReplaceSource[] = []; const seen = new Set(); for (const id of orderedIds) { - const source = getReadyCanvasImage(selectedById.get(id) || primaryNode); - if (!source || seen.has(source.imageUrl)) continue; - seen.add(source.imageUrl); + const node = id === primaryNode.id ? primaryNode : selectedById.get(id); + if (!node) continue; + const source = getReadyCanvasLayoutReplaceSource(node); + if (!source) continue; + const identity = getLayoutReplaceSourceIdentity(source); + if (seen.has(identity)) continue; + seen.add(identity); sources.push(source); if (sources.length >= MAX_BATCH_SOURCES) break; } @@ -179,7 +168,10 @@ export function CanvasLayoutReplaceBatchDialog({ const [retryingItemId, setRetryingItemId] = useState(null); const readyCanvasSources = useMemo( - () => allNodes.map(getReadyCanvasImage).filter(Boolean) as LayoutReplaceSource[], + () => + allNodes + .map(getReadyCanvasLayoutReplaceSource) + .filter(Boolean) as LayoutReplaceSource[], [allNodes] ); const activeCanvasImageSelectionSession = @@ -313,12 +305,12 @@ export function CanvasLayoutReplaceBatchDialog({ toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`); return; } - const existingUrls = new Set(sources.map((source) => source.imageUrl)); - const availableSources = readyCanvasSources.filter( - (source) => source.nodeId && !existingUrls.has(source.imageUrl) + const availableSources = getAvailableCanvasLayoutReplaceSources( + readyCanvasSources, + sources ); if (availableSources.length === 0) { - toast.info("画布中暂无其他可添加的已完成图片"); + toast.info("画布中暂无其他可添加的图片"); return; } startCanvasImageSelection({ @@ -348,16 +340,11 @@ export function CanvasLayoutReplaceBatchDialog({ ); if (selectedSources.length === 0) return; setSources((current) => { - const next = [...current]; - selectedSources.forEach((source) => { - if ( - next.length < MAX_BATCH_SOURCES && - !next.some((item) => item.imageUrl === source.imageUrl) - ) { - next.push(source); - } - }); - return next; + return mergeUniqueLayoutReplaceSources( + current, + selectedSources, + MAX_BATCH_SOURCES + ); }); endCanvasImageSelection(primaryNode.id); toast.success(`已从画布添加 ${selectedSources.length} 张图片`); @@ -397,11 +384,11 @@ export function CanvasLayoutReplaceBatchDialog({ }); } setSources((current) => { - const next = [...current]; - for (const source of uploaded) { - if (!next.some((item) => item.imageUrl === source.imageUrl)) next.push(source); - } - return next.slice(0, MAX_BATCH_SOURCES); + return mergeUniqueLayoutReplaceSources( + current, + uploaded, + MAX_BATCH_SOURCES + ); }); toast.success(`已上传 ${uploaded.length} 张图片`); } catch (error) { diff --git a/lib/canvas/layout-replace-source-selection.ts b/lib/canvas/layout-replace-source-selection.ts new file mode 100644 index 000000000..592ca1258 --- /dev/null +++ b/lib/canvas/layout-replace-source-selection.ts @@ -0,0 +1,74 @@ +import { isCanvasImageReadyForAi } from "@/lib/canvas/local-upload-state"; +import type { CanvasNode } from "@/types/canvas"; + +export type LayoutReplaceSource = { + id: string; + imageUrl: string; + kind: "canvas" | "upload"; + nodeId?: string; + title: string; +}; + +function resolveCanvasImageUrl(node: CanvasNode) { + const data = (node.data || {}) as Record; + return String( + data.src || + data.previewImage || + data.imageUrl || + data.renderArtifacts?.screenshotUrl || + "" + ).trim(); +} + +export function getLayoutReplaceSourceIdentity(source: LayoutReplaceSource) { + if (source.kind === "canvas" && source.nodeId) { + return `canvas:${source.nodeId}`; + } + return `${source.kind}:${source.imageUrl}`; +} + +export function getReadyCanvasLayoutReplaceSource( + node: CanvasNode +): LayoutReplaceSource | null { + if (!isCanvasImageReadyForAi(node)) return null; + const data = (node.data || {}) as Record; + const imageUrl = resolveCanvasImageUrl(node); + return { + id: `canvas:${node.id}`, + imageUrl, + kind: "canvas", + nodeId: node.id, + title: String(data.title || "画布图片").trim() || "画布图片", + }; +} + +export function mergeUniqueLayoutReplaceSources( + current: LayoutReplaceSource[], + incoming: LayoutReplaceSource[], + maxCount: number +) { + const next = [...current]; + const identities = new Set(next.map(getLayoutReplaceSourceIdentity)); + for (const source of incoming) { + const identity = getLayoutReplaceSourceIdentity(source); + if (next.length >= maxCount) break; + if (identities.has(identity)) continue; + identities.add(identity); + next.push(source); + } + return next; +} + +export function getAvailableCanvasLayoutReplaceSources( + canvasSources: LayoutReplaceSource[], + currentSources: LayoutReplaceSource[] +) { + const existingIdentities = new Set( + currentSources.map(getLayoutReplaceSourceIdentity) + ); + return canvasSources.filter( + (source) => + Boolean(source.nodeId) && + !existingIdentities.has(getLayoutReplaceSourceIdentity(source)) + ); +} diff --git a/tests/routes/canvas-layout-replace-entry.test.ts b/tests/routes/canvas-layout-replace-entry.test.ts index 6def2a76f..cee4f1a68 100644 --- a/tests/routes/canvas-layout-replace-entry.test.ts +++ b/tests/routes/canvas-layout-replace-entry.test.ts @@ -2,7 +2,14 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { expect, test } from "@playwright/test"; import { deriveLayoutReplaceBatchAdvance } from "@/lib/canvas/layout-replace-batch-state"; +import { + getAvailableCanvasLayoutReplaceSources, + getReadyCanvasLayoutReplaceSource, + type LayoutReplaceSource, + mergeUniqueLayoutReplaceSources, +} from "@/lib/canvas/layout-replace-source-selection"; import { useCanvasStore } from "@/store/canvas"; +import type { CanvasNode } from "@/types/canvas"; function readSource(path: string) { return readFileSync(resolve(process.cwd(), path), "utf8"); @@ -107,6 +114,51 @@ test.describe("canvas AI layout replacement entry", () => { expect(useCanvasStore.getState().canvasImageSelectionSession).toBeNull(); }); + test("allows four consecutive canvas additions and distinguishes duplicate URLs by node", () => { + const nodes = Array.from({ length: 5 }, (_, index) => ({ + id: `image-${index + 1}`, + type: "image", + position: { x: index * 100, y: 0 }, + size: { width: 100, height: 100 }, + data: { + src: + index < 2 + ? "https://cdn.example.com/shared.png" + : `https://cdn.example.com/image-${index + 1}.png`, + title: `图片 ${index + 1}`, + // Imported and restored images can be AI-ready without this legacy status. + status: index === 0 ? "completed" : undefined, + }, + })) as CanvasNode[]; + const canvasSources = nodes + .map(getReadyCanvasLayoutReplaceSource) + .filter((source): source is LayoutReplaceSource => Boolean(source)); + + expect(canvasSources).toHaveLength(5); + let selected = [canvasSources[0]!]; + for (let round = 1; round < 5; round += 1) { + const available = getAvailableCanvasLayoutReplaceSources( + canvasSources, + selected + ); + expect(available).toHaveLength(5 - round); + selected = mergeUniqueLayoutReplaceSources( + selected, + [available[0]!], + 5 + ); + expect(selected).toHaveLength(round + 1); + } + + expect(selected.map((source) => source.nodeId)).toEqual([ + "image-1", + "image-2", + "image-3", + "image-4", + "image-5", + ]); + }); + test("keeps canvas picking isolated from normal drag and selection toolbars", () => { const workspace = readSource("components/canvas/CanvasWorkspace.tsx"); const editor = readSource("app/canvas/[id]/CanvasEditorLegacyPageClient.tsx");