feat(canvas): add direct canvas image picking

This commit is contained in:
134 2026-08-14 11:47:42 +08:00
parent 3010e51f72
commit 398a4bbefd
5 changed files with 330 additions and 73 deletions

View File

@ -9374,6 +9374,7 @@ export default function CanvasEditorLegacyPage({
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (useCanvasStore.getState().canvasImageSelectionSession) return;
// 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素)
if (isCanvasTextEntryTarget(e.target)) {
return;

View File

@ -1,7 +1,6 @@
"use client";
import {
Check,
CircleAlert,
ImagePlus,
Images,
@ -14,6 +13,7 @@ import {
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@ -26,7 +26,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { buildImageProxyUrl } from "@/lib/image-delivery";
@ -153,15 +152,25 @@ export function CanvasLayoutReplaceBatchDialog({
const projectId = useCanvasStore((state) => state.projectId);
const addNode = useCanvasStore((state) => state.addNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const canvasImageSelectionSession = useCanvasStore(
(state) => state.canvasImageSelectionSession
);
const startCanvasImageSelection = useCanvasStore(
(state) => state.startCanvasImageSelection
);
const endCanvasImageSelection = useCanvasStore(
(state) => state.endCanvasImageSelection
);
const sourceUploadInputRef = useRef<HTMLInputElement>(null);
const referenceUploadInputRef = useRef<HTMLInputElement>(null);
const seenSucceededItemIdsRef = useRef(new Set<string>());
const [sources, setSources] = useState<LayoutReplaceSource[]>([]);
const [sources, setSources] = useState<LayoutReplaceSource[]>(() =>
buildInitialSources(primaryNode, allNodes, selectedNodeIds)
);
const [referenceImageUrl, setReferenceImageUrl] = useState("");
const [supplementalPrompt, setSupplementalPrompt] = useState("");
const [batch, setBatch] = useState<CanvasLayoutReplaceBatchSnapshot | null>(null);
const [selectedResultIds, setSelectedResultIds] = useState<Set<string>>(new Set());
const [isCanvasPickerOpen, setIsCanvasPickerOpen] = useState(false);
const [isLoadingBatch, setIsLoadingBatch] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isUploadingSources, setIsUploadingSources] = useState(false);
@ -173,10 +182,10 @@ export function CanvasLayoutReplaceBatchDialog({
() => allNodes.map(getReadyCanvasImage).filter(Boolean) as LayoutReplaceSource[],
[allNodes]
);
const sourceUrlSet = useMemo(
() => new Set(sources.map((source) => source.imageUrl)),
[sources]
);
const activeCanvasImageSelectionSession =
canvasImageSelectionSession?.ownerNodeId === primaryNode.id
? canvasImageSelectionSession
: null;
const successfulItems = useMemo(
() => batch?.items.filter((item) => item.status === "succeeded" && item.resultImageUrl) || [],
[batch]
@ -224,7 +233,14 @@ export function CanvasLayoutReplaceBatchDialog({
useEffect(() => {
if (!open) return;
const initialSources = buildInitialSources(primaryNode, allNodes, selectedNodeIds);
const currentCanvas = useCanvasStore.getState();
const currentPrimaryNode =
currentCanvas.nodes.find((node) => node.id === primaryNode.id) || primaryNode;
const initialSources = buildInitialSources(
currentPrimaryNode,
currentCanvas.nodes,
currentCanvas.selectedNodeIds
);
setSources(initialSources);
setSelectedResultIds(new Set());
seenSucceededItemIdsRef.current = new Set();
@ -251,7 +267,25 @@ export function CanvasLayoutReplaceBatchDialog({
return () => {
cancelled = true;
};
}, [allNodes, applyBatchSnapshot, open, primaryNode, projectId, selectedNodeIds]);
}, [applyBatchSnapshot, open, primaryNode.id, projectId]);
useEffect(
() => () => {
endCanvasImageSelection(primaryNode.id);
},
[endCanvasImageSelection, primaryNode.id]
);
useEffect(() => {
if (!activeCanvasImageSelectionSession) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
endCanvasImageSelection(primaryNode.id);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [activeCanvasImageSelectionSession, endCanvasImageSelection, primaryNode.id]);
useEffect(() => {
if (!open || !batch?.id || !batchIsActive) return;
@ -268,21 +302,72 @@ export function CanvasLayoutReplaceBatchDialog({
setSources(buildInitialSources(primaryNode, allNodes, selectedNodeIds));
}, [allNodes, primaryNode, selectedNodeIds]);
const addCanvasSource = useCallback((source: LayoutReplaceSource) => {
setSources((current) => {
if (current.some((item) => item.imageUrl === source.imageUrl)) return current;
if (current.length >= MAX_BATCH_SOURCES) {
toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`);
return current;
}
return [...current, source];
});
}, []);
const removeSource = useCallback((sourceId: string) => {
setSources((current) => current.filter((source) => source.id !== sourceId));
}, []);
const startCanvasSourceSelection = useCallback(() => {
if (canvasImageSelectionSession) return;
const remainingCount = MAX_BATCH_SOURCES - sources.length;
if (remainingCount <= 0) {
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)
);
if (availableSources.length === 0) {
toast.info("画布中暂无其他可添加的已完成图片");
return;
}
startCanvasImageSelection({
ownerNodeId: primaryNode.id,
allowedNodeIds: availableSources.flatMap((source) =>
source.nodeId ? [source.nodeId] : []
),
maxCount: remainingCount,
});
}, [
canvasImageSelectionSession,
primaryNode.id,
readyCanvasSources,
sources,
startCanvasImageSelection,
]);
const cancelCanvasSourceSelection = useCallback(() => {
endCanvasImageSelection(primaryNode.id);
}, [endCanvasImageSelection, primaryNode.id]);
const confirmCanvasSourceSelection = useCallback(() => {
if (!activeCanvasImageSelectionSession) return;
const selectedIds = new Set(activeCanvasImageSelectionSession.selectedNodeIds);
const selectedSources = readyCanvasSources.filter(
(source) => source.nodeId && selectedIds.has(source.nodeId)
);
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;
});
endCanvasImageSelection(primaryNode.id);
toast.success(`已从画布添加 ${selectedSources.length} 张图片`);
}, [
activeCanvasImageSelectionSession,
endCanvasImageSelection,
primaryNode.id,
readyCanvasSources,
]);
const handleSourceUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.currentTarget.files || []).filter((file) =>
@ -489,8 +574,14 @@ export function CanvasLayoutReplaceBatchDialog({
);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
<>
<Dialog
onOpenChange={(nextOpen) => {
if (!activeCanvasImageSelectionSession) onOpenChange(nextOpen);
}}
open={open && !activeCanvasImageSelectionSession}
>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1180px] 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 flex-wrap items-center justify-between gap-3">
<DialogTitle className="flex items-center gap-2 text-base">
@ -522,44 +613,19 @@ export function CanvasLayoutReplaceBatchDialog({
<Label>待处理图片 {sources.length}/{MAX_BATCH_SOURCES}</Label>
{!batch ? (
<div className="flex items-center gap-2">
<Popover onOpenChange={setIsCanvasPickerOpen} open={isCanvasPickerOpen}>
<PopoverTrigger asChild>
<Button disabled={sources.length >= MAX_BATCH_SOURCES} size="sm" type="button" variant="outline">
<Images className="mr-1.5 h-4 w-4" />
从画布添加
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(520px,calc(100vw-2rem))] p-3">
<div className="grid max-h-[320px] grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-4">
{readyCanvasSources.map((source) => {
const selected = sourceUrlSet.has(source.imageUrl);
return (
<button
className={cn(
"relative aspect-square overflow-hidden rounded-md border bg-muted/30",
selected ? "border-primary ring-1 ring-primary" : "hover:border-foreground/30"
)}
disabled={selected || sources.length >= MAX_BATCH_SOURCES}
key={source.id}
onClick={() => addCanvasSource(source)}
type="button"
>
<img
alt={source.title}
className="h-full w-full object-cover"
src={buildImageProxyUrl(source.imageUrl, { width: 180 })}
/>
{selected ? (
<span className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</span>
) : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<Button
disabled={
sources.length >= MAX_BATCH_SOURCES ||
Boolean(canvasImageSelectionSession)
}
onClick={startCanvasSourceSelection}
size="sm"
type="button"
variant="outline"
>
<Images className="mr-1.5 h-4 w-4" />
从画布中添加
</Button>
<input
accept="image/*"
className="hidden"
@ -850,7 +916,51 @@ export function CanvasLayoutReplaceBatchDialog({
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
{activeCanvasImageSelectionSession && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed inset-x-0 bottom-4 z-[10020] flex justify-center px-3 sm:bottom-6"
data-canvas-layout-replace-picker="true"
>
<div
className="pointer-events-auto flex w-full max-w-[720px] flex-wrap items-center gap-2 rounded-lg border border-border bg-background px-3 py-2.5 shadow-2xl sm:flex-nowrap sm:px-4"
data-canvas-overlay="true"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-[#168cff] text-white">
<Images className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium">选择画布图片</div>
<div className="truncate text-xs text-muted-foreground">
已选 {activeCanvasImageSelectionSession.selectedNodeIds.length}/
{activeCanvasImageSelectionSession.maxCount}
</div>
</div>
</div>
<Button
onClick={cancelCanvasSourceSelection}
size="sm"
type="button"
variant="outline"
>
取消
</Button>
<Button
disabled={activeCanvasImageSelectionSession.selectedNodeIds.length === 0}
onClick={confirmCanvasSourceSelection}
size="sm"
type="button"
>
确认添加到图片卡槽
</Button>
</div>
</div>,
document.body
)
: null}
</>
);
}

View File

@ -417,6 +417,9 @@ export function CanvasWorkspace({
const nodes = useCanvasStore(canvasSelectors.nodes);
const edges = useCanvasStore(canvasSelectors.edges);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const canvasImageSelectionSession = useCanvasStore(
(state) => state.canvasImageSelectionSession
);
const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool);
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
@ -728,6 +731,9 @@ export function CanvasWorkspace({
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const toggleCanvasImageSelection = useCanvasStore(
(state) => state.toggleCanvasImageSelection
);
const clearSelection = useCanvasStore((state) => state.clearSelection);
const addNode = useCanvasStore((state) => state.addNode);
const addEdge = useCanvasStore((state) => state.addEdge);
@ -1245,6 +1251,7 @@ export function CanvasWorkspace({
// 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return;
setAgentSlotContextMenu(null);
if (!isEventFromCanvasContainer(e.target)) return;
@ -1263,10 +1270,11 @@ export function CanvasWorkspace({
fitAllImageNodesIntoViewport();
}
}
}, [clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
}, [canvasImageSelectionSession, clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
// 处理画布平移开始
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
if (connectionDraft) {
@ -1312,7 +1320,7 @@ export function CanvasWorkspace({
setMarqueeStart(start);
setMarqueeEnd(start);
}
}, [cancelPanPreview, clearBrowserTextSelection, clearLatestGeneratedImageHighlights, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]);
}, [cancelPanPreview, canvasImageSelectionSession, clearBrowserTextSelection, clearLatestGeneratedImageHighlights, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]);
// 处理画布平移
const handleMouseMove = useCallback((e: React.MouseEvent) => {
@ -1831,7 +1839,14 @@ export function CanvasWorkspace({
// 渲染节点
const renderNode = (node: CanvasNode) => {
const isSelected = selectedNodeIds.includes(node.id);
const isCanvasImageSelectionCandidate = Boolean(
canvasImageSelectionSession?.allowedNodeIds.includes(node.id)
);
const isCanvasImageSelectionSelected = Boolean(
canvasImageSelectionSession?.selectedNodeIds.includes(node.id)
);
const isSelected =
!canvasImageSelectionSession && selectedNodeIds.includes(node.id);
const showWorkflowHandles = isWorkflowCanvas && (tool === "draw" || isSelected);
const isImageNode = node.type === "image";
const enableImageResizeHandles =
@ -2028,13 +2043,17 @@ export function CanvasWorkspace({
pushHistory();
}}
bounds="parent"
enableResizing={enableImageResizeHandles}
enableResizing={canvasImageSelectionSession ? false : enableImageResizeHandles}
resizeHandleStyles={imageResizeHandleStyles}
disableDragging={tool !== 'select'}
disableDragging={tool !== 'select' || Boolean(canvasImageSelectionSession)}
cancel={CANVAS_DRAG_CANCEL_SELECTOR}
className={cn(
"absolute",
tool === "select" && "cursor-pointer"
canvasImageSelectionSession
? isCanvasImageSelectionCandidate
? "cursor-crosshair"
: "cursor-default"
: tool === "select" && "cursor-pointer"
)}
data-canvas-node-id={node.id}
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
@ -2045,6 +2064,12 @@ export function CanvasWorkspace({
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
if (canvasImageSelectionSession) {
if (isCanvasImageSelectionCandidate) {
toggleCanvasImageSelection(node.id);
}
return;
}
setAgentSlotContextMenu(null);
clearLatestGeneratedImageHighlights();
setSelectedEdgeId(null);
@ -2179,6 +2204,29 @@ export function CanvasWorkspace({
</div>
)}
</motion.div>
{isCanvasImageSelectionCandidate ? (
<div
className={cn(
"pointer-events-none absolute inset-0 z-[70] border-2",
isCanvasImageSelectionSelected
? "border-[#168cff] bg-[#168cff]/10"
: "border-dashed border-[#168cff]/70"
)}
>
<span
className={cn(
"absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full border-2 shadow-sm",
isCanvasImageSelectionSelected
? "border-[#168cff] bg-[#168cff] text-white"
: "border-white bg-white/95 text-[#168cff]"
)}
>
{isCanvasImageSelectionSelected ? (
<Check className="h-4 w-4" strokeWidth={3} />
) : null}
</span>
</div>
) : null}
</Rnd>
);
};
@ -2203,7 +2251,8 @@ export function CanvasWorkspace({
isWorkflowCanvas ? "bg-[#050607] text-white" : "bg-background",
tool === 'pan' && "cursor-grab",
tool === 'draw' && "cursor-crosshair",
isPanning && "cursor-grabbing"
isPanning && "cursor-grabbing",
canvasImageSelectionSession && "cursor-crosshair"
)}
style={
isWorkflowCanvas
@ -2501,7 +2550,9 @@ export function CanvasWorkspace({
)}
{/* 选中图片快捷操作 */}
{selectedPendingImageUploadCount > 0 && selectedImageNodes.length === 0 && (
{!canvasImageSelectionSession &&
selectedPendingImageUploadCount > 0 &&
selectedImageNodes.length === 0 && (
<div
className="absolute bottom-16 left-4 z-30 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 shadow-sm"
data-canvas-overlay
@ -2509,7 +2560,7 @@ export function CanvasWorkspace({
图片仍在本地,上传完成后可进行 AI 处理
</div>
)}
{selectedImageNodes.length > 0 && (
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
<div
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
data-canvas-overlay

View File

@ -99,6 +99,13 @@ export interface ImageNodeActionOptions {
confirmed?: boolean;
}
export interface CanvasImageSelectionSession {
ownerNodeId: string;
allowedNodeIds: string[];
selectedNodeIds: string[];
maxCount: number;
}
type CanvasViewportSource = 'user' | 'agent-transient';
// Store 状态接口
@ -140,6 +147,7 @@ interface CanvasStore {
imageNodeActionRequest: ImageNodeActionRequest | null;
imageNodeActionQueue: ImageNodeActionRequest[];
handledCanvasCapabilityRequestIds: string[];
canvasImageSelectionSession: CanvasImageSelectionSession | null;
// 🆕 新增节点(用于自动聚焦)
lastAddedNodeId: string | null;
@ -222,6 +230,13 @@ interface CanvasStore {
options?: ImageNodeActionOptions
) => void;
clearImageNodeActionRequest: () => void;
startCanvasImageSelection: (input: {
ownerNodeId: string;
allowedNodeIds: string[];
maxCount: number;
}) => void;
toggleCanvasImageSelection: (nodeId: string) => void;
endCanvasImageSelection: (ownerNodeId: string) => void;
// 🆕 自动聚焦控制
clearLastAddedNodeId: () => void;
@ -265,6 +280,7 @@ export const useCanvasStore = create<CanvasStore>()(
imageNodeActionRequest: null,
imageNodeActionQueue: [],
handledCanvasCapabilityRequestIds: [],
canvasImageSelectionSession: null,
lastAddedNodeId: null,
agentOperationRefreshUntil: 0,
@ -274,6 +290,7 @@ export const useCanvasStore = create<CanvasStore>()(
state.imageNodeActionRequest = null;
state.imageNodeActionQueue = [];
state.handledCanvasCapabilityRequestIds = [];
state.canvasImageSelectionSession = null;
}
state.projectId = id;
if (title) state.projectTitle = title;
@ -639,6 +656,7 @@ export const useCanvasStore = create<CanvasStore>()(
// 批量操作
loadCanvas: (canvasState) => set((state) => {
state.canvasImageSelectionSession = null;
if (canvasState.nodes) state.nodes = canvasState.nodes;
if (canvasState.edges) state.edges = canvasState.edges;
rebuildNodeConnections(state.nodes, state.edges);
@ -656,6 +674,7 @@ export const useCanvasStore = create<CanvasStore>()(
}),
clearCanvas: () => set((state) => {
state.canvasImageSelectionSession = null;
state.nodes = [];
state.edges = [];
state.selectedNodeIds = [];
@ -843,6 +862,35 @@ export const useCanvasStore = create<CanvasStore>()(
state.imageNodeActionRequest = state.imageNodeActionQueue.shift() || null;
}),
startCanvasImageSelection: ({ ownerNodeId, allowedNodeIds, maxCount }) =>
set((state) => {
state.canvasImageSelectionSession = {
ownerNodeId,
allowedNodeIds: Array.from(new Set(allowedNodeIds)),
selectedNodeIds: [],
maxCount: Math.max(1, maxCount),
};
}),
toggleCanvasImageSelection: (nodeId) => set((state) => {
const session = state.canvasImageSelectionSession;
if (!session || !session.allowedNodeIds.includes(nodeId)) return;
const selectedIndex = session.selectedNodeIds.indexOf(nodeId);
if (selectedIndex >= 0) {
session.selectedNodeIds.splice(selectedIndex, 1);
return;
}
if (session.selectedNodeIds.length < session.maxCount) {
session.selectedNodeIds.push(nodeId);
}
}),
endCanvasImageSelection: (ownerNodeId) => set((state) => {
if (state.canvasImageSelectionSession?.ownerNodeId === ownerNodeId) {
state.canvasImageSelectionSession = null;
}
}),
clearLastAddedNodeId: () => set((state) => {
state.lastAddedNodeId = null;
}),

View File

@ -2,6 +2,7 @@ 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 { useCanvasStore } from "@/store/canvas";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
@ -48,7 +49,9 @@ test.describe("canvas AI layout replacement entry", () => {
);
expect(dialog).toContain("const MAX_BATCH_SOURCES = 5");
expect(dialog).toContain("从画布添加");
expect(dialog).toContain("从画布中添加");
expect(dialog).toContain("open={open && !activeCanvasImageSelectionSession}");
expect(dialog).toContain("确认添加到图片卡槽");
expect(dialog).toContain("上传目标模版图");
expect(dialog).toContain("批量处理 {sources.length} 张");
expect(dialog).toContain("添加所选到画布");
@ -76,6 +79,50 @@ test.describe("canvas AI layout replacement entry", () => {
expect(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatchItem"');
});
test("selects eligible canvas images without exceeding the remaining slots", () => {
const store = useCanvasStore.getState();
store.endCanvasImageSelection("layout-owner");
store.startCanvasImageSelection({
ownerNodeId: "layout-owner",
allowedNodeIds: ["image-1", "image-2", "image-3"],
maxCount: 2,
});
store.toggleCanvasImageSelection("image-1");
store.toggleCanvasImageSelection("not-allowed");
store.toggleCanvasImageSelection("image-2");
store.toggleCanvasImageSelection("image-3");
expect(useCanvasStore.getState().canvasImageSelectionSession?.selectedNodeIds).toEqual([
"image-1",
"image-2",
]);
store.toggleCanvasImageSelection("image-1");
expect(useCanvasStore.getState().canvasImageSelectionSession?.selectedNodeIds).toEqual([
"image-2",
]);
store.endCanvasImageSelection("another-owner");
expect(useCanvasStore.getState().canvasImageSelectionSession).not.toBeNull();
store.endCanvasImageSelection("layout-owner");
expect(useCanvasStore.getState().canvasImageSelectionSession).toBeNull();
});
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");
expect(workspace).toContain("toggleCanvasImageSelection(node.id)");
expect(workspace).toContain(
"disableDragging={tool !== 'select' || Boolean(canvasImageSelectionSession)}"
);
expect(workspace).toContain(
"!canvasImageSelectionSession && selectedImageNodes.length > 0"
);
expect(editor).toContain(
"if (useCanvasStore.getState().canvasImageSelectionSession) return;"
);
});
test("advances one queued item and derives terminal batch states", () => {
const active = deriveLayoutReplaceBatchAdvance([
{ status: "succeeded", taskId: "task-1" },