Quellcode durchsuchen

feat: 添加右键菜单

liaojiaxing vor 6 Monaten
Ursprung
Commit
58c4b486ef

+ 1 - 1
apps/designer/.umirc.ts

@@ -6,7 +6,7 @@ export default defineConfig({
     '/favicon.ico'
   ],
   styles: [
-    '//at.alicdn.com/t/c/font_4676747_tigpugzo8xc.css'
+    '//at.alicdn.com/t/c/font_4676747_2fcjmdv7h6f.css'
   ],
   metas: [
     { name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' }

+ 42 - 4
apps/designer/src/components/mindMap/Link.tsx

@@ -1,17 +1,55 @@
 import React from "react";
-import { Popover } from "antd";
+import { Button, Divider, Popover, Tooltip } from "antd";
 export default function Link({
   link,
+  onDelete,
+  onEdit
 }: {
   link: { title: string; value: string };
+  onEdit: () => void;
+  onDelete: () => void;
 }) {
-  const [isEdit, setIsEdit] = React.useState(false);
-  
+  const [copyed, setCopyed] = React.useState(false);
+  const handleOpenUrl = () => {
+    window.open(link.value, "_blank");
+  };
+
+  const handleCopy = () => {
+    navigator.clipboard.writeText(link.value);
+    setCopyed(true);
+    setTimeout(() => {
+      setCopyed(false);
+    }, 1000);
+  };
   return (
     <Popover
       content={
         <div>
-          链接:{link.title} <a href={link.value}>打开链接</a>
+          {link.title || link.value} <Divider type="vertical" />
+          <Button type="text" onClick={handleOpenUrl}>
+            打开链接
+          </Button>
+          <Tooltip title={copyed ? "复制成功" : "复制"}>
+            <Button
+              type="text"
+              icon={<i className="iconfont icon-fuzhi" />}
+              onClick={handleCopy}
+            />
+          </Tooltip>
+          <Tooltip title="编辑">
+            <Button
+              type="text"
+              icon={<i className="iconfont icon-bianji-" />}
+              onClick={onEdit}
+            />
+          </Tooltip>
+          <Tooltip title="删除">
+            <Button
+              type="text"
+              icon={<i className="iconfont icon-shanchu" />}
+              onClick={onDelete}
+            />
+          </Tooltip>
         </div>
       }
     >

+ 72 - 0
apps/designer/src/components/mindMap/LinkForm.tsx

@@ -0,0 +1,72 @@
+import { Button, Form, Input } from "antd";
+import { FormInstance } from "antd/lib";
+import React, { useRef, useState } from "react";
+
+export default function LinkForm({
+  title,
+  value,
+  onCancel,
+  onConfirm,
+}: {
+  title?: string;
+  value?: string;
+  onCancel: () => void;
+  onConfirm: (data: { title?: string; value?: string }) => void;
+}) {
+  const formRef = useRef<FormInstance>(null);
+  const [formModel, setFormModel] = useState({
+    title,
+    value,
+  });
+
+  const handleSetValue = (val: string) => {
+    if(!val.includes('http://') || !val.includes('http://')) {
+      // 自动补齐http://
+      val = 'http://' + val;
+    }
+    setFormModel((state) => ({ ...state, value: val }));
+  }
+
+  const handleSubmit = async () => {
+    await formRef.current?.validateFields();
+    onConfirm(formModel);
+  };
+
+  return (
+    <Form size="small" ref={formRef} colon={false} requiredMark={false}>
+      <Form.Item
+        name="value"
+        label="链接"
+        rules={[
+          { required: true, message: "请输入链接" },
+          { type: "url", message: "请输入正确的链接地址" },
+        ]}
+      >
+        <Input
+          placeholder="输入链接地址"
+          value={formModel.value}
+          onChange={(e) =>
+            handleSetValue(e.target.value)
+          }
+        />
+      </Form.Item>
+      <Form.Item name="title" label="标题">
+        <Input
+          placeholder="输入文本(非必填)"
+          value={formModel.title}
+          onChange={(e) =>
+            setFormModel((state) => ({ ...state, title: e.target.value }))
+          }
+        />
+      </Form.Item>
+      <Form.Item>
+        <div className="flex justify-end">
+          <Button onClick={onCancel}>取消</Button>
+          <Button type="primary" className="ml-8px" onClick={handleSubmit}>
+            确定
+          </Button>
+        </div>
+      </Form.Item>
+    </Form>
+  );
+}

+ 201 - 96
apps/designer/src/components/mindMap/Topic.tsx

@@ -1,5 +1,5 @@
 import { register } from "@antv/x6-react-shape";
-import { EventArgs, Graph, Node, Path } from "@antv/x6";
+import { EventArgs, Graph, Node } from "@antv/x6";
 import { topicData } from "@/config/data";
 import { useSizeHook, useShapeProps } from "@/hooks";
 import CustomInput from "../CustomInput";
@@ -13,6 +13,8 @@ import ExtraModule from "./ExtraModule";
 import CustomTag from "@/components/CustomTag";
 import { TopicItem } from "@/types";
 import { selectTopic } from "@/utils/mindmapHander";
+import { Tooltip, Popover } from "antd";
+import LinkForm from "./LinkForm";
 const component = ({ node, graph }: { node: Node; graph: Graph }) => {
   const {
     fill,
@@ -30,11 +32,15 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
     children,
     type,
     collapsed,
+    fixedWidth,
+    linkTopicId,
   } = node.getData();
   const { size, ref } = useSizeHook();
   const { fillContent, strokeColor, strokeWidth, strokeDasharray } =
     useShapeProps(fill, size, stroke);
   const [selected, setSelected] = useState(false);
+  const [showPopover, setShowPopover] = useState(false);
+  const [popoverContent, setPopoverContent] = useState<React.ReactNode>();
   const handleSelect = (_args: EventArgs["node:selected"]) => {
     const cells = graph.getSelectedCells();
     setSelected(!!cells.find((item) => item.id === node.id));
@@ -45,21 +51,79 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
   const tagRef = useRef<HTMLDivElement>(null);
   const remarkRef = useRef<HTMLDivElement>(null);
 
+  const padding = useMemo(() => {
+    switch (type) {
+      case TopicType.main:
+        return "14px 28px";
+      case TopicType.branch:
+        return "8px 16px";
+      default:
+        return "4px 6px";
+    }
+  }, [type]);
+
+  const showHerfConfig = () => {
+    setShowPopover(true);
+    setPopoverContent(
+      <LinkForm
+        title={herf?.title}
+        value={herf?.value}
+        onCancel={() => setShowPopover(false)}
+        onConfirm={(data) => {
+          setShowPopover(false);
+          node.setData({ herf: data });
+        }}
+      />
+    );
+  };
+
+  // @ts-ignore 绑定一个外部调用方法
+  node.extendAttr = {
+    showHerfConfig,
+  };
+
   useEffect(() => {
     // graph.createTransformWidget(node);
     // graph.select(node);
     graph.on("node:selected", handleSelect);
     graph.on("node:unselected", handleSelect);
+    graph.on("node:resized", handleResize);
     return () => {
       graph.off("node:selected", handleSelect);
       graph.off("node:unselected", handleSelect);
+      graph.off("node:resized", handleResize);
     };
   }, []);
 
   useEffect(() => {
-    // 动态计算出所需的宽高
-    let width = extraModules?.width || 0;
-  }, [extraModules, label, icons, tags, remarkRef]);
+    if (size.height && size.width) {
+      const w = Math.max(
+        size.width,
+        titleRef.current?.clientWidth || 0,
+        tagRef.current?.clientWidth || 0,
+        extraModuleRef.current?.clientWidth || 0
+      );
+      const h = Math.max(
+        size.height,
+        titleRef.current?.clientHeight || 0,
+        tagRef.current?.clientHeight || 0,
+        extraModuleRef.current?.clientHeight || 0
+      );
+      if (h !== node.size().height || w !== node.size().width) {
+        node.setData({
+          width: w,
+        });
+        node.size(w, h);
+      }
+    }
+  }, [size, titleRef, tagRef, extraModuleRef]);
+
+  const handleResize = () => {
+    node.setData({
+      fixedWidth: true,
+      width: node.size().width,
+    });
+  };
 
   const childrenCount = useMemo(() => {
     let count = 0;
@@ -76,6 +140,12 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
     return count;
   }, [children]);
 
+  const handleShowRemark = () => {
+    selectTopic(graph, node.data);
+    // @ts-ignore
+    graph.extendAttr.setRightToolbarActive("remark");
+  };
+
   const handleAddBranch = () => {
     const data = node.getData();
     let topic;
@@ -84,7 +154,7 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
     } else {
       topic = addTopic(TopicType.sub, setMindProjectInfo, node);
     }
-    
+
     selectTopic(graph, topic);
   };
 
@@ -94,6 +164,14 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
     });
   };
 
+  const handleDeleteHeft = () => {
+    node.setData({
+      herf: undefined,
+    }, {
+      deep: false
+    });
+  };
+
   return (
     <>
       {selected && (
@@ -108,82 +186,108 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
           }}
         />
       )}
