diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 8ebf45e4f..92c842131 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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; diff --git a/components/canvas/CanvasLayoutReplaceBatchDialog.tsx b/components/canvas/CanvasLayoutReplaceBatchDialog.tsx index ec957ef34..364593a35 100644 --- a/components/canvas/CanvasLayoutReplaceBatchDialog.tsx +++ b/components/canvas/CanvasLayoutReplaceBatchDialog.tsx @@ -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(null); const referenceUploadInputRef = useRef(null); const seenSucceededItemIdsRef = useRef(new Set()); - const [sources, setSources] = useState([]); + const [sources, setSources] = useState(() => + buildInitialSources(primaryNode, allNodes, selectedNodeIds) + ); const [referenceImageUrl, setReferenceImageUrl] = useState(""); const [supplementalPrompt, setSupplementalPrompt] = useState(""); const [batch, setBatch] = useState(null); const [selectedResultIds, setSelectedResultIds] = useState>(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) => { const files = Array.from(event.currentTarget.files || []).filter((file) => @@ -489,8 +574,14 @@ export function CanvasLayoutReplaceBatchDialog({ ); return ( - - + <> + { + if (!activeCanvasImageSelectionSession) onOpenChange(nextOpen); + }} + open={open && !activeCanvasImageSelectionSession} + > +
@@ -522,44 +613,19 @@ export function CanvasLayoutReplaceBatchDialog({ {!batch ? (
- - - - - -
- {readyCanvasSources.map((source) => { - const selected = sourceUrlSet.has(source.imageUrl); - return ( - - ); - })} -
-
-
+ )} - -
+
+
+ {activeCanvasImageSelectionSession && typeof document !== "undefined" + ? createPortal( +
+
+
+ + + +
+
选择画布图片
+
+ 已选 {activeCanvasImageSelectionSession.selectedNodeIds.length}/ + {activeCanvasImageSelectionSession.maxCount} +
+
+
+ + +
+
, + document.body + ) + : null} + ); } diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 0d02b25e2..67e9d87b9 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -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({ )} + {isCanvasImageSelectionCandidate ? ( +
+ + {isCanvasImageSelectionSelected ? ( + + ) : null} + +
+ ) : null} ); }; @@ -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 && (
)} - {selectedImageNodes.length > 0 && ( + {!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
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()( imageNodeActionRequest: null, imageNodeActionQueue: [], handledCanvasCapabilityRequestIds: [], + canvasImageSelectionSession: null, lastAddedNodeId: null, agentOperationRefreshUntil: 0, @@ -274,6 +290,7 @@ export const useCanvasStore = create()( 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()( // 批量操作 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()( }), clearCanvas: () => set((state) => { + state.canvasImageSelectionSession = null; state.nodes = []; state.edges = []; state.selectedNodeIds = []; @@ -843,6 +862,35 @@ export const useCanvasStore = create()( 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; }), diff --git a/tests/routes/canvas-layout-replace-entry.test.ts b/tests/routes/canvas-layout-replace-entry.test.ts index 8699a3bd1..6def2a76f 100644 --- a/tests/routes/canvas-layout-replace-entry.test.ts +++ b/tests/routes/canvas-layout-replace-entry.test.ts @@ -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" },