From ec0e8c4e24320f945e60e57c24fe4295c04eb2d1 Mon Sep 17 00:00:00 2001
From: HouYunFei <1844025705@qq.com>
Date: Thu, 9 Jul 2026 12:54:36 +0800
Subject: [PATCH] feat(canvas): add group node support with drag-and-drop
functionality
---
CHANGELOG.md | 2 +
docs/content/docs/progress/pending-test.mdx | 2 +
.../components/canvas/canvas-context-menu.tsx | 4 +-
.../canvas/canvas-node-hover-toolbar.tsx | 2 +-
web/src/components/canvas/canvas-node.tsx | 71 ++++++++++++++++++-
web/src/constant/canvas.ts | 8 +--
web/src/pages/canvas/project.tsx | 32 +++------
7 files changed, 91 insertions(+), 30 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 494014e..c5ed873 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,9 @@
## Unreleased
++ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
+ [新增] 画布新增组节点,支持节点拖入/拖出分组、拖拽高亮吸附和移动组时带动子节点。
++ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
## v0.6.0 - 2026-07-09
diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index bf81b2a..4b4159f 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -5,6 +5,8 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
+- 画布节点名称:每个节点保留 `title` 名称字段,默认名称显示在节点上方,双击名称可直接编辑,节点信息弹窗和 JSON 可查看该字段。
+- 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出。
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx
index 18ac9ce..81339e6 100644
--- a/web/src/components/canvas/canvas-context-menu.tsx
+++ b/web/src/components/canvas/canvas-context-menu.tsx
@@ -25,8 +25,8 @@ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }:
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
- {menu.type === "node" ? } label="Duplicate" onClick={onDuplicate} /> : null}
- } label="Delete" onClick={onDelete} danger />
+ {menu.type === "node" ? } label="复制" onClick={onDuplicate} /> : null}
+ } label="删除" onClick={onDelete} danger />
);
}
diff --git a/web/src/components/canvas/canvas-node-hover-toolbar.tsx b/web/src/components/canvas/canvas-node-hover-toolbar.tsx
index ddd4ef8..bd2e10b 100644
--- a/web/src/components/canvas/canvas-node-hover-toolbar.tsx
+++ b/web/src/components/canvas/canvas-node-hover-toolbar.tsx
@@ -218,7 +218,6 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
return JSON.stringify(
node,
(key, value) => {
- if (key === "title") return undefined;
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return "[base64 image]";
}
@@ -254,6 +253,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
{view === "info" ? (
+
diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx
index ca0d719..8a7c19d 100644
--- a/web/src/components/canvas/canvas-node.tsx
+++ b/web/src/components/canvas/canvas-node.tsx
@@ -41,6 +41,7 @@ type CanvasNodeProps = {
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onContentChange: (nodeId: string, content: string) => void;
+ onTitleChange: (nodeId: string, title: string) => void;
onToggleBatch?: (nodeId: string) => void;
onSetBatchPrimary?: (node: CanvasNodeData) => void;
onRetry?: (node: CanvasNodeData) => void;
@@ -99,6 +100,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onConnectStart,
onResize,
onContentChange,
+ onTitleChange,
onToggleBatch,
onSetBatchPrimary,
onRetry,
@@ -109,6 +111,8 @@ export const CanvasNode = React.memo(function CanvasNode({
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const [isEditingContent, setIsEditingContent] = useState(false);
+ const [isEditingTitle, setIsEditingTitle] = useState(false);
+ const [titleDraft, setTitleDraft] = useState(data.title || "");
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);
@@ -118,6 +122,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
const textareaRef = useRef
(null);
+ const titleInputRef = useRef(null);
const resizeRef = useRef({
isResizing: false,
corner: "bottom-right" as ResizeCorner,
@@ -131,6 +136,34 @@ export const CanvasNode = React.memo(function CanvasNode({
ratio: 1,
});
+ useEffect(() => {
+ setTitleDraft(data.title || "");
+ }, [data.title]);
+
+ useEffect(() => {
+ if (!isEditingTitle) return;
+ titleInputRef.current?.focus();
+ titleInputRef.current?.select();
+ }, [isEditingTitle]);
+
+ const finishTitleEditing = useCallback(() => {
+ const title = titleDraft.trim() || data.title || "未命名节点";
+ setTitleDraft(title);
+ setIsEditingTitle(false);
+ if (title !== data.title) onTitleChange(data.id, title);
+ }, [data.id, data.title, onTitleChange, titleDraft]);
+
+ useEffect(() => {
+ if (!isEditingTitle) return;
+ const handleOutsidePointerDown = (event: PointerEvent) => {
+ const target = event.target;
+ if (target instanceof Node && titleInputRef.current?.contains(target)) return;
+ finishTitleEditing();
+ };
+ window.addEventListener("pointerdown", handleOutsidePointerDown, true);
+ return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
+ }, [finishTitleEditing, isEditingTitle]);
+
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
@@ -261,6 +294,40 @@ export const CanvasNode = React.memo(function CanvasNode({
}}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
+ event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
+ {isEditingTitle ? (
+ setTitleDraft(event.target.value)}
+ onBlur={finishTitleEditing}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") finishTitleEditing();
+ if (event.key === "Escape") {
+ setTitleDraft(data.title || "");
+ setIsEditingTitle(false);
+ }
+ }}
+ />
+ ) : (
+
+ )}
+
+
-
{node.title || "组"}
+
组
{groupChildCount} 个节点
@@ -547,7 +614,7 @@ function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
- {node.title || "音频"}
+ 音频
diff --git a/web/src/constant/canvas.ts b/web/src/constant/canvas.ts
index e13eec7..2bb7420 100644
--- a/web/src/constant/canvas.ts
+++ b/web/src/constant/canvas.ts
@@ -9,11 +9,11 @@ type CanvasNodeSpec = {
};
export const NODE_DEFAULT_SIZE = {
- [CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
- [CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
+ [CanvasNodeType.Image]: { width: 340, height: 240, title: "图片" },
+ [CanvasNodeType.Text]: { width: 340, height: 240, title: "文本" },
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
- [CanvasNodeType.Video]: { width: 420, height: 236, title: "Video" },
- [CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
+ [CanvasNodeType.Video]: { width: 420, height: 236, title: "视频" },
+ [CanvasNodeType.Audio]: { width: 340, height: 120, title: "音频" },
[CanvasNodeType.Group]: { width: 760, height: 480, title: "组" },
} satisfies Record
;
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 96a1d02..6c550cb 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -235,7 +235,6 @@ function InfiniteCanvasPage() {
const historyPausedRef = useRef(false);
const didInitialCenterRef = useRef(false);
const rafRef = useRef(null);
- const toolbarHideTimerRef = useRef | null>(null);
const nodeDraggingRef = useRef(false);
const dragRef = useRef<{
isDraggingNode: boolean;
@@ -547,25 +546,12 @@ function InfiniteCanvasPage() {
}
}, []);
- const keepNodeToolbar = useCallback(
- (nodeId: string) => {
- if (nodeDraggingRef.current || nodeImageSettingsOpen) return;
- if (toolbarHideTimerRef.current) {
- clearTimeout(toolbarHideTimerRef.current);
- toolbarHideTimerRef.current = null;
- }
- setToolbarNodeId(nodeId);
- },
- [nodeImageSettingsOpen],
- );
+ const keepNodeToolbar = useCallback((nodeId: string) => {
+ if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return;
+ setToolbarNodeId(nodeId);
+ }, [nodeImageSettingsOpen]);
- const hideNodeToolbar = useCallback(() => {
- if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current);
- toolbarHideTimerRef.current = setTimeout(() => {
- setToolbarNodeId(null);
- toolbarHideTimerRef.current = null;
- }, 120);
- }, []);
+ const hideNodeToolbar = useCallback(() => {}, []);
const connectNodes = useCallback(
(current: ConnectionHandle, targetNodeId: string) => {
@@ -1106,6 +1092,7 @@ function InfiniteCanvasPage() {
}
setSelectedNodeIds(nextSelected);
+ setToolbarNodeId(nextSelected.size === 1 && nextSelected.has(nodeId) ? nodeId : null);
const dragIds = new Set(nextSelected);
currentNodes.forEach((node) => {
if (!nextSelected.has(node.id)) return;
@@ -1502,6 +1489,10 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content } } : node)));
}, []);
+ const handleNodeTitleChange = useCallback((nodeId: string, title: string) => {
+ setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, title } : node)));
+ }, []);
+
const toggleBatchExpanded = useCallback((nodeId: string) => {
const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded);
if (isExpanded) {
@@ -2658,15 +2649,14 @@ function InfiniteCanvasPage() {
onHoverStart={(nodeId) => {
if (nodeDraggingRef.current) return;
setHoveredNodeId(nodeId);
- keepNodeToolbar(nodeId);
}}
onHoverEnd={(nodeId) => {
setHoveredNodeId((current) => (current === nodeId ? null : current));
- hideNodeToolbar();
}}
onConnectStart={handleConnectStart}
onResize={handleNodeResize}
onContentChange={handleNodeContentChange}
+ onTitleChange={handleNodeTitleChange}
onToggleBatch={toggleBatchExpanded}
onSetBatchPrimary={setBatchPrimary}
onRetry={(node) => void handleRetryNode(node)}