jsmind思维导图撤销和前进的实现
最近在做封装一个基于vue的思维导图的项目,项目中遇到了关于编辑思维导图节点的时候的撤销前进功能。
该思维导图使用到的库是jsmind的库:hizzgdev.github.io/jsmind/deve…,然后在此基础上对其进行了二次封装。因为jsmind使用过的人不是很多,而且都没有这个需求,所以可能里面说的内容有点晦涩难懂,但是了解了这个文档之后,在此看着这篇文章就不会那么难了。
写这篇文章的目的,一是给以后要维护这个项目的小伙伴看,二是分享这个问题的解决方案。虽然这个解决方案的逻辑不是很难,但是里面有踩过的坑,希望能给以后使用到这个库的小伙伴有点帮助。
数据结构和解释
storeMap: new Map(), // 记录编辑操作的存储 , 你的每次编辑的操作,都会被记录在map集合里面 opearaoptorSetp: 0, // 记录操作的步长,也就说到哪个位置了 // 用来存储每一次操作数据结构 opreatorParams: { type: "", // 操作的类型 selectedNode: {}, // 选中的节点id selectedParantNode: {}, // 选中的父节点的id opreatorContent: {}, //本次操作涉及到的内容 oldNode:{}, // 更新该结点之后的老节点信息 },复制代码
(storeMap中存储的数据)
(每一步操作存储的数据)
selectedNode
和selectedParantNode
数据类型结构
{ id: '', // 节点的id topic: [], // 节点的文字 children: [], // 该节点的子节点 data: {}, // 该节点存储的数据 parent: { // 该节点的父节点 id: '', // 父节点的id topic: '', // 父节点的文字 data:{}, // 父节点存储的数据 },复制代码
storeMap里面存储的是依据opearaoptorSetp
和opreatorParams
,以opearaoptorSetp
作为key值,以opreatorParams
作为value。
但是有人就说了,既然你用数字作为key值,不如直接用数组的方式存储了。这样说也没毛病,当初设计的时候,考虑到的是,因为在编辑思维导图的时候,可能会涉及到的插入,删除,增加频率会很高,所以使用了Map进行了存储。
关于撤销和前进操作的,我们先理解一下大致的步骤
1、当增,删,改节点的时候,opearaoptorSetp也会增加一次步长 , 都会将该次的操作加入到storeMap中
2、如果是撤销的话,退回一步,然后取到storeMap在这一步的值,进行还原
撤销的过程中,如果下一次操作是删除,添加,或者是更新节点操作,会覆盖之前的操作,然后将这一步之后的所有操作清空。
假设:
增加:【1,2,3,4,5,6】
后退到2:【1,2】
再次添加一个节点:【1,2,3(覆盖了第一次的3步骤)】,之后清空3后的所有操作。
3、前进的话,根据步长去storeMap中去取就可以。
图解:
(图一 增加)
(图二 修改)
(图三 后退)
(图四 后退-删除)
opreatorParams的中的type是用来标识本次的操作类型
add:增加 , 对立:remove删除
delete:删除, 对立:add增加 (将remove和delete区别,remove是后退过程的设置,delete是主动的删除)
modify:修改,modify没有对立,但是在修改的时候,会增加一个oldNode的属性。modify的撤销和返回和add/delete的操作是不同的。add/delete的撤回(前进)时候,会先退(前进)一步,取数据然后在处理逻辑,而modify的则是保持原有的步长,如果是后退,则去取oldNode里面的值,反之去selectedNode中取值。
主要代码实现:
1、新增节点
新增节点包含的是兄弟节点和子孙节点两种情况
两种新增的节点,除了父节点(operatorNodedParant
)的不同,其余数据相同。addNode的触发是通过键盘事件监听tab、enter或者是手动点击触发的。其实关于增加节点,根据文档上的api,只需要提供父节点,增加节点的id和内容就行了,一步操作this.jm.add_node(selectedNode, nodeid, topic);
,剩下的代码就是维护storeMap
和opearatorSetp
的,毕竟这两个记录了后退和前进的数据。
addNode(selectedNode, type) { if (selectedNode.isroot && type === "sibling") return; if (type === "sibling") { selectedNode = selectedNode.parent; } let nodeid = jsMind.util.uuid.newid(); let topic = "New Node"; this.jm.add_node(selectedNode, nodeid, topic); // 预置前进后退操作 // 1.先设置号操作的参数,分为添加兄弟和同级两种 let params = { type: "add", }; const tempNode = this.jm.get_node(nodeid); const tempAddNode = { id: tempNode.id, topic: tempNode.topic, children: this.copyNode(tempNode.children), data: tempNode.data, }; if (!tempNode.isroot) { tempAddNode.parent = { id: tempNode.parent.id, topic: tempNode.parent.topic, data: tempNode.parent.data, }; } params.operatorNode = tempAddNode; params.operatorNodedParant = tempAddNode.parent; params.opreatorContent = tempAddNode; // 2.缓存此步数据 this.storeMap.set(this.opearatorSetp, params); // 3.步长+1 this.opearatorSetp++; this.clearTailNode(); console.log("新增,缓存", this.storeMap, this.opearatorSetp); },复制代码
2、删除一个节点: 删除节点包含两种删除,一种是正常的删除,另一种是撤回的时候删除(撤回的时候,增加的相反就是删除)。删除的时候,先退回一步,然后取得应该删除的那个节点(这个函数没有这一步,传来的selectedNode就是要删除的节点)。
至于为什么要分为两种呢。是因为,当我们正常删除的时候我们要将删除节点这一步记录在storeMap
中,并且操作步骤opearatorSetp
要加一次,并且清理删除之后所有的操作(这个步骤参考上面的撤销步骤)。但是如果是撤回的时候删除,我们应该是opearatorSetp减去一次,拿到之前存储的那个上一步的存储信息,然后恢复上一步的操作。
fastGoBack
是撤销,back
是正常删除。
// 删除节点 onRemoveNode(selectedNode, type) { // 先设置 if (["fastGoBack", "delete"].includes(type)) { /** * 1.先缓存,如果不缓存直接使用selectedNode,那么删除之后,就没有selectedNode,而且设置在map中的数据又是selectedNode的引用地址,所以到时候this.storeMap.set(this.opearatorSetp, params)数据会出错 * */ let params = { type: type === "fastGoBack" ? "remove" : "delete" }; let tempSelectedNode = { id: selectedNode.id, topic: selectedNode.topic, children: this.copyNode(selectedNode.children), data: selectedNode.data, parent: { id: selectedNode.parent.id, topic: selectedNode.parent.topic, data: selectedNode.parent.data, }, }; // 设置数据 params.operatorNode = tempSelectedNode; params.operatorNodedParant = tempSelectedNode.parent; params.opreatorContent = tempSelectedNode; this.storeMap.set(this.opearatorSetp, params); // 右键删除也要增加一步,快捷键删除,上一步已经修改完opearatorSetp if (type === "delete") this.opearatorSetp++; } this.jm.remove_node(selectedNode.id); // 删除之后,清理后面的数据 if (type === "delete") { this.clearTailNode(); } console.log("删除,缓存", this.storeMap, this.opearatorSetp); },复制代码
3、修改,修改节点暂时不提供,因为涉及到的这个库里面的默认修改方法
4、前进和后退
这两个函数是监听键盘事件,执行撤销和前进的回调。
// 前进 handleGoForward() { // 因为前进的时候opearatorSetpy一直处于累加的状态,所以有限制的长度,不能超过操作步骤的长度 let limitLength = Array.from(this.storeMap).length; if (this.opearatorSetp > limitLength - 1) return; // 如果是增加的时候,撤回的时候应该是对应的是删除,注意此时传入的type是‘fastGoBack’ const handleNode = this.storeMap.get(this.opearatorSetp); if (handleNode.type === "add") { this.onRemoveNode(handleNode.operatorNode, "fastGoBack"); } else if (["remove", "delete"].includes(handleNode.type)) { // 删除的时候,对应的是插入 handleNode.type = "add"; const parentNodeId = handleNode.operatorNodedParant.id; // 退后的节点是删除节点,将其插入 this.insertNode(parentNodeId, handleNode.operatorNode); } else if (handleNode.type === "modify") { // 修改的时候,修改应该是更新节点上的信息 const tempMap = this.storeMap.get(this.opearatorSetp); this.jm.update_node( tempMap.operatorNode.id, tempMap.operatorNode.topic ); } this.opearatorSetp++; console.log("前进,缓存", this.storeMap, this.opearatorSetp); }, // 后退,后退和前进类似,只是操作相反而已!!! handleGoBack() { // 先拿到操作的节点 this.opearatorSetp--; let handleNode = this.storeMap.get(this.opearatorSetp); if (handleNode.type === "add") { // 后退的节点是增加节点,将其删除 this.onRemoveNode(handleNode.operatorNode, "fastGoBack"); } else if (["remove", "delete"].includes(handleNode.type)) { const parentNodeId = handleNode.operatorNodedParant.id; handleNode.type = "add"; // 退后的节点是删除节点,将其插入 this.insertNode(parentNodeId, handleNode.operatorNode); // } else if (handleNode.type === "modify") { const tempMap = this.storeMap.get(this.opearatorSetp); this.jm.update_node(tempMap.operatorNode.id, tempMap.oldNode.topic); } console.log("后退,缓存", this.storeMap, this.opearatorSetp); },复制代码
工具函数:
// 获取选中标签的 ID getNode() { let selectedNode = this.jm.get_selected_node(); if (!selectedNode) { this.$Message.warning("请先选择一个节点"); return false; } return selectedNode; }, // 克隆一个节点,深入克隆一个节点,包含他的子节点 copyNode(node) { if (!node.length) return []; return node.map((item) => { return { id: item.id, topic: item.topic, children: this.copyNode(item.children), data: item.data, parent: { id: item.parent.id, topic: item.parent.topic, data: item.parent.data, }, }; }); }, // 清除尾节点 clearTailNode() { let arr = Array.from(this.storeMap); for (let i = this.opearatorSetp; i < arr.length; i++) { this.storeMap.delete(i); } }, // 插入节点 insertNode(parentNodeId, node) { let parentNode = this.jm.get_node(parentNodeId); this.jm.add_node(parentNode, node.id, node.topic, node.data); if (!node.children) return; node.children.forEach((item) => { this.insertNode(item.parent.id, item); }); }, // 克隆一个节点 copyNode(node) { if (!node.length) return []; return node.map((item) => { return { id: item.id, topic: item.topic, children: this.copyNode(item.children), data: item.data, parent: { id: item.parent.id, topic: item.parent.topic, data: item.parent.data, }, }; }); },复制代码
总结一下吧:
有一说一,这篇文章大部分对于前端开发者来说,一点用处没有,只有特殊的场景才会用的到。
而且,对于实现一个前进和撤销的方案,应该有很多解决方案,定义上面这种数据结构进行存储的话,少之又少,所以就当图个乐看看吧。
作者:一起要跳舞吗
链接:https://juejin.cn/post/7015818820564549646