From abe3be58a64bd4198502e8f4e4409cdb05d45037 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Thu, 9 Jul 2026 11:48:23 +0800 Subject: [PATCH] feat(canvas): add group node support with drag-and-drop functionality --- CHANGELOG.md | 2 + docs/content/docs/progress/pending-test.mdx | 1 + web/src/components/canvas/canvas-mini-map.tsx | 2 +- .../canvas/canvas-node-hover-toolbar.tsx | 2 +- web/src/components/canvas/canvas-node.tsx | 46 +++++-- web/src/components/canvas/canvas-toolbar.tsx | 8 +- web/src/constant/canvas.ts | 5 + web/src/pages/canvas/project.tsx | 128 ++++++++++++++++-- web/src/types/canvas.ts | 2 + 9 files changed, 169 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a001330..494014e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] 画布新增组节点,支持节点拖入/拖出分组、拖拽高亮吸附和移动组时带动子节点。 + ## v0.6.0 - 2026-07-09 + [新增] 新增Codex App插件支持。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 1bf0c23..bf81b2a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。 - GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。 - 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。 - Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明。 diff --git a/web/src/components/canvas/canvas-mini-map.tsx b/web/src/components/canvas/canvas-mini-map.tsx index 9433105..85ba9ae 100644 --- a/web/src/components/canvas/canvas-mini-map.tsx +++ b/web/src/components/canvas/canvas-mini-map.tsx @@ -113,7 +113,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n > {nodes.map((node) => { const pos = toMinimap(node.position.x, node.position.y); - const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted; + const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Group ? "#94a3b8" : theme.node.muted; return (
- + diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 281390f..ca0d719 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { ChevronRight, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react"; +import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; @@ -28,6 +28,8 @@ type CanvasNodeProps = { renderPanel?: (node: CanvasNodeData) => ReactNode; renderNodeContent?: (node: CanvasNodeData) => ReactNode; batchCount?: number; + groupChildCount?: number; + isGroupDropTarget?: boolean; batchExpanded?: boolean; batchClosing?: boolean; batchOpening?: boolean; @@ -65,6 +67,7 @@ type NodeContentRendererProps = { onGenerateImage?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; onSetBatchPrimary?: () => void; + groupChildCount: number; }; export const CanvasNode = React.memo(function CanvasNode({ @@ -83,6 +86,8 @@ export const CanvasNode = React.memo(function CanvasNode({ renderPanel, renderNodeContent, batchCount = 0, + groupChildCount = 0, + isGroupDropTarget = false, batchExpanded = false, batchClosing = false, batchOpening = false, @@ -107,6 +112,7 @@ export const CanvasNode = React.memo(function CanvasNode({ const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content); const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content); const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content); + const isGroup = data.type === CanvasNodeType.Group; const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1; const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId); const isActive = isConnectionTarget || isSelected || isFocusRelated; @@ -237,7 +243,7 @@ export const CanvasNode = React.memo(function CanvasNode({ return (
onMouseDown(event, data.id)} onDoubleClick={(event) => { @@ -283,7 +290,7 @@ export const CanvasNode = React.memo(function CanvasNode({ className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`} style={ { - background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill, + background: isGroup ? "transparent" : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill, "--batch-from-x": `${batchMotion?.x || 0}px`, "--batch-from-y": `${batchMotion?.y || 0}px`, "--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`, @@ -310,13 +317,14 @@ export const CanvasNode = React.memo(function CanvasNode({ onGenerateImage={onGenerateImage} onToggleBatch={() => onToggleBatch?.(data.id)} onSetBatchPrimary={() => onSetBatchPrimary?.(data)} + groupChildCount={groupChildCount} />
{showImageInfo && hasImageContent ? : null} {resourceLabel ? : null} - {!hasImageContent && !hasVideoContent && !hasAudioContent ?
: null} + {!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ?
: null} @@ -324,10 +332,10 @@ export const CanvasNode = React.memo(function CanvasNode({
- onConnectStart(event, data.id, "target")} /> - onConnectStart(event, data.id, "source")} /> + {!isGroup ? onConnectStart(event, data.id, "target")} /> : null} + {!isGroup ? onConnectStart(event, data.id, "source")} /> : null} - {showPanel && renderPanel ?
{renderPanel(data)}
: null} + {showPanel && !isGroup && renderPanel ?
{renderPanel(data)}
: null}
); }); @@ -348,8 +356,26 @@ const nodeContentRenderers = { [CanvasNodeType.Config]: EmptyImageContent, [CanvasNodeType.Video]: VideoNodeContent, [CanvasNodeType.Audio]: AudioNodeContent, + [CanvasNodeType.Group]: GroupNodeContent, } satisfies Record ReactNode>; +function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) { + return ( +
+
+ + + + {node.title || "组"} + + {groupChildCount} 个节点 + +
+
+
+ ); +} + function LoadingContent({ theme }: Pick) { 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 = {