feat(canvas): add direct canvas image picking
This commit is contained in:
parent
3010e51f72
commit
398a4bbefd
@ -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;
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}),
|
||||
|
||||
@ -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" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user