fix(canvas): allow repeated layout source picking
This commit is contained in:
parent
398a4bbefd
commit
1fa126cecf
@ -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) {
|
||||
|
||||
74
lib/canvas/layout-replace-source-selection.ts
Normal file
74
lib/canvas/layout-replace-source-selection.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user