-      <div
-        className="relative text-0 w-full h-full px-4px"
-        ref={ref}
-        style={{
-          opacity: opacity / 100,
-          border: `solid ${strokeWidth}px ${strokeColor}`,
-          background: fillContent,
-          borderRadius: borderSize,
-        }}
-        onMouseOver={() => !collapsed && setShowCollapsePoint(true)}
-        onMouseLeave={() => !collapsed && setShowCollapsePoint(false)}
-      >
-        <div className="w-full h-full flex flex-col">
+      <Popover open={showPopover} content={popoverContent}>
+        <div
+          className="content relative text-0"
+          ref={ref}
+          style={{
+            minWidth: "100%",
+            // minHeight: "100%",
+            opacity: opacity / 100,
+            border: `solid ${strokeWidth}px ${strokeColor}`,
+            background: fillContent,
+            borderRadius: borderSize,
+            padding,
+          }}
+          onMouseOver={() => !collapsed && setShowCollapsePoint(true)}
+          onMouseLeave={() => !collapsed && setShowCollapsePoint(false)}
+        >
+          {/* 扩展模块 */}
           {extraModules && (
-            <div ref={extraModuleRef}>
+            <div className="extra" ref={extraModuleRef}>
               <ExtraModule node={node} extraModules={extraModules} />
             </div>
           )}
 
-          <div className="flex-1 flex">
+          {/* 图标、标题、链接等 */}
+          <div
+            className="flex-1 flex items-center justify-center"
+            ref={titleRef}
+          >
+            <div className="flex items-center text-20px">
+              {icons?.map((icon: string) => {
+                return (
+                  <svg key={icon} className="icon mr-6px" aria-hidden="true">
+                    <use xlinkHref={`#${icon}`}></use>
+                  </svg>
+                );
+              })}
+            </div>
+            <CustomInput
+              value={label}
+              node={node}
+              styles={{
+                ...text,
+                position: "relative",
+                padding: "0",
+              }}
+              txtStyle={{
+                position: "relative",
+                flexShrink: 0,
+                transform: "translateY(50%)",
+                width: "auto",
+                ...(fixedWidth ? { maxWidth: "100%" } : {}),
+              }}
+            />
             <div
-              className="flex-1 flex flex-col justify-center"
-              // style={{ height: getSize.contentHeight }}
+              className="flex items-center color-#fff m-l-8px"
+              ref={remarkRef}
             >
-              <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">
-                      <use xlinkHref={`#${icon}`}></use>
-                    </svg>
-                  );
-                })}
-                <CustomInput
-                  value={label}
-                  node={node}
-                  styles={{
-                    ...text,
-                    position: "relative",
-                  }}
-                  txtStyle={{
-                    position: "relative",
-                    flex: 1,
-                  }}
+              {herf && (
+                <Link
+                  link={herf}
+                  onEdit={showHerfConfig}
+                  onDelete={handleDeleteHeft}
                 />
-              </div>
-
-              <div className="flex" ref={tagRef}>
-                {tags?.map((item: { name: string; color: string }) => {
-                  return (
-                    <CustomTag
-                      className="text-14px"
-                      key={item.name}
-                      title={item.name}
-                      color={item.color}
-                    >
-                      {item.name}
-                    </CustomTag>
-                  );
-                })}
-              </div>
-            </div>
-
-            <div ref={remarkRef}>
-              {herf && <Link link={herf} />}
-              {remark && <i className="iconfont icon-pinglun1" />}
+              )}
+              {remark && (
+                <Tooltip color="yellow" title={remark}>
+                  <i
+                    className="iconfont icon-pinglun1 cursor-pointer ml-4px"
+                    onClick={handleShowRemark}
+                  />
+                </Tooltip>
+              )}
+              {linkTopicId && (
+                <Tooltip color="yellow" title={remark}>
+                  <i
+                    className="iconfont icon-liangdianlianjie-01 cursor-pointer ml-4px"
+                    onClick={handleShowRemark}
+                  />
+                </Tooltip>
+              )}
             </div>
           </div>
