fix(canvas): allow repeated layout source picking

This commit is contained in:
134 2026-08-14 12:10:23 +08:00
parent 398a4bbefd
commit 1fa126cecf
3 changed files with 159 additions and 46 deletions

View File

@ -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<string>();
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<string | null>(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) {

View File

@ -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<string, any>;
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<string, any>;
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))
);
}

View File

@ -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");