From e28ddb673fa87b925cdb10c42c0fe343948d8e99 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 01:47:27 +0800 Subject: [PATCH] feat: add four-direction quick-add controls to selected nodes --- README.md | 2 ++ VALIDATION.md | 9 ++++++ src/components/canvas/canvas.module.css | 8 ++++++ src/components/canvas/graph-canvas.tsx | 16 ++++++++--- src/components/canvas/material-node.tsx | 22 ++++++++++---- src/lib/canvas.ts | 37 +++++++++++++++++++++++- tests/canvas.test.mjs | 38 ++++++++++++++++++++++++- 7 files changed, 121 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ce1530b..a03a026 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,5 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、 左上角标题第一次点击选中,选中后再次点击进入编辑;回车或失焦保存,Esc 取消,F2 也可直接编辑。真实图谱改名先读取最新资料和版本,再携带 If-Match 更新名称,保留描述与内容;版本冲突时提示重试。演示模式保存到本地。保存后标题、侧栏列表及导出文件名同步更新。 左上和底部工具栏按右上视图控件的分组节奏增加横向留白(间隙 6px、横向内边距 6px),按钮仍为 32×32px,竖向内边距仍为 4px。三处分隔线统一;左侧标题不再额外叠加外边距,右上现有样式保持不变。 + +选中节点后,上、下、左、右各显示一个加号。点击会在对应方向新增文字节点并连线,视图跟随新节点;重复添加会避开已有节点,整个操作可一步撤销。方向连线也会保存在本地草稿和导出的 JSON 中,旧版左右连接继续兼容。 diff --git a/VALIDATION.md b/VALIDATION.md index 812bebd..dfbead4 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -108,3 +108,12 @@ - 右上现有尺寸和间距保持不变;左上与底部采用稍宽的 6px 横向间隙和横向内边距,保持 4px 竖向内边距与 32×32px 按钮。 - 左上在标题前后增加与右上相同的分隔线,去掉标题额外外边距;三处分隔线改为共享样式。 - 类型检查和差异检查通过;本次 CSS 留白试调未重复生产构建或访问浏览器。 + +## 选中节点四向加号(2026-11-06) + +- 节点 selected 时显示上/下/左/右四个加号,未选中时隐藏;加号使用 nodrag/nopan 并阻止指针事件传播,避免触发节点或画布拖动。 +- 点击按实际测量的节点尺寸在对应方向新增文字节点并自动连线,重复添加避开已有节点;选择与视图跟随新节点,添加及连线组成一步撤销。 +- 四侧使用命名 Handle 与 Loose 连接模式;保留旧无名连接点用于已有连线,导入导出校验并保留新的 sourceHandle/targetHandle。 +- 类型检查与 18 项自动测试通过,新增四向位置、连接方向持久化、一步撤销和重复添加避让测试。 +- 按用户既定安排不访问浏览器,本次未进行视觉和实际点击验收。 +- 隔离 Webpack 生产构建与完整 API/路由/品牌资源冒烟回归均通过。 diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index 8a0d910..73b7d66 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -30,3 +30,11 @@ .handMode .node textarea, .handMode .node img { pointer-events: none; } .topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; } + +.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; } +.branchButton { position: absolute; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid #cbb8a8; border-radius: 50%; background: #fffefb; color: #78523f; box-shadow: 0 2px 6px #32251a0a; cursor: pointer; z-index: 5; } +.branchButton:hover { background: #f1e6df; border-color: #a47764; } +.branch_top { top: -48px; left: 50%; transform: translateX(-50%); } +.branch_right { right: -48px; top: 50%; transform: translateY(-50%); } +.branch_bottom { bottom: -48px; left: 50%; transform: translateX(-50%); } +.branch_left { left: -48px; top: 50%; transform: translateY(-50%); } diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 5c65cd4..1551f12 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -2,14 +2,14 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react"; import { - Background, BackgroundVariant, ReactFlow, ReactFlowProvider, SelectionMode, + Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode, addEdge, useReactFlow, useViewport, type Connection, } from "@xyflow/react"; import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, Type, Undo2, Upload } from "lucide-react"; import { useReducedMotion } from "motion/react"; import toolbar from "@/components/ui/toolbar.module.css"; import { IconButton } from "@/components/ui/icon-button"; -import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode } from "@/lib/canvas"; +import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode, type NodeDirection } from "@/lib/canvas"; import type { Board } from "@/lib/workspace"; import { MaterialNode, NodeEditing } from "./material-node"; import styles from "./canvas.module.css"; @@ -63,7 +63,15 @@ function CanvasEditor({ board }: { board: Board }) { const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []); const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []); - const editing = useMemo(() => ({ begin: beginEdit, edit }), [beginEdit, edit]); + const branch = useCallback((id: string, direction: NodeDirection) => { + try { + const document = branchCanvasNode(state.present, id, direction); + dispatch({ type: "replace", document }); + const child = document.nodes[document.nodes.length - 1]; + void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration }); + } catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); } + }, [state.present, flow, duration]); + const editing = useMemo(() => ({ begin: beginEdit, edit, branch }), [beginEdit, edit, branch]); const center = () => { const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); @@ -156,7 +164,7 @@ function CanvasEditor({ board }: { board: Board }) { nodes={nodes} edges={edges} nodeTypes={nodeTypes} onNodesChange={(changes) => dispatch({ type: "nodes", changes })} onEdgesChange={(changes) => dispatch({ type: "edges", changes })} - onNodeDragStart={beginEdit} onConnect={connect} + onNodeDragStart={beginEdit} onConnect={connect} connectionMode={ConnectionMode.Loose} onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })} defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4} panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]} diff --git a/src/components/canvas/material-node.tsx b/src/components/canvas/material-node.tsx index cb21918..24dd8bf 100644 --- a/src/components/canvas/material-node.tsx +++ b/src/components/canvas/material-node.tsx @@ -2,11 +2,14 @@ import { createContext, useContext } from "react"; import { Handle, Position, type NodeProps } from "@xyflow/react"; -import { FileText, Image as ImageIcon, Type } from "lucide-react"; -import type { CanvasNode } from "@/lib/canvas"; +import { FileText, Image as ImageIcon, Plus, Type } from "lucide-react"; +import { nodeDirections, type CanvasNode, type NodeDirection } from "@/lib/canvas"; import styles from "./canvas.module.css"; -export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {} }); +export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {}, branch: (_id: string, _direction: NodeDirection) => {} }); + +const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left }; +const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧" }; export function MaterialNode({ id, data, selected }: NodeProps) { const editing = useContext(NodeEditing); @@ -14,12 +17,21 @@ export function MaterialNode({ id, data, selected }: NodeProps) { return (
{data.title}
- + {data.kind === "image" ? {data.title} : (