-        </div>
+          {/* 标签 */}
+          <div className="flex items-center justify-center" ref={titleRef}>
+            {tags?.map((item: { name: string; color: string }) => {
+              return (
+                <CustomTag
+                  className="text-14px"
+                  key={item.name}
+                  title={item.name}
+                  color={item.color}
+                >
+                  {item.name}
+                </CustomTag>
+              );
+            })}
+          </div>
 
-        {/* 添加主题按钮 */}
-        {selected && !children?.length && (
-          <div
-            className={`
+          {/* 添加主题按钮 */}
+          {selected && !children?.length && (
+            <div
+              className={`
               absolute 
               w-20px 
               h-20px 
@@ -200,22 +304,22 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
               color-#9aa5b8 
               hover:bg-#067bef 
               hover:color-white`}
-            onClick={handleAddBranch}
-          >
-            <PlusOutlined />
-          </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
-            className={`
+              onClick={handleAddBranch}
+            >
+              <PlusOutlined />
+            </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
+              className={`
               absolute
               rounded-full
               bg-white
@@ -229,16 +333,17 @@ const component = ({ node, graph }: { node: Node; graph: Graph }) => {
               justify-center
               ${collapsed ? "w-16px h-16px right--20px" : "w-10px h-10px right--15px"}
              `}
-            onClick={handleToggleCollapse}
-            style={{
-              border: `1px solid ${fill.color1}`,
-              color: fill.color1,
-            }}
-          >
-            {collapsed && childrenCount}
-          </div>
-        )}
-      </div>
+              onClick={handleToggleCollapse}
+              style={{
+                border: `1px solid ${fill.color1}`,
+                color: fill.color1,
+              }}
+            >
+              {collapsed && childrenCount}
+            </div>
+          )}
+        </div>
+      </Popover>
     </>
   );
 };

+ 1 - 0
apps/designer/src/config/data.ts

