) {
return (
diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx
index 3a2eeec..ba4d6a6 100644
--- a/web/src/components/canvas/canvas-toolbar.tsx
+++ b/web/src/components/canvas/canvas-toolbar.tsx
@@ -1,7 +1,7 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useRef, useState } from "react";
import { Button, Segmented, Switch } from "antd";
-import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
+import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -18,6 +18,7 @@ export function CanvasToolbar({
onAddAudio,
onAddText,
onAddConfig,
+ onAddGroup,
onUndo,
onRedo,
onUpload,
@@ -38,6 +39,7 @@ export function CanvasToolbar({
onAddAudio: () => void;
onAddText: () => void;
onAddConfig: () => void;
+ onAddGroup: () => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
@@ -90,6 +92,9 @@ export function CanvasToolbar({
+
+
+
@@ -281,6 +286,7 @@ function toolLabel(id: string) {
if (id === "tool-video") return "视频";
if (id === "tool-audio") return "音频";
if (id === "tool-config") return "生成配置";
+ if (id === "tool-group") return "组";
if (id === "tool-upload") return "上传素材";
if (id === "tool-assets") return "我的素材";
if (id === "tool-style") return "画布外观";
diff --git a/web/src/constant/canvas.ts b/web/src/constant/canvas.ts
index d1f02b6..e13eec7 100644
--- a/web/src/constant/canvas.ts
+++ b/web/src/constant/canvas.ts
@@ -14,6 +14,7 @@ export const NODE_DEFAULT_SIZE = {
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
[CanvasNodeType.Video]: { width: 420, height: 236, title: "Video" },
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
+ [CanvasNodeType.Group]: { width: 760, height: 480, title: "组" },
} satisfies Record;
export const NODE_SPECS = {
@@ -37,6 +38,10 @@ export const NODE_SPECS = {
...NODE_DEFAULT_SIZE[CanvasNodeType.Audio],
metadata: { content: "", status: "idle" },
},
+ [CanvasNodeType.Group]: {
+ ...NODE_DEFAULT_SIZE[CanvasNodeType.Group],
+ metadata: { status: "idle" },
+ },
} satisfies Record;
export function getNodeSpec(type: CanvasNodeType) {
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index a5a9919..96a1d02 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -312,6 +312,7 @@ function InfiniteCanvasPage() {
const [collapsingBatchIds, setCollapsingBatchIds] = useState>(new Set());
const [openingBatchIds, setOpeningBatchIds] = useState>(new Set());
const [isNodeDragging, setIsNodeDragging] = useState(false);
+ const [dropTargetGroupId, setDropTargetGroupId] = useState(null);
const nodesRef = useRef(nodes);
const connectionsRef = useRef(connections);
@@ -598,7 +599,7 @@ function InfiniteCanvasPage() {
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
- if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
+ if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
setPendingConnectionCreate(null);
setConnecting(null);
},
@@ -679,6 +680,14 @@ function InfiniteCanvasPage() {
});
return map;
}, [nodes]);
+ const groupChildCountById = useMemo(() => {
+ const map = new Map();
+ nodes.forEach((node) => {
+ const groupId = node.metadata?.groupId;
+ if (groupId) map.set(groupId, (map.get(groupId) || 0) + 1);
+ });
+ return map;
+ }, [nodes]);
const batchMotionById = useMemo(() => {
const map = new Map();
nodes.forEach((node) => {
@@ -790,7 +799,7 @@ function InfiniteCanvasPage() {
setNodes((prev) => [...prev, newNode]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
- if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
+ if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
},
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
);
@@ -805,6 +814,8 @@ function InfiniteCanvasPage() {
setNodes((prev) => {
const next = prev.filter((node) => !allIds.has(node.id));
return next.map((node) => {
+ const groupId = node.metadata?.groupId;
+ if (groupId && allIds.has(groupId)) return { ...node, metadata: { ...node.metadata, groupId: undefined } };
const childIds = node.metadata?.batchChildIds?.filter((childId) => !allIds.has(childId));
if (!node.metadata?.isBatchRoot || childIds?.length === node.metadata.batchChildIds?.length) return node;
const primaryImageId = childIds?.includes(node.metadata.primaryImageId || "") ? node.metadata.primaryImageId : childIds?.[0];
@@ -888,7 +899,7 @@ function InfiniteCanvasPage() {
setNodes((prev) => [...prev, next]);
setSelectedNodeIds(new Set([id]));
setSelectedConnectionId(null);
- setDialogNodeId(id);
+ if (next.type !== CanvasNodeType.Group) setDialogNodeId(id);
}, []);
const copySelectedNodes = useCallback(() => {
@@ -943,6 +954,12 @@ function InfiniteCanvasPage() {
};
});
+ const pastedNodes = nextNodes.map((node) => {
+ const groupId = node.metadata?.groupId;
+ if (!groupId) return node;
+ return { ...node, metadata: { ...node.metadata, groupId: idMap.get(groupId) } };
+ });
+
const nextConnections = clipboard.connections.flatMap((connection, index) => {
const fromNodeId = idMap.get(connection.fromNodeId);
const toNodeId = idMap.get(connection.toNodeId);
@@ -957,12 +974,12 @@ function InfiniteCanvasPage() {
];
});
- setNodes((prev) => [...prev, ...nextNodes]);
+ setNodes((prev) => [...prev, ...pastedNodes]);
setConnections((prev) => [...prev, ...nextConnections]);
- setSelectedNodeIds(new Set(nextNodes.map((node) => node.id)));
+ setSelectedNodeIds(new Set(pastedNodes.map((node) => node.id)));
setSelectedConnectionId(null);
setContextMenu(null);
- setDialogNodeId(nextNodes[0]?.id || null);
+ setDialogNodeId(pastedNodes[0]?.type === CanvasNodeType.Group ? null : pastedNodes[0]?.id || null);
return true;
}, [getCanvasCenter]);
@@ -1091,7 +1108,13 @@ function InfiniteCanvasPage() {
setSelectedNodeIds(nextSelected);
const dragIds = new Set(nextSelected);
currentNodes.forEach((node) => {
- if (nextSelected.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId));
+ if (!nextSelected.has(node.id)) return;
+ node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId));
+ if (node.type === CanvasNodeType.Group) {
+ currentNodes.forEach((child) => {
+ if (child.metadata?.groupId === node.id) dragIds.add(child.id);
+ });
+ }
});
dragRef.current = {
isDraggingNode: true,
@@ -1122,14 +1145,23 @@ function InfiniteCanvasPage() {
historyPausedRef.current = false;
nodeDraggingRef.current = false;
setIsNodeDragging(false);
+ setDropTargetGroupId(null);
if (dragRef.current.hasMoved && clientX != null && clientY != null) {
- setNodes((prev) =>
- prev.map((node) => {
+ const movedIds = new Set(initialPositions.map((item) => item.id));
+ setNodes((prev) => {
+ const moved = prev.map((node) => {
const initial = initialPositions.find((item) => item.id === node.id);
- if (!initial) return node;
- return { ...node, position: { x: initial.x + dx, y: initial.y + dy } };
- }),
- );
+ return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
+ });
+ const targetGroup = findGroupDropTarget(movedIds, moved);
+ if (targetGroup) return snapNodesIntoGroup(movedIds, moved, targetGroup);
+ return moved.map((node) => {
+ if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node;
+ const groupId = findContainingGroupId(node, moved);
+ if (node.metadata?.groupId === groupId) return node;
+ return { ...node, metadata: { ...node.metadata, groupId } };
+ });
+ });
}
dragRef.current.isDraggingNode = false;
@@ -1139,7 +1171,7 @@ function InfiniteCanvasPage() {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
if (clickedNode?.type === CanvasNodeType.Text) {
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
- } else {
+ } else if (clickedNode?.type !== CanvasNodeType.Group) {
setDialogNodeId(clickedNodeId);
}
}
@@ -1157,6 +1189,13 @@ function InfiniteCanvasPage() {
dragRef.current.hasMoved = true;
}
+ const movedIds = new Set(initialPositions.map((item) => item.id));
+ const previewNodes = nodesRef.current.map((node) => {
+ const initial = initialPositions.find((item) => item.id === node.id);
+ return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
+ });
+ setDropTargetGroupId(findGroupDropTarget(movedIds, previewNodes)?.id || null);
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setNodes((prev) =>
@@ -2567,6 +2606,8 @@ function InfiniteCanvasPage() {
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
showPanel={dialogNodeId === node.id && !selectionBox}
batchCount={batchChildCountById.get(node.id) || 0}
+ groupChildCount={groupChildCountById.get(node.id) || 0}
+ isGroupDropTarget={dropTargetGroupId === node.id}
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
batchClosing={Boolean(node.metadata?.batchRootId && collapsingBatchIds.has(node.metadata.batchRootId))}
batchOpening={openingBatchIds.has(node.id)}
@@ -2693,6 +2734,7 @@ function InfiniteCanvasPage() {
onAddAudio={() => createNode(CanvasNodeType.Audio)}
onAddText={() => createNode(CanvasNodeType.Text)}
onAddConfig={() => createNode(CanvasNodeType.Config)}
+ onAddGroup={() => createNode(CanvasNodeType.Group)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onUpload={() => handleUploadRequest()}
@@ -3122,6 +3164,63 @@ function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial, nodes: CanvasNodeData[]) {
+ if (nodes.some((node) => movedIds.has(node.id) && node.type === CanvasNodeType.Group)) return null;
+ const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
+ if (!movingNodes.length) return null;
+ return (
+ [...nodes]
+ .reverse()
+ .find((group) => {
+ if (group.type !== CanvasNodeType.Group || movedIds.has(group.id)) return false;
+ return movingNodes.some((node) => {
+ const centerX = node.position.x + node.width / 2;
+ const centerY = node.position.y + node.height / 2;
+ return centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height;
+ });
+ }) || null
+ );
+}
+
+function snapNodesIntoGroup(movedIds: Set, nodes: CanvasNodeData[], group: CanvasNodeData) {
+ const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
+ if (!movingNodes.length) return nodes;
+ const pad = 24;
+ const bounds = nodeBounds(movingNodes);
+ const left = group.position.x + pad;
+ const top = group.position.y + pad;
+ const right = group.position.x + group.width - pad;
+ const bottom = group.position.y + group.height - pad;
+ const dx = bounds.right - bounds.left > right - left ? left - bounds.left : bounds.left < left ? left - bounds.left : bounds.right > right ? right - bounds.right : 0;
+ const dy = bounds.bottom - bounds.top > bottom - top ? top - bounds.top : bounds.top < top ? top - bounds.top : bounds.bottom > bottom ? bottom - bounds.bottom : 0;
+ return nodes.map((node) => {
+ if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node;
+ return { ...node, position: { x: node.position.x + dx, y: node.position.y + dy }, metadata: { ...node.metadata, groupId: group.id } };
+ });
+}
+
+function nodeBounds(nodes: CanvasNodeData[]) {
+ return nodes.reduce(
+ (acc, node) => ({
+ left: Math.min(acc.left, node.position.x),
+ top: Math.min(acc.top, node.position.y),
+ right: Math.max(acc.right, node.position.x + node.width),
+ bottom: Math.max(acc.bottom, node.position.y + node.height),
+ }),
+ { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity },
+ );
+}
+
+function findContainingGroupId(node: CanvasNodeData, nodes: CanvasNodeData[]) {
+ const centerX = node.position.x + node.width / 2;
+ const centerY = node.position.y + node.height / 2;
+ return (
+ [...nodes]
+ .reverse()
+ .find((group) => group.type === CanvasNodeType.Group && group.id !== node.id && centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height)?.id || undefined
+ );
+}
+
function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
return {
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
@@ -3133,6 +3232,7 @@ function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: C
const first = nodes.find((node) => node.id === firstNodeId);
const second = nodes.find((node) => node.id === secondNodeId);
if (!first || !second || first.id === second.id) return null;
+ if (first.type === CanvasNodeType.Group || second.type === CanvasNodeType.Group) return null;
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
index 3cc8137..8afc185 100644
--- a/web/src/types/canvas.ts
+++ b/web/src/types/canvas.ts
@@ -15,6 +15,7 @@ export enum CanvasNodeType {
Config = "config",
Video = "video",
Audio = "audio",
+ Group = "group",
}
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
@@ -56,6 +57,7 @@ export type CanvasNodeMetadata = {
mimeType?: string;
bytes?: number;
durationMs?: number;
+ groupId?: string;
};
export type CanvasNodeData = {