Prechádzať zdrojové kódy

feat: 添加替换弹窗

liaojiaxing 5 mesiacov pred
rodič
commit
953ae5e50f

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

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

+ 76 - 3
apps/designer/src/components/CustomInput.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useRef } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
 import { Input, InputRef } from "antd";
 import { Node } from "@antv/x6";
 import { useSafeState } from "ahooks";
@@ -44,6 +44,79 @@ export default function CustomInput(props: {
     props.onChange?.(val);
   };
 
+  const [findObj, setFindObj] = useState<{
+    findStr: string;
+    currentCellId?: string;
+    currentIndex: number;
+  }>();
+  // 查找
+  const handleFind = (args: any) => {
+    setFindObj(args?.current || {});
+  };
+
+  const label = useMemo(() => {
+    if (!findObj) return value;
+    const list = (value || "").split(findObj.findStr || "");
+    return list.map((str: string, index) => {
+      // 当前的节点展示
+      const style =
+        findObj.currentCellId === node.id
+          ? {
+              background:
+                index + 1 === findObj.currentIndex
+                  ? "#FF9933"
+                  : "rgba(255, 153, 51, 0.25)",
+            }
+          : {
+              background: "#ffff00",
+            };
+      return (
+        <span key={index}>
+          {str}
+          {index < list.length - 1 && (
+            <span style={style}>{findObj.findStr}</span>
+          )}
+        </span>
+      );
+    });
+  }, [value, findObj]);
+
+  const handleReplace = (args: any) => {
+    console.log("替换:", args);
+    const { type, searchText, replaceText, currentIndex, currentCellId } = args?.current || {};
+    if(args.current?.type === 'replaceAll') {
+      handleChange(value.replaceAll(searchText, replaceText));
+    }
+    if(type === 'replace' && currentCellId === node.id) {
+      const list = value.split(searchText);
+      const text = list.map((str, index) => {
+        const result = index + 1 === currentIndex ? replaceText : searchText;
+        return str + (index < list.length - 1 ? result : '');
+      }).join("");
+      console.log(text);
+      handleChange(text);
+    }
+  };
+
+  const handleClear = () => {
+    setFindObj(undefined);
+  };
+
+  useEffect(() => {
+    node.off("change:find", handleFind);
+    node.off("change:replace", handleReplace);
+    node.off("change:clearFind", handleClear);
+
+    node.on("change:find", handleFind);
+    node.on("change:replace", handleReplace);
+    node.on("change:clearFind", handleClear);
+    return () => {
+      node.off("change:find", handleFind);
+      node.off("change:replace", handleReplace);
+      node.off("change:clearFind", handleClear);
+    };
+  }, [value]);
+
   const handleSetEditing = (edit: boolean) => {
     if (node.data?.lock) {
       return;
@@ -71,7 +144,7 @@ export default function CustomInput(props: {
 
   return (
     <div className="absolute w-full h-full w-full" style={txtStyle}>
-      <FlowExtra node={node}/>
+      <FlowExtra node={node} />
       {isEditing ? (
         <Input.TextArea
           ref={inputRef}
@@ -90,7 +163,7 @@ export default function CustomInput(props: {
           style={style}
           onDoubleClick={() => handleSetEditing(true)}
         >
-          {value}
+          {label}
         </div>
       )}
     </div>

+ 198 - 0
apps/designer/src/components/FindReplaceModal.tsx

@@ -0,0 +1,198 @@
+import { CloseOutlined } from "@ant-design/icons";
+import { Button, Input } from "antd";
+import React, {
+  forwardRef,
+  useImperativeHandle,
+  useState,
+  useRef,
+  useEffect,
+} from "react";
+import type { DraggableData, DraggableEvent } from "react-draggable";
+import Draggable from "react-draggable";
+
+export type FindReplaceModalRef = {
+  open: () => void;
+};
+export default forwardRef(function FindReplaceModal(
+  {
+    current = 0,
+    count = 0,
+    onClose,
+    onFind,
+    onFindNext,
+    onFindPrev,
+    onReplace,
+    onReplaceAll,
+  }: {
+    current: number;
+    count: number;
+    onFind: (text: string) => void;
+    onClose: () => void;
+    onFindNext: () => void;
+    onFindPrev: () => void;
+    onReplace: (searchText: string, replaceText: string) => void;
+    onReplaceAll: (searchText: string, replaceText: string) => void;
+  },
+  ref: React.Ref<FindReplaceModalRef>
+) {
+  const [open, setOpen] = useState(false);
+  const [active, setActive] = useState(1);
+  const [searchText, setSearchText] = useState("");
+  const [replaceText, setReplaceText] = useState("");
+
+  useImperativeHandle(ref, () => ({
+    open() {
+      setOpen(true);
+    },
+  }));
+
+  useEffect(() => {
+    if (!open) {
+      setBounds({
+        left: 0,
+        top: 0,
+        bottom: 0,
+        right: 0,
+      });
+      setSearchText("");
+      setReplaceText("");
+    }
+  }, [open]);
+
+  const [disabled, setDisabled] = useState(true);
+  const [bounds, setBounds] = useState({
+    left: 0,
+    top: 0,
+    bottom: 0,
+    right: 0,
+  });
+  const draggleRef = useRef<HTMLDivElement>(null);
+
+  const onStart = (_event: DraggableEvent, uiData: DraggableData) => {
+    const { clientWidth, clientHeight } = window.document.documentElement;
+    const targetRect = draggleRef.current?.getBoundingClientRect();
+    if (!targetRect) {
+      return;
+    }
+    setBounds({
+      left: -targetRect.left + uiData.x,
+      right: clientWidth - (targetRect.right - uiData.x),
+      top: -targetRect.top + uiData.y,
+      bottom: clientHeight - (targetRect.bottom - uiData.y),
+    });
+  };
+
+  const handleReplace = () => {
+    onReplace?.(searchText, replaceText);
+  };
+
+  const handleReplaceAll = () => {
+    onReplaceAll?.(searchText, replaceText);
+  };
+
+  const handleClose = () => {
+    onClose?.();
+    setOpen(false);
+  };
+
+  const handleChange = (val: string) => {
+    setSearchText(val);
+    onFind?.(val);
+  };
+
+  return (
+    <Draggable
+      disabled={disabled}
+      bounds={bounds}
+      nodeRef={draggleRef}
+      onStart={(event, uiData) => onStart(event, uiData)}
+    >
+      <div
+        className="w-320px fixed right-30px top-110px bg-white rounded-md shadow-md z-100 p-10px"
+        ref={draggleRef}
+        style={{ display: open ? "block" : "none" }}
+      >
+        <div className="flex items-ctenter px-10px">
+          <span
+            className="text-14px mr-12px cursor-pointer"
+            style={{ color: active === 1 ? "#333" : "#999" }}
+            onClick={() => setActive(1)}
+          >
+            查找
+          </span>
+          <span
+            className="text-14px cursor-pointer"
+            style={{ color: active === 2 ? "#333" : "#999" }}
+            onClick={() => setActive(2)}
+          >
+            替换
+          </span>
+          <div
+            className="flex-1 cursor-move"
+            onMouseOver={() => disabled && setDisabled(false)}
+            onMouseOut={() => setDisabled(true)}
+          ></div>
+          <Button
+            type="text"
+            size="small"
+            icon={<CloseOutlined />}
+            onClick={handleClose}
+          />
+        </div>
+        <div className="flex">
+          <Input
+            placeholder="查找内容"
+            value={searchText}
+            onChange={(e) => handleChange(e.target.value)}
+            suffix={
+              <span>
+                {current}/{count}
+              </span>
+            }
+          />
+          <Button
+            type="text"
+            icon={<i className="iconfont icon-xiangzuo1" />}
+            disabled={count === 0}
+            onClick={() => onFindPrev?.()}
+          />
+          <Button
+            type="text"
+            disabled={count === 0}
+            icon={<i className="iconfont icon-xiangyou1" />}
+            onClick={() => onFindNext?.()}
+          />
+        </div>
+        {active === 2 && (
+          <>
+            <div>
+              <Input
+                placeholder="替换内容"
+                value={replaceText}
+                onChange={(e) => setReplaceText(e.target.value)}
+              />
+            </div>
+            <div className="flex justify-end gap-16px mt-12px">
+              <Button
+                type="primary"
+                size="small"
+                disabled={!searchText || !replaceText}
+                onClick={handleReplace}
+              >
+                替换
+              </Button>
+              <Button
+                type="primary"
+                size="small"
+                disabled={!searchText || !replaceText}
+                onClick={handleReplaceAll}
+              >
+                全部替换
+              </Button>
+            </div>
+          </>
+        )}
+      </div>
+    </Draggable>
+  );
+});

+ 4 - 4
apps/designer/src/config/data.ts

@@ -153,16 +153,16 @@ export const defaultProject: MindMapProjectInfo = {
   author: "",
   structure: StructureType.right,
   pageSetting: {
-    fillType: "color",
-    fill: "#ffffff",
-    fillImageUrl: "",
+    backgroundType: "color",
+    backgroundColor: "#ffffff",
+    backgroundImage: "",
     branchY: 30,
     branchX: 44,
     subTopicY: 16,
     subTopicX: 20,
     alignSameTopic: false,
     showWatermark: false,
-    watermark: "",
+    watermarkText: "",
   },
   // 主题
   theme: "default",

+ 194 - 0
apps/designer/src/hooks/useFindReplace.ts

@@ -0,0 +1,194 @@
+import { Cell, Graph } from "@antv/x6"
+import { useEffect, useState } from "react"
+
+export const useFindReplace = (graph?: Graph) => {
+
+  const [graphInstance, setGraphInstance] = useState<Graph | undefined>(graph);
+  // 总的匹配数
+  const [count, setCount] = useState(0);
+  // 当前匹配的索引
+  const [current, setCurrent] = useState(0);
+  // 匹配集合
+  const [matches, setMatches] = useState<{cell: Cell, count: number}[]>([])
+  const [searchText, setSearchText] = useState('');
+  const setInstance = (graph: Graph) => {
+    setGraphInstance(graph)
+  }
+
+  // TODO 数据变化监听
+  // useEffect(() => {
+  //   graph && graph.on("node:change:data", () => {
+  //     handleFind(searchText);
+  //   })
+  // }, [graph])
+
+  const handleFind = (str: string) => {
+    setSearchText(str);
+    // 查找元素中匹配的文本
+    let count = 0;
+    let cellList: {cell: Cell, count: number}[] = [];
+    const cells = graphInstance?.getCells();
+    (cells || []).forEach(cell => {
+      if(cell.isNode()) {
+        const label = cell.data?.label || '';
+        const regex = new RegExp(str, 'g');
+        const matches = label.match(regex);
+        count += matches ? matches.length : 0;
+        if(matches && matches.length) {
+          cellList.push({
+            cell,
+            count: matches.length
+          });
+        }
+      }
+      if(cell.isEdge()) {
+        const labels = cell.labels || [];
+        let count = 0;
+        labels.forEach(label => {
+          const regex = new RegExp(str, 'g');
+          const matches = label.text.match(regex);
+          count += matches ? matches.length : 0;
+          if(matches && matches.length) {
+            count = matches.length;
+          }
+        });
+        if(count) {
+          cellList.push({
+            cell,
+            count
+          });
+        }
+      }
+    });
+    setCount(count);
+    setCurrent(1);
+    setMatches(cellList);
+    // 通知元素匹配开始
+    cellList.forEach((item, index) => {
+      item.cell.prop("find", { 
+        findStr: str,
+        currentCellId: index === 0 ? item.cell.id : undefined,
+        currentIndex: 1,
+        time: new Date().getTime()
+      });
+    })
+  }
+  const handleReplace = (searchText: string, replaceText: string) => {
+    let flag = 0;
+    let flagIndex = 0;
+    let currentCellId: string | undefined;
+    // 找到所在的元素id
+    matches.forEach(item => {
+      if(current > flag && current <= flag + item.count) {
+        currentCellId = item.cell.id;
+        flagIndex = current - flag;
+      }
+      flag += item.count;
+    })
+    matches.forEach((item) => {
+      item.cell.prop("replace", {
+        currentCellId,
+        currentIndex: flagIndex,
+        type: 'replace',
+        searchText,
+        replaceText,
+        time: new Date().getTime()
+      });
+    })
+  }
+  /**
+   * 全部替换
+   */
+  const handleReplaceAll = (searchText: string, replaceText: string) => {
+    console.log('替换全部:', searchText, replaceText)
+    if(searchText && replaceText) {
+      matches.forEach(item => {
+        item.cell.prop("replace", {
+          type: 'replaceAll',
+          searchText,
+          replaceText,
+          time: new Date().getTime()
+        });
+      });
+    }
+  }
+  /**
+   * 关闭
+   */
+  const handleClose = () => {
+    matches.forEach((item) => {
+      item.cell.prop("clearFind", {
+        time: new Date().getTime()
+      });
+    });
+    setCount(0);
+    setCurrent(0);
+    setMatches([]);
+    setSearchText('');
+  }
+  /**
+   * 下一个
+   */
+  const handleFindNext = () => {
+    const currentIndex = current < count ? current + 1 : 1;
+    setCurrent(currentIndex);
+    let flag = 0;
+    let flagIndex = 0;
+    let currentCellId: string | undefined;
+    // 找到所在的元素id
+    matches.forEach(item => {
+      if(currentIndex > flag && currentIndex <= flag + item.count) {
+        currentCellId = item.cell.id;
+        flagIndex = currentIndex - flag;
+      }
+      flag += item.count;
+    })
+    // 计算所在段的索引位置
+    matches.forEach((item) => {
+      item.cell.prop("find", {
+        findStr: searchText,
+        currentCellId,
+        currentIndex: flagIndex,
+      });
+    })
+  }
+  /**
+   * 上一个
+   */
+  const handleFindPrev = () => {
+    const currentIndex = current > 1 ? current - 1 : count;
+    setCurrent(currentIndex);
+    let flag = 0;
+    let flagIndex = 0;
+    let currentCellId: string | undefined;
+    // 找到所在的元素id
+    matches.forEach(item => {
+      if(currentIndex > flag && currentIndex <= flag + item.count) {
+        currentCellId = item.cell.id;
+        flagIndex = currentIndex - flag;
+      }
+      flag += item.count;
+    })
+    // 计算所在段的索引位置
+    matches.forEach((item, index) => {
+      item.cell.prop("find", {
+        findStr: searchText,
+        currentCellId,
+        currentIndex: flagIndex,
+      });
+      flag = item.count;
+    })
+  }
+
+  return {
+    findCount:count,
+    currentIndex: current,
+    setInstance,
+    handleFind,
+    handleReplace,
+    handleReplaceAll,
+    handleClose,
+    handleFindNext,
+    handleFindPrev,
+  }
+}

+ 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'
+import { useFindReplace } from '@/hooks/useFindReplace'
 
 export default function GraphModel() {
   const [graph, setGraph] = useState<Graph>();

+ 6 - 6
apps/designer/src/models/mindMapModel.ts

@@ -81,26 +81,26 @@ export default function mindMapModel() {
         mindProjectInfo?.pageSetting
       ) as MindMapProjectInfo["pageSetting"];
       const pageSetting = pageSettingRef.current;
-      if (pageSetting?.fillType === "color") {
+      if (pageSetting?.backgroundType === "color") {
         graph.drawBackground({
-          color: pageSetting?.fill,
+          color: pageSetting?.backgroundColor,
         });
       } else {
         graph.drawBackground({
-          image: pageSetting?.fillImageUrl,
+          image: pageSetting?.backgroundImage,
           repeat: "repeat",
         });
       }
       // 设置水印
-      if (pageSetting.showWatermark && pageSetting.watermark) {
+      if (pageSetting.showWatermark && pageSetting.watermarkText) {
         const canvas = document.createElement("canvas");
-        canvas.width = pageSetting.watermark.length * 16;
+        canvas.width = pageSetting.watermarkText.length * 16;
         canvas.height = 100;
         const ctx = canvas.getContext("2d");
         if (ctx) {
           ctx.fillStyle = "#aaa";
           ctx.font = "16px Arial";
-          ctx.fillText(pageSetting.watermark, 1, 15);
+          ctx.fillText(pageSetting.watermarkText, 1, 15);
         }
         const img = canvas.toDataURL();
 

+ 42 - 2
apps/designer/src/pages/flow/components/ToolBar/index.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
 import styles from "./index.less";
 import {
   Button,
@@ -29,6 +29,8 @@ import {
 import CustomColorPicker from "@/components/CustomColorPicker";
 import { ConnectorType } from "@/enum";
 import { set, cloneDeep } from "lodash-es";
+import FindReplaceModal from "@/components/FindReplaceModal";
+import { useFindReplace } from "@/hooks/useFindReplace";
 
 export default function ToolBar() {
   const {
@@ -57,6 +59,24 @@ export default function ToolBar() {
     connectorType: ConnectorType.Normal,
   });
 
+  const {
+    handleFind,
+    handleReplace,
+    handleReplaceAll,
+    handleClose,
+    handleFindNext,
+    handleFindPrev,
+    findCount,
+    currentIndex,
+    setInstance
+  } = useFindReplace(graph);
+
+  useEffect(() => {
+    graph && setInstance(graph);
+  }, [graph])
+
+  const findModalRef = useRef<any>();
+
   const hasNode = useMemo(() => {
     return selectedCell?.find((cell) => cell.isNode());
   }, [selectedCell]);
@@ -665,8 +685,28 @@ export default function ToolBar() {
             </Button>
           </Dropdown> */}
         </div>
-
+        <FindReplaceModal
+          ref={findModalRef}
+          current={currentIndex}
+          count={findCount}
+          onClose={handleClose}
+          onFind={handleFind}
+          onFindNext={handleFindNext}
+          onFindPrev={handleFindPrev}
+          onReplace={handleReplace}
+          onReplaceAll={handleReplaceAll}
+        />
         <div>
+          <Tooltip placement="bottom" title="替换">
+            <Button
+              type="text"
+              icon={<i className="iconfont icon-chaxun" />}
+              className="m-r-16px"
+              onClick={() => {
+                findModalRef.current?.open();
+              }}
+            />
+          </Tooltip>
           <Tooltip placement="bottom" title="样式">
             <Button
               type="text"

+ 9 - 9
apps/designer/src/pages/mindmap/components/Config/PageStyle.tsx

@@ -8,7 +8,7 @@ export default function PageStyle() {
   const { mindProjectInfo, setMindProjectInfo } = useModel("mindMapModel");
 
   const pageSetting = mindProjectInfo?.pageSetting;
-  const [watermark, setWatermark] = useState(pageSetting?.watermark);
+  const [watermark, setWatermark] = useState(pageSetting?.watermarkText);
 
   const handleSetPageSetting = (key: string, value: any) => {
     if (pageSetting) {
@@ -33,24 +33,24 @@ export default function PageStyle() {
               { label: "纯色", value: "color" },
               { label: "图片", value: "image" },
             ]}
-            value={pageSetting?.fillType}
-            onChange={(value) => handleSetPageSetting("fillType", value)}
+            value={pageSetting?.backgroundType}
+            onChange={(value) => handleSetPageSetting("backgroundType", value)}
           />
           <CustomColorPicker
-            disabled={pageSetting?.fillType !== "color"}
-            color={pageSetting?.fill}
-            onChange={(color) => handleSetPageSetting("fill", color)}
+            disabled={pageSetting?.backgroundType !== "color"}
+            color={pageSetting?.backgroundColor}
+            onChange={(color) => handleSetPageSetting("backgroundColor", color)}
           />
         </div>
-        {pageSetting?.fillType === "image" && (
+        {pageSetting?.backgroundType === "image" && (
           <div className="flex justify-between items-center gap-16px">
             <span>图片地址</span>
             <Input
               className="flex-1"
               placeholder="请输入图片地址"
-              value={pageSetting?.fillImageUrl}
+              value={pageSetting?.backgroundImage}
               onChange={(e) =>
-                handleSetPageSetting("fillImageUrl", e.target.value)
+                handleSetPageSetting("backgroundImage", e.target.value)
               }
             />
           </div>

+ 4 - 4
apps/designer/src/types.d.ts

@@ -196,16 +196,16 @@ export interface MindMapProjectInfo {
   theme: string;
   structure: StructureType;
   pageSetting: {
-    fillType: "color" | "image";
-    fill: string;
-    fillImageUrl: string;
+    backgroundType: "color" | "image";
+    backgroundColor: string;
+    backgroundImage: string;
     branchY: number;
     branchX: number;
     subTopicY: number;
     subTopicX: number;
     alignSameTopic: boolean;
     showWatermark: boolean;
-    watermark: string;
+    watermarkText: string;
   };
   topics: any[];
 }

+ 1 - 0
package.json

@@ -54,6 +54,7 @@
     "axios": "^1.7.7",
     "insert-css": "^2.0.0",
     "lodash-es": "^4.17.21",
+    "react-draggable": "^4.4.6",
     "thememirror": "^2.0.1",
     "umi": "^4.3.18",
     "unocss": "^0.62.3"

+ 20 - 0
pnpm-lock.yaml

@@ -116,6 +116,9 @@ importers:
       lodash-es:
         specifier: ^4.17.21
         version: 4.17.21
+      react-draggable:
+        specifier: ^4.4.6
+        version: 4.4.6(react-dom@18.3.1)(react@18.3.1)
       thememirror:
         specifier: ^2.0.1
         version: 2.0.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)
@@ -6504,6 +6507,11 @@ packages:
     engines: {node: '>=0.8'}
     dev: true
 
+  /clsx@1.2.1:
+    resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
+    engines: {node: '>=6'}
+    dev: false
+
   /codemirror@6.0.1(@lezer/common@1.2.1):
     resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==}
     dependencies:
@@ -12468,6 +12476,18 @@ packages:
       react: 18.3.1
       scheduler: 0.23.2
 
+  /react-draggable@4.4.6(react-dom@18.3.1)(react@18.3.1):
+    resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==}
+    peerDependencies:
+      react: '>= 16.3.0'
+      react-dom: '>= 16.3.0'
+    dependencies:
+      clsx: 1.2.1
+      prop-types: 15.8.1
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+    dev: false
+
   /react-error-overlay@6.0.9:
     resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==}
     dev: false