@@ -133,6 +133,7 @@ export const topicData = {
   borderSize: BorderSize.medium,
   fixedWidth: false,
   collapsed: false,
+  linkTopicId: undefined,
   edge: {
     color: "#323232",
     width: 3,

+ 87 - 31
apps/designer/src/events/mindMapEvent.ts

@@ -5,10 +5,12 @@ import {
   getMindMapProjectByLocal,
   updateTopic,
 } from "@/pages/mindmap/mindMap";
-import { MindMapProjectInfo, TopicItem } from "@/types";
+import { cellStyle, MindMapProjectInfo, TopicItem } from "@/types";
 import { Dnd } from "@antv/x6-plugin-dnd";
 import { selectTopic } from "@/utils/mindmapHander";
 import { uuid } from "@/utils";
+import { getTheme } from "@/pages/mindmap/theme";
+import { traverseNode } from "@/utils/mindmapHander";
 
 enum positionType {
   left = "left",
@@ -105,7 +107,13 @@ export const bindMindMapEvents = (
     if (indicatorNode && insertNode) {
       graph.removeCell(args.node.id + "-edge");
       setMindProjectInfo &&
-        handleSwitchPosition(setMindProjectInfo, args.node.id, insertNode.id);
+        handleSwitchPosition(
+          setMindProjectInfo,
+          args.node.id,
+          insertNode.id,
+          undefined,
+          graph
+        );
     }
 
     // 成为自由节点
@@ -116,10 +124,16 @@ export const bindMindMapEvents = (
       canBeFreeNode(args.x, args.y, args.node)
     ) {
       setMindProjectInfo &&
-        handleSwitchPosition(setMindProjectInfo, args.node.id, undefined, {
-          x: args.x,
-          y: args.y,
-        });
+        handleSwitchPosition(
+          setMindProjectInfo,
+          args.node.id,
+          undefined,
+          {
+            x: args.x,
+            y: args.y,
+          },
+          graph
+        );
     }
 
     currentShadowNode && setShadowMode(false, args.node, graph);
@@ -175,7 +189,6 @@ export const bindMindMapEvents = (
    * 节点数据更改
    */
   graph.on("node:change:*", (args) => {
-    // console.log("node:change:*", args);
     const { current, previous } = args;
     if (args.key === "data") {
       // 收折子项 setMindProjectInfo更新会重新渲染
@@ -207,7 +220,8 @@ export const bindMindMapEvents = (
           args.cell.id,
           { ...args.cell.data, x: current.x, y: current.y },
           (info) => {
-            localStorage.setItem("minMapProjectInfo", JSON.stringify(info));
+            // localStorage.setItem("minMapProjectInfo", JSON.stringify(info));
+            setMindProjectInfo && setMindProjectInfo(info)
           }
         );
       }
@@ -269,25 +283,6 @@ const canBeFreeNode = (x: number, y: number, node: Node): boolean => {
   return Math.abs(x - moveStart.x) > 50 || Math.abs(y - moveStart.y) > 50;
 };
 
-/**
- * 遍历主题树
- * @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 },
@@ -512,13 +507,14 @@ const handleSwitchPosition = (
   setMindProjectInfo: (info: MindMapProjectInfo) => void,
   sourceId: string,
   targetId?: string,
-  position?: { x: number; y: number }
+  position?: { x: number; y: number },
+  graph?: Graph
 ) => {
   const mindmapProjectInfo = getMindMapProjectByLocal();
   if (!mindmapProjectInfo) return;
 
   // 找到要拖拽的节点并删除
-  let source: TopicItem | undefined;
+  let source: (TopicItem & cellStyle) | undefined;
   mindmapProjectInfo.topics.forEach((topic) => {
     if (topic.id === sourceId) {
       source = topic;
@@ -540,6 +536,68 @@ const handleSwitchPosition = (
       });
   if (!source) return;
 
+  // 处理节点样式
+  const targetNode = targetId ? graph?.getCellById(targetId) : undefined;
+  const theme = getTheme(
+    mindmapProjectInfo.theme,
+    targetNode?.isNode() && targetNode.data.type !== TopicType.main
+      ? targetNode
+      : undefined
+  );
+
+  source.type =
+    targetNode?.data?.type === TopicType.main
+      ? TopicType.branch
+      : TopicType.sub;
+
+  const themeObj = targetId ? theme[source.type] : theme[TopicType.branch];
+  source.fill = {
+    ...source.fill,
+    ...themeObj.fill,
+  };
+  source.text = {
+    ...source.text,
+    ...themeObj.text,
+  };
+  source.stroke = {
+    ...source.stroke,
+    ...themeObj.stroke,
+  };
+  source.edge = {
+    ...source.edge,
+    ...themeObj.edge,
+  };
+
+  // 后代节点样式
+  if (source.children)
+    source.children = traverseNode(source.children, (topic) => {
+      const theme = getTheme(
+        mindmapProjectInfo.theme,
+        graph?.createNode({
+          data: {
+            ...source,
+          },
+        })
+      );
+      topic.type = TopicType.sub;
+      topic.fill = {
+        ...topic.fill,
+        ...theme[topic.type].fill,
+      };
+      topic.text = {
+        ...topic.text,
+        ...theme[topic.type].text,
+      };
+      topic.stroke = {
+        ...topic.stroke,
+        ...theme[topic.type].stroke,
+      };
+      topic.edge = {
+        ...topic.edge,
+        ...theme[topic.type].edge,
+      };
+    });
+
   if (targetId) {
     // 加入到目标节点下
     mindmapProjectInfo.topics = traverseNode(topics, (topic) => {
@@ -550,8 +608,6 @@ const handleSwitchPosition = (
         source &&
           topic.children.push({
             ...source,
-            type:
-              topic.type === TopicType.main ? TopicType.branch : TopicType.sub,
             parentId: topic.id,
           });
       }

+ 1 - 1
apps/designer/src/hooks/useSizeHook.ts

@@ -2,7 +2,7 @@ import { useRef } from "react";
 import { useSize } from 'ahooks';
 export function useSizeHook() {
   const ref = useRef<HTMLDivElement>(null);
-  const size = useSize(ref) || { width: 70, height: 70};
+  const size = useSize(ref) || { width: 0, height: 0};
 
   return {
     size,

+ 13 - 56
apps/designer/src/models/mindMapModel.ts

@@ -16,18 +16,11 @@ 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"
-  | "structure"
-  | "theme"
-  | "icon"
-  | "image"
-  | "tag";
+import { handleCreateCorrelationEdge } from "@/utils/mindmapHander";
+import { Dnd } from '@antv/x6-plugin-dnd';
 export default function mindMapModel() {
   const [rightToobarActive, setRightToolbarActive] =
-    useState<RightToolbarType>();
+    useState<string>();
   // 格式刷启用
   const [enableFormatBrush, setEnableFormatBrush] = useState(false);
   // 格式刷样式
@@ -38,6 +31,7 @@ export default function mindMapModel() {
   const [canRedo, setCanRedo] = useState(false);
   const [canUndo, setCanUndo] = useState(false);
   const [selectedCell, setSelectedCell] = useState<Cell[]>([]);
+  const correlationEdgeRef = useRef<Edge>();
 
   const [mindProjectInfo, setMindProjectInfo] = 
     useLocalStorageState<MindMapProjectInfo>("minMapProjectInfo", {
@@ -184,6 +178,13 @@ export default function mindMapModel() {
     );
     // 绑定键盘
     bindMindmapKeys(instance, mindProjectInfo, setMindProjectInfo);
+    // 绑定实例方法
+    // @ts-ignore
+    instance.extendAttr = {
+      setRightToolbarActive,
+      correlationEdgeRef,
+      setMindProjectInfo,
+    }
 
     setGraph(instance);
     renderMindMap(instance, setMindProjectInfo);
@@ -241,56 +242,12 @@ export default function mindMapModel() {
   };
 
   // 设置右侧工具激活项
-  const rightToolbarActive = (type: RightToolbarType) => {
+  const rightToolbarActive = (type: string) => {
     setRightToolbarActive(rightToobarActive === type ? undefined : type);
   };
 
-
-  // 关联线
-  const handleCorrelation = (e: MouseEvent) => {
-    if(correlationEdgeRef.current) {
-      const point = graph?.clientToLocal(e.x, e.y);
-      point && correlationEdgeRef.current?.setTarget(point);
-    }
-  };
-  const correlationEdgeRef = useRef<Edge>();
   const setCorrelationEdgeInfo = (sourceNode?: Node) => {
-    if(sourceNode) {
-      correlationEdgeRef.current = graph?.addEdge({
-        source: { cell: sourceNode},
-        target: {
-          x: sourceNode.position().x,
-          y: sourceNode.position().y,
-        },
-        connector: "normal",
-        attrs: {
-          line: {
-            stroke: "#71cb2d",
-            strokeWidth: 2,
-            sourceMarker: {
-              name: "",
-            },
-            targetMarker: {
-              name: "",
-            },
-            style: {
-              opacity: 0.6,
-            },
-          },
-        },
-        data: {
-          ignoreDrag: true
-        },
-        zIndex: 0
-      });
-      document.body.addEventListener("mousemove", handleCorrelation);
-    } else {
-      document.body.removeEventListener("mousemove", handleCorrelation);
-      if(correlationEdgeRef.current) {
-        graph?.removeCell(correlationEdgeRef.current);
-        correlationEdgeRef.current = undefined;
-      }
-    }
+    graph && handleCreateCorrelationEdge(graph, correlationEdgeRef, sourceNode)
   }
 
   const handleAddCorrelation = (source: Cell, target: Cell) => {

+ 5 - 3
apps/designer/src/pages/home/index.tsx

@@ -44,6 +44,7 @@ const basicGraph = [
     subtitle: "图形化表单方式",
     color: "#dfecff",
     icon: <Icon icon="local:flow"/>,
+    path: "/flow",
   },
   {
     id: "2",
@@ -51,6 +52,7 @@ const basicGraph = [
     subtitle: "结构化表单方式",
     color: "#dff4ea",
     icon: <Icon icon="local:mind"/>,
+    path: "/mindmap",
   },
 ];
 
@@ -89,7 +91,7 @@ const appList = [
 
 const handleMenuClick = (item: any) => {
   console.log("click", item);
-  history.push('/flow');
+  history.push(item.path);
 };
 
 const renderBasicItem = (props: {
@@ -111,9 +113,9 @@ const renderBasicItem = (props: {
         align-items: center;
         cursor: pointer;
         margin-top: 8px;
-        background: ${props.color};
+        background: ${props.color + "70"};
         &:hover {
-          background: ${props.color + "70"};
+          background: ${props.color};
         }
       `}
       onClick={() => handleMenuClick(props)}

+ 1 - 0
apps/designer/src/pages/mindmap/components/Config/NodeStyle.tsx

@@ -107,6 +107,7 @@ export default function GraphStyle() {
             setFormModel((state) => {
               return {
                 ...state,
+                fixedWidth: args.current.fixedWidth,
                 type: args.current.type,
                 text: args.current.text,
                 fill: args.current.fill,

+ 47 - 0
apps/designer/src/pages/mindmap/components/Config/Remark.tsx

@@ -0,0 +1,47 @@
+import React, { useEffect } from 'react'
+import { Tabs, Input } from 'antd'
+import { useModel } from 'umi'
+
+export default function Remark() {
+  const { selectedCell } = useModel("mindMapModel");
+  const [value, setValue] = React.useState("");
+  const [disabled, setDisabled] = React.useState(true);
+
+  useEffect(() => {
+    if(selectedCell.length === 1 && selectedCell[0].isNode()) {
+      setValue(selectedCell[0].data.remark || "");
+      setDisabled(false);
+    } else {
+      setDisabled(true);
+    }
+  }, [selectedCell]);
+
+  const handleInput = (val: string) => {
+    selectedCell[0].setData({
+      remark: val
+    });
+    setValue(val);
+  };
+
+  return (
+    <Tabs items={[
+      {
+        key: '1',
+        label: '备注',
+        children: <div className='px-16px'>
+          <div className='text-12px color-#666' style={{opacity: disabled ? 0.8 : 1}}>点击编辑备注</div>
+          <Input.TextArea
+            style={{
+              height: 'calc(100vh - 100px)',
+              background: '#fffef2'
+            }}
+            disabled={disabled}
+            value={value}
+            onChange={(e) => handleInput(e.target.value)}
+            autoFocus
+          />
+        </div>
+      }
+    ]}/>
+  )
+}

+ 3 - 0
apps/designer/src/pages/mindmap/components/Config/index.tsx

@@ -9,6 +9,7 @@ import Structure from "./Structure";
 import Theme from "./Theme";
 import TagConfig from "./TagConfig";
 import IconConfig from "./IconConfig";
+import Remark from "./Remark";
 
 InsertCss(`
   .shalu-tabs-nav {
@@ -60,6 +61,8 @@ export default function index() {
       {rightToobarActive === "icon" && <IconConfig />}
       {/* 标签 */}
       {rightToobarActive === "tag" && <TagConfig />}
+      {/* 备注 */}
+      {rightToobarActive === "remark" && <Remark />}
     </div>
   );
 }

+ 57 - 36
apps/designer/src/pages/mindmap/mindMap.tsx

@@ -7,6 +7,7 @@ import { uuid } from "@/utils";
 import { hierarchyMethodMap } from "@/pages/mindmap/hierarchy";
 import { createEdge } from "./edge";
 import { getTheme } from "./theme";
+import { topicMenu } from "@/utils/contentMenu";
 
 interface HierarchyResult {
   id: string;
@@ -40,6 +41,7 @@ export const renderMindMap = (graph: Graph, setMindProjectInfo: () => void) => {
     }
     const offsetX = originPosition.x - result.x;
     const offsetY = originPosition.y - result.y;
+
     const traverse = (hierarchyItem: HierarchyResult, parent?: Node) => {
       if (hierarchyItem) {
         const { data, children, x, y } = hierarchyItem;
@@ -51,12 +53,19 @@ export const renderMindMap = (graph: Graph, setMindProjectInfo: () => void) => {
           height: data.height,
           data: {
             ...data,
+            opacity: 100,
             // 节点内部执行数据更新方法
             setMindProjectInfo,
           },
           id,
           x: offsetX + x,
           y: offsetY + y,
+          tools: [{
+            name: 'contextmenu',
+            args: {
+              menu: topicMenu,
+            },
+          },]
         });
         cells.push(node);
         parent && parent.addChild(node);
@@ -66,16 +75,15 @@ export const renderMindMap = (graph: Graph, setMindProjectInfo: () => void) => {
 
         if (children) {
           children.forEach((item: HierarchyResult) => {
-            cells.push(
-              // 创建连线
-              createEdge(
-                graph,
-                id,
-                item,
-                projectInfo.structure,
-                projectInfo.theme
-              )
+            const edge = createEdge(
+              graph,
+              id,
+              item,
+              projectInfo.structure,
+              projectInfo.theme
             );
+            cells.push(edge);
+            node.addChild(edge);
             // 递归遍历
             traverse(item, node);
           });
@@ -85,40 +93,46 @@ export const renderMindMap = (graph: Graph, setMindProjectInfo: () => void) => {
 
     traverse(result);
   });
-  // 处理节点
+
+  const oldCells = graph.getCells();
+  // 移除不要的节点及对应的边
+  oldCells.forEach((cell) => {
+    if (!cells.find((item) => cell.id === item.id)) {
+      graph.removeCell(cell.id + "-edge");
+      graph.removeCell(cell);
+    }
+  });
+  // 添加或删除节点
   cells
-    .filter((cell) => cell.isNode())
+    .filter((cell) => cell.isNode() && !graph.hasCell(cell.id))
     .forEach((cell) => {
-      // 存在更新位置,否则添加
-      if (graph.hasCell(cell.id)) {
-        const oldCell = graph.getCellById(cell.id);
-        if (oldCell.isNode()) {
-          oldCell.position(cell.position().x, cell.position().y);
-          oldCell.setData({
-            ...cell.data,
-          });
-        }
-      } else {
-        graph.addCell(cell);
-      }
+      graph.addCell(cell);
     });
+  // 更新老的节点
+  cells
+    .filter((cell) => cell.isNode() && graph.hasCell(cell.id))
+    .forEach((cell) => {
+      cell.isNode() && updateNode(cell, graph);
+    });
+  // 添加所需的节点
   cells
     .filter((cell) => cell.isEdge())
     .forEach((cell) => {
-      // graph.removeCell(cell.id);
       graph.addCell(cell);
     });
-  const oldCells = graph.getCells();
-  // 移除不存在的节点
-  oldCells.forEach((cell) => {
-    if (!cells.find((item) => cell.id === item.id)) {
-      graph.removeCell(cell.id + "-edge");
-      graph.removeCell(cell);
-    }
-  });
-  // graph.centerContent();
 };
 
+const updateNode = (node: Node, graph: Graph) => {
+  const oldCell = graph.getCellById(node.id);
+  if(oldCell.isNode()) {
+    oldCell.setData(node.data);
+    oldCell.position(node.position().x, node.position().y);
+    // oldCell.setAttrs(node.attrs);
+    // const cells = node.children?.map(item => graph.getCellById(item.id));
+    // oldCell.setChildren(cells ?? null);
+  }
+}
+
 /**
  * 添加分支主题
  */
@@ -127,7 +141,7 @@ export const addTopic = (
   setMindProjectInfo: (info: MindMapProjectInfo) => void,
   node?: Node,
   otherData: Record<string, any> = {}
-) => {
+): TopicItem | undefined => {
   const projectInfo = getMindMapProjectByLocal();
   if (!projectInfo || !setMindProjectInfo) return;
 
@@ -193,16 +207,17 @@ export const buildTopic = (
   type: TopicType,
   options: Record<string, any> = {},
   parentNode?: Node
-) => {
+): TopicItem => {
   const projectInfo = getMindMapProjectByLocal();
 
   const theme = getTheme(
     projectInfo?.theme,
     type === TopicType.sub ? parentNode : undefined
   );
+  const id = uuid();
   return {
     ...topicData,
-    id: uuid(),
+    id,
     type,
     label: topicMap[type].label || "自由主题",
     width: topicMap[type].width || 206,
@@ -224,6 +239,12 @@ export const buildTopic = (
       color: theme[type]?.edge.color,
     },
     ...options,
+    children: (options?.children || topicData.children || []).map((item: TopicItem) => {
+      return {
+        ...item,
+        parentId: id
+      }
+    })
   };
 };
 

+ 3 - 2
apps/designer/src/pages/mindmap/theme.ts

@@ -1,8 +1,9 @@
 import { MindmapConnectorType, TopicType } from "@/enum";
+import { cellStyle, TopicItem } from "@/types";
 import { getRandomColor, lightenColor } from "@/utils/color";
 import { Node } from "@antv/x6";
 
-export const getTheme = (key?: string, parentNode?: Node) => {
+export const getTheme = (key?: string, parentNode?: Node): Record<string, any> => {
   const color1 = getRandomColor(["#3D4BCF", "#9C2CB8"]);
   const map = {
     // 默认主题
@@ -20,7 +21,7 @@ export const getTheme = (key?: string, parentNode?: Node) => {
           width: 0,
         },
         edge: {
-          color: ""
+          color: "#fff"
         }
       },
       branch: {

+ 6 - 2
apps/designer/src/types.d.ts

@@ -46,7 +46,7 @@ export interface cellStyle {
   };
 }
 
-export interface TopicItem {
+export type TopicItem = {
   /**
    * 主键
    */
@@ -128,7 +128,11 @@ export interface TopicItem {
    * 关联线
    */
   links?: any[];
-}
+  /**
+   * 主题链接
+   */
+  linkTopicId?: string;
+} & cellStyle;
 export interface MindMapProjectInfo{
   name: string;
   desc: string;

+ 177 - 4
apps/designer/src/utils/contentMenu.tsx

@@ -4,6 +4,9 @@ import { Dropdown } from "antd";
 import { Graph, ToolsView, EdgeView } from "@antv/x6";
 import type { MenuProps } from "antd";
 import { menuHander } from "./hander";
+import { mindmapMenuHander } from "./mindmapHander";
+import InsetCss from "insert-css";
+import { TopicType } from "@/enum";
 
 export class ContextMenuTool extends ToolsView.ToolItem<
   EdgeView,
@@ -19,8 +22,17 @@ export class ContextMenuTool extends ToolsView.ToolItem<
       const { sx, sy } = this.graph.scale();
       let offsetX = e.offsetX * sx,
         offsetY = e.offsetY * sy;
+      if (!this.cell.isNode()) {
+        return;
+      }
+
       // 非页面节点需要获取当前节点位置 + 节点本身偏移位置
-      if (this.cell.isNode() && !this.cell.getData()?.isPage) {
+      if (this.cell.data?.type) {
+        const { x, y } = this.graph.options;
+        const { x: x1, y: y1 } = this.cell.getPosition();
+        offsetX = x + x1 + e.offsetX;
+        offsetY = y + y1 + e.offsetY;
+      } else if (this.cell.isNode() && !this.cell.getData()?.isPage) {
         const { x, y } = this.cell.getPosition();
         offsetX = x * sx + e.offsetX * sx;
         offsetY = y * sy + e.offsetY * sy;
@@ -30,6 +42,18 @@ export class ContextMenuTool extends ToolsView.ToolItem<
         if (!item) return item;
         return {
           ...item,
+          disabled:
+            typeof item?.disabled === "function" ? item.disabled(this) : false,
+          children: item?.children?.map((c: any) => ({
+            ...c,
+            disabled:
+              typeof item?.disabled === "function"
+                ? item.disabled(this)
+                : false,
+            onClick: () => {
+              c?.onClick?.call(this, this, e);
+            },
+          })),
           onClick: () => {
             setTimeout(() => {
               item?.onClick?.call(this, this, e);
@@ -37,6 +61,12 @@ export class ContextMenuTool extends ToolsView.ToolItem<
           },
         };
       });
+
+      InsetCss(`
+        .ant-dropdown-menu-submenu-expand-icon {
+          top: 4px
+        }`);
+
       this.root.render(
         <Dropdown
           open={true}
@@ -68,7 +98,7 @@ export class ContextMenuTool extends ToolsView.ToolItem<
 
   delegateEvents() {
     this.cellView.on("cell:contextmenu", this.onContextMenu, this);
-    this.graph.on("blank:contextmenu", this.onContextMenu, this);
+    // this.graph.on("blank:contextmenu", this.onContextMenu, this);
     return super.delegateEvents();
   }
 
@@ -96,6 +126,8 @@ interface MenuItem {
   icon?: string;
   fastKey?: string;
   handler?: (tool: ContextMenuTool, e: MouseEvent) => void;
+  disabled?: (tool: ContextMenuTool) => boolean;
+  children?: MenuItem[];
 }
 
 // [复制、剪切、粘贴、复用、删除、设为默认样式],[置于顶层、置于底层、上移一层、下移一层],[锁定],[全选],[导出所选图形为PNG、复制所选图形为图片]
@@ -277,17 +309,156 @@ const LabelComponent = ({ item }: { item: MenuItem }) => {
   );
 };
 
-const getMenuData = (menuData: MenuItem[]) => {
-  return menuData.map((item) => {
+const getMenuData = (menuData: MenuItem[]): MenuProps["items"] => {
+  const menu = menuData.map((item) => {
     if (item.type === "divider") return item;
     return {
       key: item.key,
       label: <LabelComponent item={item} />,
       onClick: item.handler,
+      disabled: item?.disabled,
+      children: item.children ? getMenuData(item.children) : undefined,
     };
   });
+  return menu as MenuProps["items"];
 };
 
+const topicMenuData: MenuItem[] = [
+  {
+    key: "addChild",
+    label: "新增子主题",
+    fastKey: "Tab",
+    handler: mindmapMenuHander.addTopic,
+  },
+  {
+    key: "addCorrelation",
+    label: "新增同级主题",
+    fastKey: "Enter",
+    handler: mindmapMenuHander.addPeerTopic,
+    disabled: (tool) => !tool.cell.data?.parentId,
+  },
+  {
+    key: "addParent",
+    label: "新增父主题",
+    fastKey: "Shift-Tab",
+    handler: mindmapMenuHander.addParentTopic,
+    disabled: (tool) => !tool.cell.data?.parentId,
+  },
+  { type: "divider" },
+  {
+    key: "insert",
+    label: "插入",
+    children: [
+      {
+        key: "link",
+        label: "关联线",
+        fastKey: "Ctrl+L",
+        handler: mindmapMenuHander.addCorrelationEdge,
+      },
+      {
+        key: "remark",
+        label: "备注",
+        fastKey: "Ctrl+R",
+        handler: mindmapMenuHander.addRemark,
+      },
+      {
+        key: "herf",
+        label: "链接",
+        fastKey: "Ctrl+K",
+        handler: mindmapMenuHander.addHerf,
+      },
+      // {
+      //   key: "topicLink",
+      //   label: "主题链接",
+      //   handler: mindmapMenuHander.addTopicLink
+      // },
+      {
+        key: "image",
+        label: "图片",
+        fastKey: "Ctrl+Shift+I",
+        handler: mindmapMenuHander.addImage,
+      },
+      {
+        key: "tag",
+        label: "标签",
+        fastKey: "Alt+R",
+        handler: mindmapMenuHander.addTag,
+      },
+      {
+        key: "icon",
+        label: "图标",
+        fastKey: "Alt+C",
+        handler: mindmapMenuHander.addIcon,
+      },
+      {
+        key: "code",
+        label: "代码块",
+        handler: mindmapMenuHander.addCode,
+      },
+    ],
+  },
+  {
+    key: "chooseTopic",
+    label: "选择主题",
+    children: [
+      {
+        key: "sameLevel",
+        label: "选择当前分支同级主题",
+        handler: mindmapMenuHander.chooseSameLevel,
+      },
+      {
+        key: "allSameLevel",
+        label: "选择所有分支统计主题",
+        handler: mindmapMenuHander.chooseAllSameLevel,
+      },
+    ],
+  },
+  { type: "divider" },
+  {
+    key: "copy",
+    label: "复制",
+    fastKey: "Ctrl+C",
+    handler: mindmapMenuHander.copy,
+  },
+  {
+    key: "cut",
+    label: "剪切",
+    fastKey: "Ctrl+X",
+    handler: mindmapMenuHander.cut,
+  },
+  {
+    key: "paste",
+    label: "粘贴",
+    fastKey: "Ctrl+V",
+    handler: mindmapMenuHander.paste,
+  },
+  {
+    key: "delete",
+    label: "删除",
+    fastKey: "Del",
+    handler: mindmapMenuHander.delete,
+    disabled: (tool) => tool.cell.data.type === TopicType.main,
+  },
+  {
+    key: "deleteCurrent",
+    label: "删除当前主题",
+    fastKey: "Ctrl+Del",
+    handler: mindmapMenuHander.deleteCurrent,
+    disabled: (tool) => tool.cell.data?.type === TopicType.main,
+  },
+  { type: "divider" },
+  {
+    key: "exportImage",
+    label: "导出当前主题为图片",
+    handler: mindmapMenuHander.exportImage,
+  },
+  // {
+  //   key: "copyImage",
+  //   label: "复制当前主题为图片",
+  //   handler: mindmapMenuHander.copyImage
+  // },
+];
+
 // 节点右键菜单
 export const nodeMenu = getMenuData(nodeMenuData);
 // 边线右键菜单
@@ -296,3 +467,5 @@ export const edgeMenu = getMenuData(edgeMenuData);
 export const pageMenu = getMenuData(pageMenuData);
 // 上锁节点菜单
 export const lockMenu = getMenuData(lockMenuData);
+// 思维导图菜单
+export const topicMenu = getMenuData(topicMenuData);

+ 270 - 3
apps/designer/src/utils/mindmapHander.tsx

@@ -1,10 +1,13 @@
 import { TopicType } from "@/enum";
 import { addTopic, getMindMapProjectByLocal } from "@/pages/mindmap/mindMap";
 import { MindMapProjectInfo, TopicItem } from "@/types";
-import { Cell, Graph, Node } from "@antv/x6";
+import { Cell, Graph, Node, Edge } from "@antv/x6";
 import { message } from "antd";
 import { cloneDeep } from "lodash-es";
-import { uuid } from ".";
+import { uuid } from "@/utils";
+import { ContextMenuTool } from "./contentMenu";
+import { MutableRefObject } from "react";
+import { exportImage } from "@/components/ExportImage";
 
 export const selectTopic = (graph: Graph, topic?: TopicItem) => {
   if (topic?.id) {
@@ -16,6 +19,16 @@ export const selectTopic = (graph: Graph, topic?: TopicItem) => {
   }
 };
 
+export const selectTopics = (graph: Graph, topics?: TopicItem[]) => {
+  setTimeout(() => {
+    graph.resetSelection(topics?.map((item) => item.id));
+    topics?.forEach((item) => {
+      const node = graph.getCellById(item.id);
+      node?.isNode() && graph.createTransformWidget(node);
+    });
+  }, 100);
+};
+
 /**
  * 添加同级主题
  * @param node
@@ -45,6 +58,7 @@ export const addPeerTopic = (
  * 添加子主题
  * @param node
  * @param setMindProjectInfo
+ * @param graph
  */
 export const addChildrenTopic = (
   node: Node,
@@ -59,6 +73,35 @@ export const addChildrenTopic = (
   graph && selectTopic(graph, topic);
 };
 
+/**
+ * 添加父主题
+ * @param node
+ * @param setMindProjectInfo
+ * @param graph
+ */
+export const addParentTopic = (
+  node: Node,
+  setMindProjectInfo?: (info: MindMapProjectInfo) => void,
+  graph?: Graph
+) => {
+  if (!setMindProjectInfo || !node.data?.parentId) return;
+
+  const type =
+    node.data?.type === TopicType.branch ? TopicType.branch : TopicType.sub;
+  const parentNode = graph?.getCellById(node.data.parentId);
+  // 删除原来的数据
+  deleteTopics([node.data.id], setMindProjectInfo);
+  // 加入新的父主题
+  const topic = addTopic(type, setMindProjectInfo, parentNode as Node, {
+    children: [
+      {
+        ...node.data,
+      },
+    ],
+  });
+  graph && selectTopic(graph, topic);
+};
+
 /**
  * 删除子主题
  * @param ids
@@ -90,6 +133,51 @@ export const deleteTopics = (
   localStorage.setItem("minMapProjectInfo", JSON.stringify(mindProjectInfo));
 };
 
+/**
+ * 删除当前主题
+ * @param graph
+ * @param nodes
+ */
+export const handleDeleteCurrentTopic = (
+  graph: Graph, 
+  nodes: Node[],
+) => {
+  const mindProjectInfo = getMindMapProjectByLocal();
+  if (!mindProjectInfo) return;
+
+  nodes.forEach((node) => {
+    if (node.data.parentId) {
+      traverseNode(mindProjectInfo.topics, (topic) => {
+        if (topic.id === node.data.parentId) {
+          const index = topic.children?.findIndex(
+            (item) => item.id === node.id
+          );
+          if (typeof index === "number" && index >= 0) {
+            const newChildren = (node.data.children || []).map(
+              (childNode: TopicItem) => {
+                return {
+                  ...childNode,
+                  type: topic.type === TopicType.main ? TopicType.branch : TopicType.sub,
+                  parentId: topic.id,
+                };
+              }
+            );
+            (topic.children || []).splice(
+              index,
+              1,
+              ...newChildren
+            );
+          }
+        }
+      });
+    }
+    console.log(mindProjectInfo)
+    // @ts-ignore
+    graph?.extendAttr?.setMindProjectInfo?.(mindProjectInfo);
+    localStorage.setItem("minMapProjectInfo", JSON.stringify(mindProjectInfo));
+  });
+};
+
 /**
  * 执行粘贴
  * @param graph
@@ -194,4 +282,183 @@ const traverseCopyData = (list: TopicItem[], parentId: string): TopicItem[] => {
     }
     return item;
   });
-};
+};
+
+/**
+ * 遍历主题树
+ * @param topics
+ * @param callback
+ * @returns
+ */
+export 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 handleCorrelation = (
+  e: MouseEvent,
+  correlationEdgeRef: MutableRefObject<Edge | undefined>,
+  graph: Graph
+) => {
+  if (correlationEdgeRef.current) {
+    const point = graph?.clientToLocal(e.x, e.y);
+    point && correlationEdgeRef.current?.setTarget(point);
+  }
+};
+/**
+ * 添加关联线
+ * @param graph
+ * @param correlationEdgeRef
+ * @param sourceNode
+ */
+export const handleCreateCorrelationEdge = (
+  graph: Graph,
+  correlationEdgeRef: MutableRefObject<Edge | undefined>,
+  sourceNode?: Node
+) => {
+  if (sourceNode) {
+    correlationEdgeRef.current = graph?.addEdge({
+      source: { cell: sourceNode },
+      target: {
+        x: sourceNode.position().x,
+        y: sourceNode.position().y,
+      },
+      connector: "normal",
+      attrs: {
+        line: {
+          stroke: "#71cb2d",
+          strokeWidth: 2,
+          sourceMarker: {
+            name: "",
+          },
+          targetMarker: {
+            name: "",
+          },
+          style: {
+            opacity: 0.6,
+          },
+        },
+      },
+      data: {
+        ignoreDrag: true,
+      },
+      zIndex: 0,
+    });
+    document.body.addEventListener("mousemove", (e) =>
+      handleCorrelation(e, correlationEdgeRef, graph)
+    );
+  } else {
+    document.body.removeEventListener("mousemove", (e) =>
+      handleCorrelation(e, correlationEdgeRef, graph)
+    );
+    if (correlationEdgeRef.current) {
+      graph?.removeCell(correlationEdgeRef.current);
+      correlationEdgeRef.current = undefined;
+    }
+  }
+};
+
+/**
+ * 右键菜单处理方法
+ */
+export const mindmapMenuHander = {
+  addTopic(tool: ContextMenuTool) {
+    const node = tool.cell;
+    if (node.isNode())
+      addChildrenTopic(node, node.data.setMindProjectInfo, tool.graph);
+  },
+  addPeerTopic(tool: ContextMenuTool) {
+    const node = tool.cell;
+    if (node.isNode())
+      addPeerTopic(node, tool.graph, node.data.setMindProjectInfo);
+  },
+  addParentTopic(tool: ContextMenuTool) {
+    const node = tool.cell;
+    if (node.isNode())
+      addParentTopic(node, node.data.setMindProjectInfo, tool.graph);
+  },
+  addCorrelationEdge(tool: ContextMenuTool) {
+    if (tool.cell.isNode()) {
+      // @ts-ignore
+      const correlationEdgeRef = tool.graph?.extendAttr?.correlationEdgeRef;
+      handleCreateCorrelationEdge(tool.graph, correlationEdgeRef, tool.cell);
+    }
+  },
+  addRemark(tool: ContextMenuTool) {
+    // @ts-ignore
+    tool.graph?.extendAttr?.setRightToolbarActive("remark");
+    selectTopic(tool.graph, tool.cell.data);
+  },
+  addHerf(tool: ContextMenuTool) {
+    console.log(tool.cell)
+    // @ts-ignore
+    tool.cell?.extendAttr?.showHerfConfig?.();
+  },
+  addTopicLink(tool: ContextMenuTool) {
+    // todo
+  },
+  addImage(tool: ContextMenuTool) {
+    // todo
+  },
+  addTag(tool: ContextMenuTool) {
+    // @ts-ignore
+    tool.graph?.extendAttr?.setRightToolbarActive("tag");
+    selectTopic(tool.graph, tool.cell.data);
+  },
+  addIcon(tool: ContextMenuTool) {
+    // @ts-ignore
+    tool.graph?.extendAttr?.setRightToolbarActive("icon");
+    selectTopic(tool.graph, tool.cell.data);
+  },
+  addCode(tool: ContextMenuTool) {
+    tool.cell.setData({
+      extraModules: {
+        type: "code",
+        data: {
+          code: "",
+          language: "javascript",
+        },
+      },
+    });
+  },
+  chooseSameLevel(tool: ContextMenuTool) {
+    const parentId = tool.cell.data?.parentId;
+    if (!parentId) return;
+    const parent = tool.graph.getCellById(parentId);
+    selectTopics(tool.graph, parent.data?.children);
+  },
+  chooseAllSameLevel(tool: ContextMenuTool) {
+    // todo
+  },
+  copy(tool: ContextMenuTool) {
+    localStorage.setItem("mindmap-copy-data", JSON.stringify([tool.cell]));
+    navigator.clipboard.writeText("  ");
+  },
+  cut(tool: ContextMenuTool) {
+    tool.graph.cut([tool.cell]);
+  },
+  paste(tool: ContextMenuTool) {
+    handleMindmapPaste(tool.graph, tool.cell.data.setMindProjectInfo);
+  },
+  delete(tool: ContextMenuTool) {
+    deleteTopics([tool.cell.id], tool.cell.data.setMindProjectInfo);
+  },
+  deleteCurrent(tool: ContextMenuTool) {
+    tool.cell.isNode() && handleDeleteCurrentTopic(tool.graph, [tool.cell]);
+  },
+  exportImage(tool: ContextMenuTool) {
+    exportImage(tool.graph);
+  },
+  copyImage(tool: ContextMenuTool) {
+    // TODO复制为图片
+  },
+};