瀏覽代碼

feat: 添加拖拽节点功能

liaojiaxing 6 月之前
父節點
當前提交
fb4b452a98

+ 1 - 1
apps/designer/src/components/CustomInput.tsx

@@ -15,7 +15,7 @@ export default function CustomInput(props: {
   onChange?: (value: string) => void;
 }) {
   const { value, styles, node, placeholder, txtStyle } = props;
-  const [isEditing, setIsEditing] = useSafeState(!node.data?.lock);
+  const [isEditing, setIsEditing] = useSafeState(false);
   const inputRef = useRef<InputRef>(null);
   
   const style = useMemo(() => {

+ 32 - 19
apps/designer/src/components/mindMap/Topic.tsx

@@ -12,6 +12,7 @@ import Link from "./Link";
 import ExtraModule from "./ExtraModule";
 import CustomTag from "@/components/CustomTag";
 import { TopicItem } from "@/types";
+import { selectTopic } from "@/utils/mindmapHander";
 const component = ({ node, graph }: { node: Node; graph: Graph }) => {
   const {
     fill,
@@ -33,10 +34,10 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
   const { size, ref } = useSizeHook();
   const { fillContent, strokeColor, strokeWidth, strokeDasharray } =
     useShapeProps(fill, size, stroke);
-  const [selected, setSelected] = useState(true);
+  const [selected, setSelected] = useState(false);
   const handleSelect = (_args: EventArgs["node:selected"]) => {
     const cells = graph.getSelectedCells();
-    setSelected(cells.length === 1 && cells[0].id === node.id);
+    setSelected(!!cells.find((item) => item.id === node.id));
   };
   const [showCollapsePoint, setShowCollapsePoint] = useState(collapsed);
   const extraModuleRef = useRef<HTMLDivElement>(null);
@@ -45,8 +46,8 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
   const remarkRef = useRef<HTMLDivElement>(null);
 
   useEffect(() => {
-    graph.createTransformWidget(node);
-    graph.select(node);
+    // graph.createTransformWidget(node);
+    // graph.select(node);
     graph.on("node:selected", handleSelect);
     graph.on("node:unselected", handleSelect);
     return () => {
@@ -58,8 +59,6 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
   useEffect(() => {
     // 动态计算出所需的宽高
     let width = extraModules?.width || 0;
-    
-    
   }, [extraModules, label, icons, tags, remarkRef]);
 
   const childrenCount = useMemo(() => {
@@ -85,17 +84,30 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
     } else {
       topic = addTopic(TopicType.sub, setMindProjectInfo, node);
     }
-    graph.resetSelection(topic?.id);
+    
+    selectTopic(graph, topic);
   };
-  
+
   const handleToggleCollapse = () => {
     node.setData({
-      collapsed: !collapsed
-    })
+      collapsed: !collapsed,
+    });
   };
 
   return (
     <>
+      {selected && (
+        <div
+          style={{
+            width: `calc(100% + 4px)`,
+            height: `calc(100% + 4px)`,
+            border: "1.5px solid #239edd",
+            position: "absolute",
+            top: -2,
+            left: -2,
+          }}
+        />
+      )}
       <div
         className="relative text-0 w-full h-full px-4px"
         ref={ref}
@@ -120,7 +132,10 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
               className="flex-1 flex flex-col justify-center"
               // style={{ height: getSize.contentHeight }}
             >
-              <div className="flex justify-start items-center text-20px" ref={titleRef}>
+              <div
+                className="flex justify-start items-center text-20px"
+                ref={titleRef}
+              >
                 {icons?.map((icon: string) => {
                   return (
                     <svg key={icon} className="icon mr-6px" aria-hidden="true">
@@ -190,13 +205,13 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
             <PlusOutlined />
           </div>
         )}
-        {
-          type !== TopicType.main && children.length && <div
+        {type !== TopicType.main && children.length && (
+          <div
             className="absolute right--30px top-0 w-30px h-full"
             onMouseOver={() => !collapsed && setShowCollapsePoint(true)}
             onMouseOut={() => !collapsed && setShowCollapsePoint(false)}
-            />
-        }
+          />
+        )}
         {/* 折叠按钮 */}
         {type !== TopicType.main && children?.length && showCollapsePoint && (
           <div
@@ -217,12 +232,10 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
             onClick={handleToggleCollapse}
             style={{
               border: `1px solid ${fill.color1}`,
-              color: fill.color1
+              color: fill.color1,
             }}
           >
-            {
-              collapsed && childrenCount
-            }
+            {collapsed && childrenCount}
           </div>
         )}
       </div>

+ 506 - 19
apps/designer/src/events/mindMapEvent.ts

@@ -1,22 +1,163 @@
-import { Graph, Cell } from "@antv/x6";
-import { BorderSize, TopicType } from "@/enum";
-import { addTopic, updateTopic } from "@/pages/mindmap/mindMap";
-import { MindMapProjectInfo } from "@/types";
+import { Graph, Cell, Node, Edge } from "@antv/x6";
+import { BorderSize, StructureType, TopicType } from "@/enum";
+import {
+  addTopic,
+  getMindMapProjectByLocal,
+  updateTopic,
+} from "@/pages/mindmap/mindMap";
+import { MindMapProjectInfo, TopicItem } from "@/types";
+import { Dnd } from "@antv/x6-plugin-dnd";
+import { selectTopic } from "@/utils/mindmapHander";
+import { uuid } from "@/utils";
+
+enum positionType {
+  left = 'left',
+  right = 'right',
+  outside = 'outside',
+}
+// 拖拽主题时指示器
+let indicatorNode: Node | undefined;
+let indicatorEdge: Edge | undefined;
+// 插入的节点位置
+let insertNode: Node | undefined;
+let moveStart: { x: number; y: number } | undefined;
+let dragging = false;
+let currentShadowNode: Node | undefined;
 
 export const bindMindMapEvents = (
   graph: Graph,
   mindProjectInfo?: MindMapProjectInfo,
   setMindProjectInfo?: (info: MindMapProjectInfo) => void,
-  setSelectedCell?: (cell: Cell[]) => void
+  setSelectedCell?: (cell: Cell[]) => void,
+  dndRef?: React.MutableRefObject<Dnd | undefined>
 ) => {
-  // 选中的节点/边发生改变(增删)时触发
-  graph.on('selection:changed', ({selected}: {selected: Cell[];}) => {
+  graph.on("selection:changed", ({ selected }: { selected: Cell[] }) => {
     setSelectedCell?.(selected);
-  })
+  });
+
+  graph.on("node:mouseenter", (args) => {
+    if (args.node.data?.type !== TopicType.main) {
+      graph.disablePanning();
+    }
+  });
+
+  graph.on("node:mouseleave", (args) => {
+    graph.enablePanning();
+  });
+
+  /*********************************** 拖拽开始 *********************************/
+  graph.on("node:mousedown", (args) => {
+    moveStart = {
+      x: args.x,
+      y: args.y,
+    };
+  });
+
+  graph.on("node:mousemove", (args) => {
+    if (!args.node.data?.parentId || args.node.data?.shadow) {
+      return;
+    }
+
+    if (
+      !dragging &&
+      moveStart &&
+      (Math.abs(args.x - moveStart.x) > 5 || Math.abs(args.y - moveStart.y) > 5)
+    ) {
+      // 判断开始点是否有值,有值时当拖拽距离>5时开启影子节点
+      dragging = true;
+      const node = args.node;
+      const shadowNode = graph.createNode({
+        shape: "rect",
+        width: node.size().width,
+        height: node.size().height,
+        x: args.x,
+        y: args.y,
+        label: node.data?.label,
+        attrs: {
+          body: {
+            rx: node.data?.borderSize,
+            ry: node.data?.borderSize,
+            stroke: "#666",
+            strokeWidth: 1,
+          },
+        },
+        data: {
+          ...node.data,
+          shadow: true,
+        },
+      });
+      setShadowMode(true, node, graph);
+      currentShadowNode = shadowNode;
+      dndRef?.current?.init();
+      dndRef?.current?.start(shadowNode, args.e as unknown as MouseEvent);
+    }
+  });
+
+  graph.on("node:mousemove", (args) => {
+    // 节点拖拽-处理影子节点吸附问题
+    if (currentShadowNode && setMindProjectInfo) {
+      topicDragHander(
+        graph,
+        setMindProjectInfo,
+        { x: args.x, y: args.y },
+        args.node
+      );
+    }
+  });
+
+  graph.on("node:mouseup", (args) => {
+    // 拖拽结束
+    if (indicatorNode && insertNode) {
+      graph.removeCell(args.node.id + '-edge');
+      setMindProjectInfo && handleSwitchPosition(setMindProjectInfo, args.node.id, insertNode.id);
+    }
+
+    // 成为自由节点
+    if(currentShadowNode && !indicatorNode && args.node.data?.parentId) {
+      setMindProjectInfo && handleSwitchPosition(setMindProjectInfo, args.node.id, undefined, { x: args.x, y: args.y});
+    }
+
+    currentShadowNode && setShadowMode(false, args.node, graph);
+    moveStart = undefined;
+    dragging = false;
+    currentShadowNode = undefined;
+    dndRef?.current?.remove();
+    if (indicatorEdge && indicatorNode) {
+      graph.removeCells([indicatorEdge, indicatorNode]);
+      indicatorEdge = undefined;
+      indicatorNode = undefined;
+    }
+  });
+
+  graph.on("node:move", (args) => {
+    // 自由节点拖拽
+    if(!args.node.data?.parentId) {
+      setShadowMode(true, args.node, graph);
+    }
+  });
+
+  graph.on("node:moving", (args) => {
+    if(!args.node.data?.parentId && setMindProjectInfo) {
+      setShadowMode(false, args.node, graph);
+      topicDragHander(
+        graph,
+        setMindProjectInfo,
+        { x: args.x, y: args.y },
+        args.node
+      );
+    }
+  });
+
+  graph.on("node:moved", (args) => {
+    if(!args.node.data?.parentId) {
+      setShadowMode(false, args.node, graph);
+    }
+  });
+  /*********************************** 拖拽结束 *********************************/
 
   // 双击画布空白-新增自由主题
   graph.on("blank:dblclick", (args) => {
-    if(setMindProjectInfo) {
+    if (setMindProjectInfo) {
       const topic = addTopic(TopicType.branch, setMindProjectInfo, undefined, {
         x: args.x,
         y: args.y,
@@ -26,7 +167,7 @@ export const bindMindMapEvents = (
         borderSize: BorderSize.medium,
       });
 
-      graph.resetSelection(topic?.id)
+      selectTopic(graph, topic);
     }
   });
 
@@ -34,23 +175,369 @@ export const bindMindMapEvents = (
   graph.on("node:change:*", (args) => {
     // console.log("node:change:*", args);
     const { current, previous } = args;
-    if(args.key === "data") {
+    if (args.key === "data") {
       // 收折子项
       if (current.collapsed !== previous.collapsed) {
-        setMindProjectInfo && updateTopic(args.cell.id, { collapsed: current.collapsed }, setMindProjectInfo);
+        setMindProjectInfo &&
+          updateTopic(
+            args.cell.id,
+            { collapsed: current.collapsed },
+            setMindProjectInfo
+          );
       } else {
         updateTopic(args.cell.id, current, (info) => {
           localStorage.setItem("minMapProjectInfo", JSON.stringify(info));
         });
       }
     }
-    if(args.key === "size") {
-      updateTopic(args.cell.id, { 
-        width: current.width, 
-        height: current.height
-       }, (info) => {
-        localStorage.setItem("minMapProjectInfo", JSON.stringify(info));
-      });
+    if (args.key === "size") {
+      updateTopic(
+        args.cell.id,
+        {
+          width: current.width,
+          height: current.height,
+        },
+        (info) => {
+          localStorage.setItem("minMapProjectInfo", JSON.stringify(info));
+        }
+      );
+    }
+  });
+};
+
+/**
+ * 遍历主题树
+ * @param topics
+ * @param callback
+ * @returns
+ */
+const traverseNode = (
+  topics: TopicItem[],
+  callback: (topic: TopicItem, index: number) => void
+): TopicItem[] => {
+  return topics.map((topic, index) => {
+    callback && callback(topic, index);
+    if (topic.children?.length) {
+      topic.children = traverseNode(topic.children, callback);
     }
+    return topic;
+  });
+};
+
+// 判断当前点在主图的位置
+const atNodePosition = (
+  position: { x: number; y: number },
+  x: number,
+  y: number,
+  width: number,
+  height: number
+): positionType => {
+  if (
+    position.x < x + width / 2 &&
+    position.x > x &&
+    position.y < y + height &&
+    position.y > y
+  ) {
+    return positionType.left;
+  }
+  if (
+    position.x < x + width &&
+    position.x > x + width / 2 &&
+    position.y < y + height &&
+    position.y > y
+  ) {
+    return positionType.right;
+  }
+  return positionType.outside;
+};
+
+// 创建一个指示器
+const addIndicator = (
+  x: number,
+  y: number,
+  graph: Graph,
+  targetNode: Node,
+  atPosition: positionType
+) => {
+  if (indicatorEdge && indicatorNode) {
+    graph.removeCells([indicatorEdge, indicatorNode]);
+  }
+  indicatorNode = graph.addNode({
+    shape: "rect",
+    width: 100,
+    height: 26,
+    x,
+    y,
+    attrs: {
+      body: {
+        fill: "#fecb99",
+        stroke: "#fc7e00",
+        strokeWidth: 1,
+        rx: 2,
+        ry: 2,
+        style: {
+          opacity: 0.6
+        }
+      },
+    },
+  });
+
+  indicatorEdge = graph.addEdge({
+    source: {
+      cell: targetNode.id,
+      anchor: {
+        name: atPosition === positionType.left ? "left" : "right",
+        args: {
+          dx: atPosition === positionType.left ? 3 : -3,
+        },
+      },
+    },
+    target: {
+      cell: indicatorNode.id,
+      anchor: {
+        name: atPosition === positionType.left ? "right" : "left",
+        args: {
+          dx: atPosition === positionType.left ? -3 : 3,
+        },
+      },
+    },
+    connector: "normal",
+    attrs: {
+      line: {
+        stroke: "#fc7e00",
+        strokeWidth: 2,
+        sourceMarker: {
+          name: "",
+        },
+        targetMarker: {
+          name: "",
+        },
+        style: {
+          opacity: 0.6
+        }
+      },
+    },
+  });
+};
+
+// 添加指示器
+const setIndicator = (
+  atPosition: positionType,
+  targetNode: Node,
+  graph: Graph
+) => {
+  switch (atPosition) {
+    // 1、左侧位置
+    case positionType.left: {
+      const x = targetNode.position().x - 120;
+      const y = targetNode.position().y + targetNode.size().height / 2 - 13;
+      // 判断是左侧节点还是右侧节点
+      if (targetNode.data?.parentId) {
+        const parentNode = graph.getCellById(targetNode.data.parentId);
+        // 左侧朝向的结构
+        if (
+          parentNode?.isNode() &&
+          targetNode.position().x < parentNode.position().x
+        ) {
+          addIndicator(x, y, graph, targetNode, atPosition);
+        }
+      } else {
+        const mindProjectInfo = getMindMapProjectByLocal();
+        if (mindProjectInfo?.structure === StructureType.left) {
+          addIndicator(x, y, graph, targetNode, atPosition);
+        }
+      }
+      break;
+    }
+    // 2、右侧位置
+    case positionType.right: {
+      const x = targetNode.position().x + targetNode.size().width + 20;
+      const y = targetNode.position().y + targetNode.size().height / 2 - 13;
+      // 判断是左侧节点还是右侧节点
+      if (targetNode.data?.parentId) {
+        const parentNode = graph.getCellById(targetNode.data.parentId);
+        // 右侧朝向的结构
+        if (
+          parentNode?.isNode() &&
+          targetNode.position().x > parentNode.position().x
+        ) {
+          addIndicator(x, y, graph, targetNode, atPosition);
+        }
+      } else {
+        const mindProjectInfo = getMindMapProjectByLocal();
+        if (mindProjectInfo?.structure === StructureType.right) {
+          addIndicator(x, y, graph, targetNode, atPosition);
+        }
+      }
+      break;
+    }
+    // 外部位置
+    case positionType.outside: {
+
+    }
+  }
+};
+
+// 判断目标节点是不是后代节点
+const isDescendantNode = (targetNode: Node, originNode: Node, graph: Graph) => {
+  if (targetNode.data?.parentId === originNode.id) {
+    return true;
+  }
+  let isChild = false; // 是后代节点
+  const findParent = (parentId: string) => {
+    const cell = graph.getCellById(parentId);
+    if (cell?.isNode() && cell.data.parentId) {
+      if (cell.data.parentId === originNode.id) {
+        isChild = true;
+      } else {
+        findParent(cell.data.parentId);
+      }
+    }
+  };
+
+  targetNode.data?.parentId && findParent(targetNode.data.parentId);
+
+  return isChild;
+};
+
+/**
+ * 主题拖拽放置
+ * @param graph
+ * @param setMindProjectInfo
+ */
+export const topicDragHander = (
+  graph: Graph,
+  setMindProjectInfo: (info: MindMapProjectInfo) => void,
+  position: { x: number; y: number },
+  originNode: Node
+) => {
+  if (indicatorEdge) graph.removeCell(indicatorEdge);
+  if (indicatorNode) graph.removeCell(indicatorNode);
+  indicatorEdge = undefined;
+  indicatorNode = undefined;
+  // 1、 获取位置的地方附件是否有节点
+  const nodes = graph.getNodes();
+  nodes.forEach((targetNode) => {
+    // 目标节点是自己、自己的后代、节点不做处理
+    if (
+      targetNode.id === originNode.id ||
+      isDescendantNode(targetNode, originNode, graph)
+    ) {
+      return;
+    }
+    const { x, y } = targetNode.position();
+    const { width, height } = targetNode.size();
+    // 2、 找到是在节点哪个区域内
+    const atPosition = atNodePosition(position, x, y, width, height);
+    // 3、添加插入指示
+    setIndicator(atPosition, targetNode, graph);
+    // 4、 根据位置确定插入位置
+    if ([positionType.left, positionType.right].includes(atPosition)) {
+      insertNode = targetNode;
+    }
+  });
+};
+
+/**
+ * 拖拽完毕,切换主题位置
+ * @param setMindProjectInfo 
+ * @param sourceId 拖拽的节点
+ * @param targetId 放置的节点
+ * @param position 自由节点放置的位置
+ * @returns 
+ */
+const handleSwitchPosition = (
+  setMindProjectInfo: (info: MindMapProjectInfo) => void,
+  sourceId: string,
+  targetId?: string,
+  position?: { x: number; y: number }
+) => {
+  const mindmapProjectInfo = getMindMapProjectByLocal();
+  if(!mindmapProjectInfo) return;
+
+  // 找到要拖拽的节点并删除
+  let source: TopicItem | undefined;
+  mindmapProjectInfo.topics.forEach(topic => {
+    if(topic.id === sourceId) {
+      source = topic;
+    };
+    mindmapProjectInfo.topics = mindmapProjectInfo.topics.filter(item => item.id !== sourceId);
   })
+  const topics = source 
+    ? mindmapProjectInfo.topics 
+    :traverseNode(mindmapProjectInfo.topics, (topic) => {
+    const findItem = topic?.children?.find(item => item.id === sourceId);
+    if(findItem) {
+      source = findItem;
+      topic.children = topic.children?.filter(item => item.id !== sourceId);
+    }
+  });
+  if(!source) return;
+
+  if (targetId) {
+    // 加入到目标节点下
+    mindmapProjectInfo.topics = traverseNode(topics, (topic) => {
+      if(topic.id === targetId) {
+        if(!topic.children) {
+          topic.children = [];
+        }
+        source && topic.children.push({
+          ...source,
+          type: topic.type === TopicType.main ? TopicType.branch : TopicType.sub,
+          parentId: topic.id
+        })
+      }
+    });
+  } else {
+    const id = uuid();
+    // 成为自由节点
+    mindmapProjectInfo.topics.push({
+      ...source,
+      id,
+      children: (source.children || []).map((item) => {
+        item.parentId = id;
+        return item;
+      }),
+      opacity: 100,
+      x: position?.x,
+      y: position?.y,
+      type: TopicType.branch,
+      parentId: null
+    })
+  }
+
+  setMindProjectInfo(mindmapProjectInfo);
+};
+
+/**
+ * 设置影子模式-拖拽时变灰
+ * @param node
+ * @param graph
+ */
+export const setShadowMode = (enable: boolean, node: Node, graph: Graph) => {
+  const data = node.getData();
+  node.setData({
+    opacity: enable ? 60 : 100,
+  });
+
+  if (data?.children?.length) {
+    traverseNode(data.children, (topic) => {
+      const cell = graph.getCellById(topic.id);
+      const edge = graph.getCellById(topic.id + "-edge");
+      if (cell && cell.isNode()) {
+        cell.setData({
+          opacity: enable ? 60 : 100,
+        });
+      }
+      if (edge && edge.isEdge()) {
+        edge.setAttrs({
+          line: {
+            style: {
+              opacity: enable ? 0.6 : 1,
+            },
+          },
+        });
+      }
+    });
+  }
 };

+ 1 - 0
apps/designer/src/models/graphModel.ts

@@ -13,6 +13,7 @@ import '@/components/PageContainer'
 import { handleGraphEvent } from '@/events/flowEvent'
 import { pageMenu, nodeMenu} from '@/utils/contentMenu';
 import { bindKeys } from '@/utils/fastKey'
+
 export default function GraphModel() {
   const [graph, setGraph] = useState<Graph>();
   const [dnd, setDnd] = useState<Dnd>();

+ 49 - 11
apps/designer/src/models/mindMapModel.ts

@@ -16,6 +16,7 @@ import { defaultProject } from "@/config/data";
 import { TopicType } from "@/enum";
 import { isEqual } from "lodash-es";
 import { bindMindmapKeys } from "@/utils/fastKey";
+import { Dnd } from '@antv/x6-plugin-dnd'
 
 type RightToolbarType =
   | "style"
@@ -32,15 +33,22 @@ export default function mindMapModel() {
   // 格式刷样式
   const formatBrushStyle = useRef<cellStyle>();
   const graphRef = useRef<Graph>();
+  const dndRef = useRef<Dnd>();
   const [graph, setGraph] = useState<Graph>();
   const [canRedo, setCanRedo] = useState(false);
   const [canUndo, setCanUndo] = useState(false);
   const [selectedCell, setSelectedCell] = useState<Cell[]>([]);
 
-  const [mindProjectInfo, setMindProjectInfo] =
+  const [mindProjectInfo, setMindProjectInfo] = 
     useLocalStorageState<MindMapProjectInfo>("minMapProjectInfo", {
       listenStorageChange: true,
       // defaultValue: defaultProject,
+      serializer: (val) => {
+        return JSON.stringify(val);
+      },
+      deserializer: (val) => {
+        return JSON.parse(val);
+      }
     });
 
   if (!mindProjectInfo) {
@@ -50,13 +58,14 @@ export default function mindMapModel() {
   useEffect(() => {
     if (!graph || !mindProjectInfo) return;
     renderMindMap(graph, setMindProjectInfo);
+    localStorage.setItem('minMapProjectInfo', JSON.stringify(mindProjectInfo))
   }, [mindProjectInfo, graph]);
 
   const pageSettingRef = useRef<MindMapProjectInfo["pageSetting"]>();
 
   useEffect(() => {
     if (mindProjectInfo?.pageSetting && graph) {
-      if(isEqual(pageSettingRef.current, mindProjectInfo?.pageSetting)) {
+      if (isEqual(pageSettingRef.current, mindProjectInfo?.pageSetting)) {
         return;
       }
       pageSettingRef.current = mindProjectInfo?.pageSetting;
@@ -72,22 +81,22 @@ export default function mindMapModel() {
         });
       }
       // 设置水印
-      if(pageSetting.showWatermark && pageSetting.watermark) {
+      if (pageSetting.showWatermark && pageSetting.watermark) {
         const canvas = document.createElement("canvas");
         canvas.width = pageSetting.watermark.length * 16;
         canvas.height = 100;
         const ctx = canvas.getContext("2d");
-        if(ctx) {
+        if (ctx) {
           ctx.fillStyle = "#aaa";
           ctx.font = "16px Arial";
           ctx.fillText(pageSetting.watermark, 1, 15);
-        };
+        }
         const img = canvas.toDataURL();
 
         graph.drawBackground({
           image: img,
-          repeat: 'watermark'
-        })
+          repeat: "watermark",
+        });
       }
     }
   }, [graph, mindProjectInfo?.pageSetting]);
@@ -100,6 +109,12 @@ export default function mindMapModel() {
       height: document.documentElement.clientHeight,
       autoResize: true,
       async: false,
+      mousewheel: {
+        enabled: true,
+        modifiers: "ctrl",
+        minScale: 0.2,
+        maxScale: 2,
+      },
       connecting: {
         connectionPoint: "anchor",
       },
@@ -110,8 +125,13 @@ export default function mindMapModel() {
             lock: boolean;
             type: TopicType;
             parentId: string;
+            shadow: boolean;
           }>();
+          // 禁止拖拽或锁节点
           if (data?.ignoreDrag || data?.lock) return false;
+          // 影子节点
+          if (data?.shadow) return true;
+          // 自由节点
           return data?.type === TopicType.branch && !data?.parentId;
         },
       },
@@ -119,7 +139,6 @@ export default function mindMapModel() {
 
     instance.use(new Selection());
     instance.use(new Keyboard());
-    instance.use(new History());
     instance.use(new Clipboard());
     instance.use(
       new Scroller({
@@ -135,15 +154,34 @@ export default function mindMapModel() {
         },
       })
     );
-    graphRef.current = instance;
-
+    instance.use(new History({
+      enabled: true,
+      // beforeAddCommand: (e, args) => {
+      //   // @ts-ignore
+      //   return !args.cell.isEdge()
+      // }
+    }));
     instance.on("history:change", () => {
       setCanRedo(instance.canRedo());
       setCanUndo(instance.canUndo());
     });
+    graphRef.current = instance;
+
+    dndRef.current = new Dnd({
+      target: instance,
+      validateNode: () => {
+        return false;
+      },
+    });
 
     // 绑定事件
-    bindMindMapEvents(instance, mindProjectInfo, setMindProjectInfo, setSelectedCell);
+    bindMindMapEvents(
+      instance,
+      mindProjectInfo,
+      setMindProjectInfo,
+      setSelectedCell,
+      dndRef,
+    );
     // 绑定键盘
     bindMindmapKeys(instance, mindProjectInfo, setMindProjectInfo);
 

+ 101 - 32
apps/designer/src/pages/mindmap/components/Footer/index.tsx

@@ -1,10 +1,17 @@
-import { AimOutlined, CompressOutlined, ExpandOutlined, MinusOutlined, PlusOutlined, QuestionCircleFilled } from "@ant-design/icons";
+import {
+  AimOutlined,
+  CompressOutlined,
+  ExpandOutlined,
+  MinusOutlined,
+  PlusOutlined,
+  QuestionCircleFilled,
+} from "@ant-design/icons";
 import { Button, ConfigProvider, Divider, Slider, Tooltip } from "antd";
 import React, { useEffect, useMemo, useRef, useState } from "react";
 import { useFullscreen } from "ahooks";
 import { useModel } from "umi";
-import { MiniMap } from '@antv/x6-plugin-minimap';
-import insertCss from 'insert-css';
+import { MiniMap } from "@antv/x6-plugin-minimap";
+import insertCss from "insert-css";
 import { TopicType } from "@/enum";
 
 insertCss(`
@@ -26,10 +33,10 @@ export default function Footer() {
   const navigationViewRef = useRef(null);
   const [showNavigation, setShowNavigation] = useState(false);
   const [scale, setScale] = useState(100);
-  const { selectedCell, graph} = useModel("mindMapModel");
+  const { selectedCell, graph } = useModel("mindMapModel");
 
   useEffect(() => {
-    if(!graph || !navigationViewRef.current) return;
+    if (!graph || !navigationViewRef.current) return;
 
     graph.use(
       new MiniMap({
@@ -37,69 +44,131 @@ export default function Footer() {
         width: 330,
         height: 210,
         padding: 10,
-      }),
-    )
+      })
+    );
   }, [graph, navigationViewRef.current]);
 
+  graph?.on("scale", (args) => {
+    setScale(args.sx * 100);
+  });
 
   const countInfo = useMemo(() => {
     return {
       selectedNodeCount: selectedCell.filter((cell) => cell.isNode()).length,
-      selectedNodeTextCount: selectedCell.reduce((a, b) => a + b.data?.label?.length || 0 , 0),
+      selectedNodeTextCount: selectedCell.reduce(
+        (a, b) => a + b.data?.label?.length || 0,
+        0
+      ),
       nodeCount: graph?.getNodes().length || 0,
-      textCount: graph?.getNodes().reduce((a, b) => a + b.data?.label?.length || 0 , 0) || 0
+      textCount:
+        graph?.getNodes().reduce((a, b) => a + b.data?.label?.length || 0, 0) ||
+        0,
     };
   }, [selectedCell, graph]);
 
   const handleZoom = (value: number) => {
-    graph?.zoomTo(value / 100)
-  }
+    graph?.zoomTo(value / 100);
+  };
 
   const handleZoomFit = () => {
-    graph?.zoomToFit({ })
-  }
+    graph?.zoomTo(1);
+  };
 
   const handleOnChange = (value: number) => {
     setScale(value);
-    handleZoom(value)
-  }
+    handleZoom(value);
+  };
 
   const handleFocusCenter = () => {
-    const center = graph?.getCells().find(cell => cell.data?.type === TopicType.main);
+    const center = graph
+      ?.getCells()
+      .find((cell) => cell.data?.type === TopicType.main);
     center && graph?.centerCell(center);
-  }
+  };
 
   return (
     <ConfigProvider componentSize="small">
       <div className="absolute w-full h-24px left-0 bottom-0 bg-white flex justify-between items-center px-16px">
         <div className="footer-left"></div>
         <div className="footer-right flex items-center">
-          <div className="mr-8px">字数:{countInfo.selectedNodeCount ? `${countInfo.selectedNodeTextCount}/` : ''}{countInfo.textCount}</div>
-          <div>主题数:{countInfo.selectedNodeCount ? `${countInfo.selectedNodeCount}/` : ''}{countInfo.nodeCount}</div>
+          <div className="mr-8px">
+            字数:
+            {countInfo.selectedNodeCount
+              ? `${countInfo.selectedNodeTextCount}/`
+              : ""}
+            {countInfo.textCount}
+          </div>
+          <div>
+            主题数:
+            {countInfo.selectedNodeCount
+              ? `${countInfo.selectedNodeCount}/`
+              : ""}
+            {countInfo.nodeCount}
+          </div>
           <Divider type="vertical" />
           <Tooltip title="模板">
             <Button type="text" icon={<i className="iconfont icon-buju" />} />
           </Tooltip>
           <Tooltip title="定位到中心主题">
-            <Button type="text" icon={<AimOutlined />} onClick={handleFocusCenter}/>
+            <Button
+              type="text"
+              icon={<AimOutlined />}
+              onClick={handleFocusCenter}
+            />
           </Tooltip>
           <Tooltip title={showNavigation ? "关闭视图导航" : "显示视图导航"}>
-            <Button type="text" icon={<i className="iconfont icon-map" />} onClick={() => setShowNavigation(!showNavigation)}/>
+            <Button
+              type="text"
+              icon={<i className="iconfont icon-map" />}
+              onClick={() => setShowNavigation(!showNavigation)}
+            />
           </Tooltip>
-          <div className="navigation-view" style={{display: showNavigation ? 'block' : 'none'}} ref={navigationViewRef}></div>
-          <Button type="text" icon={<MinusOutlined/>} onClick={() => handleZoom( scale - 2)}/>
-          <Slider min={20} max={200} className="w-120px m-0 mx-8px" tooltip={{formatter: (val) => `${val}%`}} value={scale} onChange={handleOnChange}/>
-          <Button type="text" icon={<PlusOutlined/>} onClick={() => handleZoom( scale + 2)}/>
+          <div
+            className="navigation-view"
+            style={{ display: showNavigation ? "block" : "none" }}
+            ref={navigationViewRef}
+          ></div>
+          <Button
+            type="text"
+            icon={<MinusOutlined />}
+            onClick={() => handleZoom(scale - 2)}
+          />
+          <Slider
+            min={20}
+            max={200}
+            className="w-120px m-0 mx-8px"
+            tooltip={{ formatter: (val) => `${val}%` }}
+            value={scale}
+            onChange={handleOnChange}
+          />
+          <Button
+            type="text"
+            icon={<PlusOutlined />}
+            onClick={() => handleZoom(scale + 2)}
+          />
           <Tooltip title="重置缩放">
-            <div className="cursor-pointer mx-8px w-40px" onClick={handleZoomFit}>{scale}%</div>
+            <div
+              className="cursor-pointer mx-8px w-40px"
+              onClick={handleZoomFit}
+            >
+              {scale}%
+            </div>
           </Tooltip>
-          {
-            isFullscreen 
-            ? <Button type="text" icon={<CompressOutlined/>} onClick={toggleFullscreen}/>
-            : <Button type="text" icon={<ExpandOutlined/>} onClick={toggleFullscreen}/>
-          }
+          {isFullscreen ? (
+            <Button
+              type="text"
+              icon={<CompressOutlined />}
+              onClick={toggleFullscreen}
+            />
+          ) : (
+            <Button
+              type="text"
+              icon={<ExpandOutlined />}
+              onClick={toggleFullscreen}
+            />
+          )}
           <Divider type="vertical" />
-          <Button type="text" icon={<QuestionCircleFilled/>} />
+          <Button type="text" icon={<QuestionCircleFilled />} />
         </div>
       </div>
     </ConfigProvider>

+ 3 - 1
apps/designer/src/pages/mindmap/components/HeaderToolbar/index.tsx

@@ -5,6 +5,7 @@ import logo from "@/assets/logo.png";
 import { useModel, Icon } from "umi";
 import { addTopic } from "../../mindMap";
 import { TopicType } from "@/enum";
+import { selectTopic } from "@/utils/mindmapHander";
 
 export default function index() {
   const {
@@ -50,7 +51,8 @@ export default function index() {
       setMindProjectInfo,
       currentNode[0]
     );
-    graph?.resetSelection(topic?.id);
+
+    graph && selectTopic(graph, topic);
   };
 
   // 添加关联线

+ 1 - 0
apps/designer/src/pages/mindmap/index.less

@@ -13,6 +13,7 @@
   padding: 2px;
   border: 1.5px solid #239edd;
   border-radius: 0;
+  border: none;
 }
 
 .x6-widget-transform{

+ 5 - 4
apps/designer/src/utils/color.ts

@@ -6,12 +6,13 @@
 export const getRandomColor = (baseColors: string[], alpha?: number) => {
   const randomIndex = Math.floor(Math.random() * baseColors.length);
   const randomAlpha = Math.random();
-  alpha = alpha || randomAlpha < 0.5 ? 0.5 : randomAlpha;
+  alpha = alpha || randomAlpha;
   const r = parseInt(baseColors[randomIndex].slice(1, 3), 16);
-  const g = parseInt(baseColors[randomIndex].slice(3, 5), 16) * alpha;
-  const b = parseInt(baseColors[randomIndex].slice(5, 7), 16) * alpha;
+  const g = parseInt(baseColors[randomIndex].slice(3, 5), 16);
+  const b = parseInt(baseColors[randomIndex].slice(5, 7), 16);
+  const bColor = Number((b * alpha).toFixed(0)).toString(16);
   // 返回hex颜色
-  return `#${r.toString(16)}${Number(g.toFixed(0)).toString(16)}${Number(b.toFixed(0)).toString(16)}`
+  return `#${r.toString(16)}${g.toString(16)}${bColor.length < 2 ? '0' + bColor : bColor}`
 }
 
 /**

+ 2 - 11
apps/designer/src/utils/fastKey.tsx

@@ -306,20 +306,11 @@ export const bindMindmapKeys = (
   });
 
   // Tab 增加子主题
-  graph.bindKey("tab", (e: KeyboardEvent) => {
+  graph.bindKey(["tab", "insert"], (e: KeyboardEvent) => {
     e.preventDefault();
     const node = graph.getSelectedCells().find((cell) => cell.isNode());
     if (node) {
-      node.isNode() && addChildrenTopic(node, setMindProjectInfo);
-    }
-  });
-
-  // insert 增加子主题
-  graph.bindKey("insert", (e: KeyboardEvent) => {
-    e.preventDefault();
-    const node = graph.getSelectedCells().find((cell) => cell.isNode());
-    if (node) {
-      node.isNode() && addChildrenTopic(node, setMindProjectInfo);
+      node.isNode() && addChildrenTopic(node, setMindProjectInfo, graph);
     }
   });
 

+ 35 - 17
apps/designer/src/utils/mindmapHander.tsx

@@ -6,6 +6,16 @@ import { message } from "antd";
 import { cloneDeep } from "lodash-es";
 import { uuid } from ".";
 
+export const selectTopic = (graph: Graph, topic?: TopicItem) => {
+  if (topic?.id) {
+    setTimeout(() => {
+      graph.resetSelection(topic.id);
+      const node = graph.getCellById(topic?.id);
+      node?.isNode() && graph.createTransformWidget(node);
+    }, 100);
+  }
+};
+
 /**
  * 添加同级主题
  * @param node
@@ -24,7 +34,11 @@ export const addPeerTopic = (
       : graph.getCellById(node.data.parentId);
   const type =
     node.data.type === TopicType.main ? TopicType.branch : node.data.type;
-  parentNode?.isNode() && addTopic(type, setMindProjectInfo, parentNode);
+  if (parentNode?.isNode()) {
+    const topic = addTopic(type, setMindProjectInfo, parentNode);
+
+    selectTopic(graph, topic);
+  }
 };
 
 /**
@@ -34,13 +48,15 @@ export const addPeerTopic = (
  */
 export const addChildrenTopic = (
   node: Node,
-  setMindProjectInfo?: (info: MindMapProjectInfo) => void
+  setMindProjectInfo?: (info: MindMapProjectInfo) => void,
+  graph?: Graph
 ) => {
   if (!setMindProjectInfo) return;
 
   const type =
     node.data?.type === TopicType.main ? TopicType.branch : TopicType.sub;
-  addTopic(type, setMindProjectInfo, node);
+  const topic = addTopic(type, setMindProjectInfo, node);
+  graph && selectTopic(graph, topic);
 };
 
 /**
@@ -130,27 +146,29 @@ export const handleMindmapPaste = (
             const text = event.target?.result as string;
             // 内部复制方法
             if (text === "  ") {
-              const nodes = localStorage.getItem('mindmap-copy-data');
+              const nodes = localStorage.getItem("mindmap-copy-data");
               if (nodes) {
                 JSON.parse(nodes)?.forEach((node: Node) => {
                   const data = node.data;
                   // 修改新的数据嵌套
                   data.id = uuid();
                   data.parentId = currentNode.id;
-                  if(data.children?.length) {
+                  if (data.children?.length) {
                     data.children = traverseCopyData(data.children, data.id);
                   }
 
-                  addTopic(currentNode.data?.type === TopicType.main
-                    ? TopicType.branch
-                    : TopicType.sub,
-                  setMindProjectInfo,
-                  currentNode,
-                  { ...data })
+                  addTopic(
+                    currentNode.data?.type === TopicType.main
+                      ? TopicType.branch
+                      : TopicType.sub,
+                    setMindProjectInfo,
+                    currentNode,
+                    { ...data }
+                  );
                 });
               }
             } else {
-              addTopic(
+              const topic = addTopic(
                 currentNode.data?.type === TopicType.main
                   ? TopicType.branch
                   : TopicType.sub,
@@ -158,6 +176,7 @@ export const handleMindmapPaste = (
                 currentNode,
                 { label: text }
               );
+              selectTopic(graph, topic);
             }
           };
         });
@@ -166,14 +185,13 @@ export const handleMindmapPaste = (
   });
 };
 
-
 const traverseCopyData = (list: TopicItem[], parentId: string): TopicItem[] => {
-  return list.map(item => {
+  return list.map((item) => {
     item.id = uuid();
     item.parentId = parentId;
-    if( item.children?.length) {
+    if (item.children?.length) {
       item.children = traverseCopyData(item.children, item.id);
     }
     return item;
-  })
-}
+  });
+};