在工作和学习中少不了用思维导图作为工具。那么如何开发一个在线的思维导图呢?
在线的demo
demo源码
思维导图功能分析
先分析一下xmind生成的基本的思维导图的功能,如下图。
- 思维导图分支增加、删除、内容修改
- 思维导图分支节点收起与展开
- 思维导图不同的分支之间不能交叉重叠
- 思维导图拖拽、放大、缩小等功能
以上是思维导图基本的功能
研发功能分析
- 数据结构设计
- 分支与按钮位置计算
- 分支连线的实现方案
- 鼠标拖拽画布,双击放大
- 结点点击折叠功能
思考分析
- 数据结构直观上看起来特别符合 树形结构 ,这里就暂定树形结构,随着研发深入,有需要再调整
- 按钮到底是选择input输入框、还是button、还是div?
由于按钮里的内容是需要输入、编辑的,并且输入的时候要求换行,否责按钮的文案太长的时候不方便操作。
这里我们采用div,因为div也可以通过contentEditable属性来控制是否需要可编辑文本。 - 按钮之间的分支连线如何实现?
可选方案有:canvas 和 svg
svg相对简单这里选定svg作为线条的实现方案。 - 按钮、线条这里的位置都通过绝对定位来设置。因为按钮和线条的位置都是相互影响的。
- 点击放大与画面拖拽这里直接采用改变transform的属性值来实现。transform在渲染性能上相较其它方案要好一些。
- 为了简化文案和操作,我们先只实现右向的导图功能,上下左右的实现思想是一样的。
以上是开发之前比较容易分析出来的一些需求和解决方案。
开始开发
准备工作
-
技术选型,选择自己拿手的技术栈即可。我用的 React + TS + ViteJS
-
如果跟我一样的技术栈,执行命令:
npm create vite mindmap-demo根据提示选择React 和 TypeScript即可
-
项目创建完成之后,在src目录里面创建几个常用的目录:
-
src/components 作为存放 组件 的目录 -
src/const 作为 包含一些常量的文件 的目录 -
src/utils 作为 包含一些常用工具的文件 的目录 -
src/store 作为 包含全局数据的文件 的目录
- 安装一下 @types/node 这个包,否责使用node自带包会报TS错误
- 为了在引入组件时路径统一,我们提前把别名在vite.config.ts和tsconfig.json配置好,参考demo即可
最终得到类似下图的一个工作目录:
一、创建导图画布容器 - 其实就是一个div盒子作为思维导图的操作区域
把自动生成的src/App.tsx 和 src/App.css 修改一下即可
为了简单我们直接把盒子设置成宽高都是100%,然后给一个顺眼的颜色。
代码如下:
参考 src/App.tsx src/App.css
二、创建导图画布 - 其实就是一个div盒子,所有的按钮和线条都会放到这个盒子里
- 在components里面创建一个 canvas组件包括tsx文件和css文件。
- 这个画布尺寸需要设置很大,因为思维导图可能会很大。
参考 src/components/canvas/*
三、导图结点相关功能开发
1.节点数据结构设计 - 下面是节点的TS类型
interface TreeNode {
// 节点唯一标识
id: string;
// 父节点标识
pid: string;
// 节点文本
topic: string;
// 节点按钮横坐标
x?: number;
// 节点按钮右边的x坐标 - 画连线的时候有用
rx?: number;
// 节点按钮纵坐标
y?: number;
// 节点按钮中间的y坐标 - 画连线的时候有用
cy?: number;
// 节点按钮宽度
w?: number;
// 当前节点纵坐标的上限
topBoundaryPosition?: number;
// 当前节点特有的class
className?: string[];
// 线条颜色
lineColor: string;
// 子节点
children: TreeNode[];
// 是否为编辑状态
isEditing?: boolean;
// 是否收起
isFold: boolean;
}
这些数据类型可以统一管理,放到src/typings/.d.ts文件里,参考:src/typeings/
2.节点渲染到浏览器
由于我们设计数据结构的时候,设计的结构是 树形结构渲染比较麻烦一点,因此我们渲染的时候把树形结构进行扁平化处理,生成一个扁平数组。这里可以通过广度优先遍历(BFS) 的方式实现
为了方便开发我们把每一个topic按钮单独立做一个组件RichText
这时候我们可以轻松把按钮渲染出来,代码如下:
// src/components/canvas/index.tsx
import "./index.css";
import { useEffect, useState } from "react";
import generateBaseMap from "@/utils/generateBaseMap";
import { flatten } from "@/utils/tools";
import RichText from "./rich-text";
function App() {
// 原始树结构
const [tree, setTree] = useState<mpcanvas.TreeNode>(generateBaseMap());
// 扁平过的树结构
const [flattendTree, setFlattendTree] = useState<mpcanvas.TreeNode[]>([]);
useEffect(() => {
setFlattendTree(flatten(tree));
}, [tree]);
return (
<div className="mp--canvas">
{flattendTree.map((node, index) => (
<RichText key={node.id} node={node} />
))}
</div>
);
}
export default App;
下面是 节点最基本的代码,就是显示节点文案和显示位置。还需要增加新增、编辑、删除等代码
// src/components/canvas/rich-text.tsx
import "./rich-text.css";
import React from "react";
const App = React.memo(RichText);
/**
*
* @param index {Number} 这里代表当前渲染的结点是哪一级
* @returns
*/
function RichText({ node }: richtext.Props) {
return (
<>
<div
className={"mp--canvas__richtext"}
style={{
left: node.x,
top: node.y,
width: node.w + "px",
}}
>
<span>{node.topic}</span>
</div>
</>
);
}
export default App;
3.节点的坐标计算 - 难点
-
节点横坐标x的值 = 父节点的横坐标 + 父节点宽度 + 节点之间的横向间隔
节点之间的横向间隔 : 人为设置的。这里我写的 100 . 节点的横坐标 : 根节点人为设置的,其它是动态计算的。这里为了写的 x = 100 节点的宽度:节点文案不一样导致节点宽度不一样。为了准确获取当前的宽度,提供的方案是: 1、设置一个与当前节点盒子完全一样的shadow box,但是shadow box的visibility:hidden,且是脱离文档流的 2、把节点里的内容放到shadow box 3、然后获取shadow box的宽、高。 4、获取的宽、高就是当前盒子会占用的宽、高 -
节点右边框的横坐标rx的值 = 节点横坐标 + 节点宽度
-
节点中心点纵坐标cy的值 = 节点的上限位置(topBoundaryPosition) + (节点高度+节点纵向间隔)/2
节点上限位置:父节点中心点纵坐标 - (是指该节点所有末尾节点的高度之和 + 纵向间隔之和 ) / 2
难点:就是计算当前节点拥抱的子节点所占的总高度,如果占的高度不够,就会发生重叠。
在未编辑的时候,只需要把当前节点所有的末级节点加起来然后计算他们占的总高度即可。
在编辑状态的时候,需要判断所有末级节点的所占高度是否比当前编辑的节点高度高,这里取比较高的那一个
4.编辑框的个种细节问题 - 难点
- 节点的盒里面需要再嵌套一个专门用来编辑的盒子,否则获取里面的内容的时候有问题
- 光标错位,这个光标由于react rerender的原因总是显示的位置错误,不能直接修改当前节点的内容,需要有一个临时的编辑节点。
- 选中与编辑状态,为了保证选中和编辑状态唯一,这些状态当作全局的数据保存到了store里面。这里全局状态是保存到了 context里面了
- 选中与编辑切换,当点击节点的时候,首先判断节点是否处于选中或编辑状态,如果是普通状态就把节点id放到focusId。如果是编辑状态editId改成普通状态节点id就可以了
5.渲染连线
- 渲染连线比如简单,主要就是父节点与子节点枢纽点和每个子节点之间的连
这里利用的是 svg path的能力,主要属性是d配合 M(用于移动到某点) 和 L(当前的点联接到的节点)。
具体的坐标前面已经计算出来了。
父节点联线起点(父节点rx, 父节点cy)
联线枢纽点 (父节点rx + 50, 父节点cy)
子节点联线终点(子节点x,子节点cy) - 这里还有个细节就是获取线条的颜色,主要就是从默认颜色里面获取一新的颜色,这块可以自由发挥颜色生成方法
6. 渲染枢纽点
这里要注意的是如果节点状态是fold就不用继续渲染了
7. 节点的增加、删除、修改
增加:
a. 节点id的生成需要唯一
b.如果是紧联着根节点需要把线条颜色生成一下,否则直接使用父节点的颜色
c.生成节点之后,节点可以直接输入topic,并且光标是focus的状态,生成的新节点数据直接push到父节点的children即可
删除:根据pid直接在扁平数组里面找到对应的parentnode,然后直接把parentnode children中当前节点删掉就行
修改:注意状态的切换
8. 画布拖拽、放大缩小
思路:为了方便复用,我们单独写一个组件专门实现拖拽功能。
拖拽的时候只需要修改trasform: translate()的值就可以了。
难点:
1、放大缩小实现的时候注意加一个限制,不能太大也不能太小
2、通过鼠标或者触摸板放大缩小的wheel事件是同一个事件。注意用event.ctrlKey来区分到底是划动操作还是tab或者点击操作。