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)}