乐趣区

关于javascript:Web思维导图实现的技术点分析附完整源码

简介

思维导图是一种常见的表白发散性思维的无效工具,市面上有十分多的工具能够用来画思维导图,有收费的也有免费的,此外也有一些能够用来帮忙疾速实现的 JavaScript 类库,如:jsMind、KityMinder。

本文会残缺的介绍如何从头实现一个繁难的思维导图,最终成绩预览:https://wanglin2.github.io/mind-map/。

技术选型

这种图形类的绘制个别有两种抉择:svgcanvas,因为思维导图次要是节点与线的连贯,应用与html 比拟靠近的 svg 比拟容易操作,svg的类库在试用了 svgjs 和 snap 后,有些需要在 snap 里没有找到对应的办法,所以笔者最终抉择了svgjs

为了能跨框架应用,所以思维导图的主体局部作为一个独自的 npm 包来开发及公布,通过 的形式来组织代码,示例页面的开发应用的是 vue2.x 全家桶。

整体思路

笔者最后的思路是先写一个渲染器,依据输出的思维导图数据,渲染成 svg 节点,计算好各个节点的地位,而后显示到画布,最初给节点连上线即可,接下来对思维导图的操作都只须要保护这份数据,数据变动了就清空画布,而后从新渲染,这种数据驱动的思维很简略,在最后的开发中也没有任何问题,所有都很顺利,因为模仿数据就写了四五个节点,然而起初当我把节点数量减少到几十个的时候,发现凉了,太卡了,点击节点激活或者开展膨胀节点的时候一秒左右才有反馈,就算只是个 demo 也无奈让人承受。

卡的起因一方面是因为计算节点地位,每种布局构造起码都须要三次遍历节点树,加上一些计算逻辑,会比拟耗时,另一方面是因为渲染节点内容,因为一个思维导图节点除了文本,还要反对图片、图标、标签等信息、svg不像 html 会主动按流式布局来帮你排版,所以每种信息节点都须要手动计算它们的地位,所以也是很耗时的一个操作,并且因为 svg 元素也算是 dom 节点,所以数量多了又要频繁操作,当然就卡了。

卡顿的起因找到了,怎么解决呢?一种办法是不必 svg,改用canvas,然而笔者发现该问题的时候曾经写了较多代码,而且就算用canvas 树的遍历也无奈防止,所以笔者最初采纳的办法的是不再每次都齐全从新渲染,而是按需进行渲染,比方点击节点激活该节点的时候,不须要从新渲染其余节点,只须要从新渲染被点击的节点就能够了,又比方某个节点膨胀或开展时,其余节点只是地位须要变动,节点内容并不需要从新渲染,所以只须要从新计算其余节点的地位并把它们挪动过来即可,这样额定的益处是还能够让它们通过动画的形式挪动过来,其余相干的操作也是如此,尽量只更新必要的节点和进行必要的操作,革新完后尽管还是会存在肯定卡顿的景象,然而相比之前曾经好了很多。

数据结构

思维导图能够看成就是一棵树,我把它称作渲染树,所以根本的构造就是树的构造,每个节点保留节点自身的信息再加上子节点的信息,具体来说,大略须要蕴含节点的各种内容(文本、图片、图标等固定格局)、节点开展状态、子节点等等,此外还要包含该节点的公有款式,用来笼罩主题的默认款式,这样能够对每个节点进行个性化:

{
  "data": {
    "text": "根节点",
    "expand": true,
    "color": "#fff",
    // ...
    "children": []}

具体构造可参考:节点构造。

仅有这棵渲染树是不够的,咱们须要再定义一个节点类,当遍历渲染树的时候,每个数据节点都会创立一个节点实例,用来保留该节点的状态,以及执行渲染、计算宽高、绑定事件等等相干操作:

// 节点类
class Node {constructor(opt = {}) {
    this.nodeData = opt.data// 节点实在数据,就是上述说的渲染树的节点
    this.isRoot =  opt.isRoot// 是否是根节点
    this.layerIndex = opt.layerIndex// 节点层级
    this.width = 0// 节点宽
    this.height = 0// 节点高
    this.left = opt.left || 0// left
    this.top = opt.top || 0// top
    this.parent = opt.parent || null// 父节点
    this.children = []// 子节点
    // ...
  }
  
  // ...
}

因为一个节点可能蕴含文本、图片等多种信息,所以咱们应用一个 g 元素来作为节点容器,文本就创立一个 text 节点,须要边框的话就再创立一个 rect 节点,节点的最终大小就是文本节点的大小再加上内边距,比方咱们要渲染一个带边框的只有文本的节点:

import {
    G,
    Rect,
    Text
} from '@svgdotjs/svg.js'
class Node {constructor(opt = {}) {
    // ...
    this.group = new G()// 节点容器
    this.getSize()
    this.render()}
  // 计算节点宽高
  getSize() {let textData = this.createTextNode()
    this.width = textData.width + 20// 左右内边距各 10
    this.height = textData.height + 10// 高低内边距各 5
  }
  // 创立文本节点
  createTextNode() {let node = new Text().text(this.nodeData.data.text)
    let {width, height} = node.bbox()// 获取文本节点的宽高
    return {
      node,
      width,
      height
    }
  }
  // 渲染节点
  render() {let textData = this.createTextNode()
    textData.node.x(10).y(5)// 文字节点绝对于容器偏移内边距的大小
    // 创立一个矩形来作为边框
    this.group.rect(this.width, this.height).x(0).y(0)
    // 文本节点增加到节点容器里
    this.group.add(textData.node)
    // 在画布上定位该节点
    this.group.translate(this.left, this.top)
    // 容器增加到画布上
    this.draw.add(this.group)
  }
}

如果还须要渲染图片的话,就须要再创立一个 image 节点,那么节点的总高度就须要再加上图片的高,节点的总宽就是图片和文字中较宽的那个大小,文字节点的地位计算也须要依据节点的总宽度及文字节点的宽度来计算,须要再渲染其余类型的信息也是一样,总之,所有节点的地位都须要自行计算,还是有点繁琐的。

节点类残缺代码请看:Node.js。

逻辑结构图

思维导图有多种构造,咱们先看最根底的【逻辑结构图】如何进行布局计算,其余的几种会在下一篇里进行介绍。

逻辑结构图如上图所示,子节点在父节点的右侧,而后父节点绝对于子节点总体来说是垂直居中的。

节点定位

这个思路源于笔者在网上看到的,首先根节点咱们把它定位到画布两头的地位,而后遍历子节点,那么子节点的 left 就是根节点的left + 根节点的width+ 它们之间的间距marginX,如下图所示:

而后再遍历每个子节点的子节点(其实就是递归遍历)以同样的形式进行计算 left,这样一次遍历实现后所有节点的left 值就计算好了。

class Render {
  // 第一次遍历渲染树
  walk(this.renderer.renderTree, null, (cur, parent, isRoot, layerIndex) => {
    // 先序遍历
    // 创立节点实例
    let newNode = new Node({
      data: cur,// 节点数据
      layerIndex// 层级
    })
    // 节点实例关联到节点数据上
    cur._node = newNode
    // 根节点
    if (isRoot) {
      this.root = newNode
      // 定位在画布核心地位
      newNode.left = (this.mindMap.width - node.width) / 2
      newNode.top = (this.mindMap.height - node.height) / 2
    } else {// 非根节点
      // 相互收集
      newNode.parent = parent._node
      parent._node.addChildren(newNode)
      // 定位到父节点右侧
      newNode.left = parent._node.left + parent._node.width + marginX
    }
  }, null, true, 0)
}

接下来是 top,首先最开始也只有根节点的top 是确定的,那么子节点怎么依据父节点的 top 进行定位呢?下面说过每个节点是绝对于其所有子节点居中显示的,那么如果咱们晓得所有子节点的总高度,那么第一个子节点的 top 也就确定了:

firstChildNode.top = (node.top + node.height / 2) - childrenAreaHeight / 2

如图所示:

第一个子节点的 top 确定了,其余节点只有在前一个节点的 top 上累加即可。

那么怎么计算 childrenAreaHeight 呢?首先第一次遍历到一个节点时,咱们会给它创立一个 Node 实例,而后触发计算该节点的大小,所以只有当所有子节点都遍历完回来后咱们能力计算总高度,那么显然能够在后序遍历的时候来计算,然而要计算节点的 top 只能在下一次遍历渲染树时,为什么不在计算完一个节点的 childrenAreaHeight 后立刻就计算其子节点的 top 呢?起因很简略,以后节点的 top 都还没确定,怎么确定其子节点的地位呢?

// 第一次遍历
walk(this.renderer.renderTree, null, (cur, parent, isRoot, layerIndex) => {
  // 先序遍历
  // ...
}, (cur, parent, isRoot, layerIndex) => {
  // 后序遍历
  // 计算该节点所有子节点所占高度之和,包含节点之间的 margin、节点整体前后的间距
  let len = cur._node.children
  cur._node.childrenAreaHeight = cur._node.children.reduce((h, node) => {return h + node.height}, 0) + (len + 1) * marginY
}, true, 0)

总结一下,在第一轮遍历渲染树时,咱们在先序遍历时创立 Node 实例,而后计算节点的left,在后序遍历时计算每个节点的所有子节点的所占的总高度。

接下来开启第二轮遍历,这轮遍历能够计算所有节点的top,因为此时节点树曾经创立胜利了,所以能够不必再遍历渲染树,间接遍历节点树:

// 第二次遍历
walk(this.root, null, (node, parent, isRoot, layerIndex) => {if (node.children && node.children.length > 0) {
    // 第一个子节点的 top 值 = 该节点核心的 top 值 - 子节点的高度之和的一半
    let top = node.top + node.height / 2 - node.childrenAreaHeight / 2
    let totalTop = top + marginY// node.childrenAreaHeight 是包含子节点整体前后的间距的
    node.children.forEach((cur) => {
      cur.top = totalTop
      totalTop += cur.height + marginY// 在上一个节点的 top 根底上加上间距 marginY 和该节点的 height
    })
  }
}, null, true)

事件到这里并没有完结,请看下图:

能够看到对于每个节点来说,地位都是正确的,然而,整体来看就不对了,因为产生了重叠,起因很简略,因为【二级节点 1】的子节点太多了,子节点占的总高度曾经超出了该节点本身的高,因为【二级节点】的定位是根据【二级节点】的总高度来计算的,并没有思考到其子节点,解决办法也很简略,再来一轮遍历,当发现某个节点的子节点所占总高度大于其本身的高度时,就让该节点前后的节点都往外挪一挪,比方上图,假如子节点所占的高度比节点本身的高度多出了100px,那咱们就让【二级节点 2】向下挪动50px,如果它下面还有节点的话也让它向上挪动50px,须要留神的是,这个调整的过程须要始终往父节点上冒泡,比方:

【子节点 1 -2】的子元素总高度显著大于其本身,所以【子节点 1 -1】须要往上挪动,这样显然还不够,假如下面还有【二级节点 0】的子节点,那么它们可能也要产生重叠了,而且下方的【子节点 2 -1-1】和【子节点 1 -2-3】显然挨的太近了,所以【子节点 1 -1】本人的兄弟节点调整完后,父节点【二级节点 1】的兄弟节点也须要同样进行调整,下面的往上移,上面的往下移,始终到根节点为止:

// 第三次遍历
walk(this.root, null, (node, parent, isRoot, layerIndex) => {// 判断子节点所占的高度之和 ((除去子节点整体前后的 margin)) 是否大于该节点本身
  let difference = node.childrenAreaHeight - marginY * 2 - node.height
  // 大于则前后的兄弟节点须要调整地位
  if (difference > 0) {this.updateBrothers(node, difference / 2)
  }
}, null, true)

updateBrothers用来向上递归挪动兄弟节点:

updateBrothers(node, addHeight) {if (node.parent) {
    let childrenList = node.parent.children
    // 找到本人处于第几个节点
    let index = childrenList.findIndex((item) => {return item === node})
    childrenList.forEach((item, _index) => {if (item === node) {return}
      let _offset = 0
      // 下面的节点往上移
      if (_index < index) {_offset = -addHeight} else if (_index > index) { // 上面的节点往下移
        _offset = addHeight
      }
      // 挪动节点
      item.top += _offset
      // 节点本身挪动了,还须要同步挪动其所有上级节点
      if (item.children && item.children.length) {this.updateChildren(item.children, 'top', _offset)
      }
    })
    // 向上遍历,挪动父节点的兄弟节点
    this.updateBrothers(node.parent, addHeight)
  }
}
// 更新节点的所有子节点的地位
updateChildren(children, prop, offset) {children.forEach((item) => {item[prop] += offset
    if (item.children && item.children.length) {this.updateChildren(item.children, prop, offset)
    }
  })
}

到此【逻辑结构图】的整个布局计算就实现了,当然,有一个小小小的问题:

就是严格来说,某个节点可能不再绝对于其所有子节点居中了,而是绝对于所有子孙节点居中,其实这样问题也不大,切实有强迫症的话,能够自行思考一下如何优化(而后偷偷通知笔者),这部分残缺代码请移步 LogicalStructure.js。

节点连线

节点定位好了,接下来就要进行连线,把节点和其所有子节点连接起来,连线格调有很多,能够应用直线,也能够应用曲线,直线的话很简略,因为所有节点的 lefttopwidthheight 都曾经晓得了,所以连接线的转折点坐标都能够轻松计算出来:

咱们重点看一下曲线连贯,如之前的图片所示,根节点的连线和其余节点的线是不一样的,根节点到其子节点的如下所示:

这种简略的曲线能够应用二次贝塞尔曲线,终点坐标为根节点的两头点:

let x1 = root.left + root.width / 2
let y1 = root.top + root.height / 2

起点坐标为各个子节点的左侧两头:

let x2 = node.left
let y2 = node.top + node.height / 2

那么只有确定一个控制点即可,具体这个点能够本人调节,找一个看的悦目的地位即可,笔者最终抉择的是:

let cx = x1 + (x2 - x1) * 0.2
let cy = y1 + (y2 - y1) * 0.8)

再看上级节点的连线:

能够看到有两段蜿蜒,所以须要应用三次贝塞尔曲线,也是一样,本人抉择两个适合的控制点地位,笔者的抉择如下图,两个控制点的 x 处于终点和起点的两头:

  let cx1 = x1 + (x2 - x1) / 2
  let cy1 = y1
  let cx2 = cx1
  let cy2 = y2

接下来给 Node 类加个渲染连线的办法即可:

class Node {
  // 渲染节点到其子节点的连线
  renderLine() {let { layerIndex, isRoot, top, left, width, height} = this
    this.children.forEach((item, index) => {
      // 根节点的连线终点在节点两头,其余都在右侧
      let x1 = layerIndex === 0 ? left + width / 2 : left + width
      let y1 = top + height / 2
      let x2 = item.left
      let y2 = item.top + item.height / 2
      let path = ''
      if (isRoot) {path = quadraticCurvePath(x1, y1, x2, y2)
      } else {path = cubicBezierPath(x1, y1, x2, y2)
      }
      // 绘制 svg 门路到画布
      this.draw.path().plot(path)
    })
  }
}

// 根节点到其子节点的连线
const quadraticCurvePath = (x1, y1, x2, y2) => {
  // 二次贝塞尔曲线的控制点
  let cx = x1 + (x2 - x1) * 0.2
  let cy = y1 + (y2 - y1) * 0.8
  return `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}`
}

// 其余节点到其子节点的连线
const cubicBezierPath = (x1, y1, x2, y2) => {
  // 三次贝塞尔曲线的两个控制点
  let cx1 = x1 + (x2 - x1) / 2
  let cy1 = y1
  let cx2 = cx1
  let cy2 = y2
  return `M ${x1},${y1} C ${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`
}

节点激活

点击某个节点就绝对于把它激活,为了能有点反馈,所以须要给它加一点激活的款式,通常都是给它加个边框,然而笔者不满足于此,笔者认为节点所有的款式,激活时都能够扭转,这样能够更好的与主题交融,也就是节点的所有款式都有两种状态,一般状态和激活状态,毛病是激活和勾销激活时的操作多了,会带来一点卡顿。

实现上能够监听节点的单击事件,而后设置节点的激活标记,因为同时是能够存在多个激活节点的,所以用一个数组来保留所有的激活节点。

class Node {bindEvent() {this.group.on('click', (e) => {e.stopPropagation()
      // 曾经是激活状态就间接返回
      if (this.nodeData.data.isActive) {return}
      // 革除以后曾经激活节点的激活状态
      this.renderer.clearActive()
      // 执行激活 点击节点的激活状态 的命令
      this.mindMap.execCommand('SET_NODE_ACTIVE', this, true)
      // 增加到激活列表里
      this.renderer.addActiveNode(this)
    })
  }
}

SET_NODE_ACTIVE命令会从新渲染该节点,所以咱们只有在渲染节点的逻辑里判断节点的激活状态来利用不同的款式即可,具体在后序的款式与主题大节里细说。

文字编辑

文字编辑比较简单,监听节点容器的双击事件,而后获取文字节点的宽高和地位,最初再盖一个同样大小的编辑层在下面即可,编辑完监听回车键,暗藏编辑层,批改节点数据而后从新渲染该节点,如果节点大小变动了就更新其余节点的地位。

class Node {
  // 绑定事件
  bindEvent() {this.group.on('dblclick', (e) => {e.stopPropagation()
      this.showEditTextBox()})
  }
  
  // 显示文本编辑层
  showEditTextBox() {
    // 获取 text 节点的地位和尺寸信息
    let rect = this._textData.node.node.getBoundingClientRect()
    // 文本编辑层节点没有创立过就创立一个
    if (!this.textEditNode) {this.textEditNode = document.createElement('div')
      this.textEditNode.style.cssText = `
        position:fixed;
        box-sizing: border-box;
        background-color:#fff;
        box-shadow: 0 0 20px rgba(0,0,0,.5);
        padding: 3px 5px;
        margin-left: -5px;
        margin-top: -3px;
        outline: none;`
      // 开启编辑模式
      this.textEditNode.setAttribute('contenteditable', true)
      document.body.appendChild(this.textEditNode)
    }
    // 把文字的换行符替换成换行元素
    this.textEditNode.innerHTML = this.nodeData.data.text.split(/\n/img).join('<br>')
    // 定位和显示文本编辑框
    this.textEditNode.style.minWidth = rect.width + 10 + 'px'
    this.textEditNode.style.minHeight = rect.height + 6 + 'px'
    this.textEditNode.style.left = rect.left + 'px'
    this.textEditNode.style.top = rect.top + 'px'
    this.textEditNode.style.display = 'block'
  }
}

有个小细节,就是当节点反对个性化的时候,须要把节点文字的款式,比方 font-sizeline-height 之类款式也设置到这个编辑节点上,这样能够尽量放弃一致性,尽管是个盖下来的层,然而并不会让人感觉很突兀。

class Node {
  // 注册快捷键
  registerCommand() {
    // 注册回车快捷键
    this.mindMap.keyCommand.addShortcut('Enter', () => {this.hideEditTextBox()
    })
  }

  // 敞开文本编辑框
  hideEditTextBox() {
    // 遍历以后激活的节点列表,批改它们的文字信息
    this.renderer.activeNodeList.forEach((node) => {
      // 这个办法会去掉 html 字符串里的标签及把 br 标签替换成 \n
      let str = getStrWithBrFromHtml(this.textEditNode.innerHTML)
      // 执行 设置节点文本 的命令
      this.mindMap.execCommand('SET_NODE_TEXT', this, str)
      // 更新其余节点
      this.mindMap.render()})
    // 暗藏文本编辑层
    this.textEditNode.style.display = 'none'
    this.textEditNode.innerHTML = ''
  }
}

下面波及到了其余两个概念,一个是注册快捷键,另一个是执行命令,这两个话题前面的大节里会进行介绍,节点编辑类残缺代码:TextEdit.js.

开展与收起

有时候节点太多了,咱们不须要全副都显示,那么能够通过开展和收起来只显示须要的节点,首先须要给有子节点的节点渲染一个开展收起按钮,而后绑定点击事件,切换节点的开展和膨胀状态:

class Node {renderExpandBtn() {
    // 没有子节点或是根节点间接返回
    if (!this.nodeData.children || this.nodeData.children.length <= 0 || this.isRoot) {return}
    // 按钮容器
    this._expandBtn = new G()
    let iconSvg
    // 依据节点的开展状态来判断渲染哪个图标,oepn 与 close 都是 svg 字符串
    if (this.nodeData.data.expand === false) {iconSvg = btnsSvg.open} else {iconSvg = btnsSvg.close}
    let node = SVG(iconSvg).size(this.expandBtnSize, this.expandBtnSize)
    // 因为图标都是门路 path 元素,鼠标很难点击到,所以渲染一个通明的圆来响应鼠标事件
    let fillNode = new Circle().size(this.expandBtnSize)
    // 增加到容器里
    this._expandBtn.add(fillNode).add(node)
    // 绑定点击事件
    this._expandBtn.on('click', (e) => {e.stopPropagation()
      // 执行开展膨胀的命令
      this.mindMap.execCommand('SET_NODE_EXPAND', this, !this.nodeData.data.expand)
    })
    // 设置按钮的显示地位,显示到节点的右侧垂直居中的地位
    this._expandBtn.translate(width, height / 2)
    // 增加到节点的容器里
    this.group.add(this._expandBtn)
  }
}

SET_NODE_EXPAND命令会设置节点的开展收起状态,并渲染或删除其所有子孙节点,达到开展或收起的成果,并且还须要从新计算和挪动其余所有节点的地位,此外遍历树计算地位的相干代码也须要加上开展膨胀的判断:

// 第一次遍历
walk(this.renderer.renderTree, null, (cur, parent, isRoot, layerIndex) => {// ...}, (cur, parent, isRoot, layerIndex) => {
  // 后序遍历
  if (cur.data.expand) {// 开展状态
    cur._node.childrenAreaHeight = cur._node.children.reduce((h, node) => {return h + node.height}, 0) + (len + 1) * marginY
  } else {// 如果该节点为收起状态,那么其 childrenAreaHeight 显然应该为 0
    cur._node.childrenAreaHeight = 0
  }
}, true, 0)
// 第二次遍历
walk(this.root, null, (node, parent, isRoot, layerIndex) => {
  // 只计算开展状态节点的子节点
  if (node.nodeData.data.expand && node.children && node.children.length > 0) {
    let top = node.top + node.height / 2 - node.childrenAreaHeight / 2
    // ...
  }
}, null, true)
// 第三次遍历
walk(this.root, null, (node, parent, isRoot, layerIndex) => {
  // 收起状态不必再去判断子节点高度
  if (!node.nodeData.data.expand) {return;}
  let difference = node.childrenAreaHeight - marginY * 2 - node.height
  // ...
  }, null, true)

到这里,一个根本可用的思维导图就实现了。

补充一个小细节,就是下面始终提到的挪动节点,代码其实很简略:

let t = this.group.transform()
this.group.animate(300).translate(this.left - t.translateX, this.top - t.translateY)

因为 translate 是在之前的根底上进行变换的,所以须要先获取到以后的变换,而后相减失去本次的增量,至于动画,应用 svgjs 只有顺便执行一下 animate 办法就能够了。

命令

后面的代码曾经波及到几个命令了,咱们把会批改节点状态的操作通过命令来调用,每调用一个命令就会保留一份以后的节点数据正本,用来回退和后退。

命令相似于公布订阅者,先注册命令,而后再触发命令的执行:

class Command {constructor() {
    // 保留命令
    this.commands = {}
    // 保留历史正本
    this.history = []
    // 以后所在的历史地位
    this.activeHistoryIndex = 0
  }

  // 增加命令
  add(name, fn) {if (this.commands[name]) {this.commands[name].push(fn)
    } else[this.commands[name] = [fn]
    ]
  }

  // 执行命令
  exec(name, ...args) {if (this.commands[name]) {this.commands[name].forEach((fn) => {fn(...args)
      })
      // 保留以后数据正本到历史列表里
      this.addHistory()}
  }

  // 保留以后数据正本到历史列表里
  addHistory() {
    // 深拷贝一份以后数据
    let data = this.getCopyData()
    this.history.push(data)
    this.activeHistoryIndex = this.history.length - 1
  }
}

比方之前的 SET_NODE_ACTIVE 命令会先注册:

class Render {registerCommand() {this.mindMap.command.add('SET_NODE_ACTIVE', this.setNodeActive)
  }

  // 设置节点是否激活
  setNodeActive(node, active) {
    // 设置节点激活状态
    this.setNodeData(node, {isActive: active})
    // 从新渲染节点内容
    node.renderNode()}
}

回退与后退

上一节的命令里曾经保留了所有操作后的正本数据,所以回退和后退就只有操作指针activeHistoryIndex,而后获取到这个地位的历史数据,复制一份替换以后的渲染树,最初再触发从新渲染即可,这里会进行整体全副的从新渲染,所以会略微有点卡顿。

class Command {
  // 回退
  back(step = 1) {if (this.activeHistoryIndex - step >= 0) {
      this.activeHistoryIndex -= step
      return simpleDeepClone(this.history[this.activeHistoryIndex]);
    }
  }

  // 后退
  forward(step = 1) {
    let len = this.history.length
    if (this.activeHistoryIndex + step <= len - 1) {
      this.activeHistoryIndex += step
      return simpleDeepClone(this.history[this.activeHistoryIndex]);
    }
  }
}
class Render {
  // 回退
  back(step) {let data = this.mindMap.command.back(step)
    if (data) {
      // 替换以后的渲染树
      this.renderTree = data
      this.mindMap.reRender()}
  }

  // 后退
  forward(step) {let data = this.mindMap.command.forward(step)
    if (data) {
      this.renderTree = data
      this.mindMap.reRender()}
  }
}

款式与主题

主题包含节点的所有款式,比方色彩、填充、字体、边框、内边距等等,也包含连线的粗细、色彩,及画布的背景色彩或图片等等。

一个主题的构造大抵如下:

export default {
    // 节点内边距
    paddingX: 15,
    paddingY: 5,
    // 连线的粗细
    lineWidth: 1,
    // 连线的色彩
    lineColor: '#549688',
    // 背景色彩
    backgroundColor: '#fafafa',
    // ...
    // 根节点款式
    root: {
        fillColor: '#549688',
        fontFamily: '微软雅黑, Microsoft YaHei',
        color: '#fff',
        // ...
        active: {borderColor: 'rgb(57, 80, 96)',
            borderWidth: 3,
            borderDasharray: 'none',
            // ...
        }
    },
    // 二级节点款式
    second: {
        marginX: 100,
        marginY: 40,
        fillColor: '#fff',
        // ...
        active: {// ...}
    },
    // 三级及以下节点款式
    node: {
        marginX: 50,
        marginY: 0,
        fillColor: 'transparent',
        // ...
        active: {// ...}
    }
}

最外层的是非节点款式,对于节点来说,也分成了三种类型,别离是根节点、二级节点及其他节点,每种节点外面又分成了常态款式和激活时的款式,它们能设置的款式是齐全一样的,残缺构造请看 default.js。

创立节点的每个信息元素时都会给它利用相干的款式,比方之前提到的文本元素和边框元素:

class Node {
  // 创立文本节点
  createTextNode() {let node = new Text().text(this.nodeData.data.text)
    // 给文本节点利用款式
    this.style.text(node)
    let {width, height} = node.bbox()
    return {
      node: g,
      width,
      height
    }
  }
  
  // 渲染节点
  render() {let textData = this.createTextNode()
    textData.node.translate(10, 5)
    // 给边框节点利用款式
    this.style.rect(this.group.rect(this.width, this.height).x(0).y(0))
    // ...
  }
}

style是款式类 Style 的实例,每个节点都会实例化一个(其实没必要,后续可能会批改),用来给各种元素设置款式,它会依据节点的类型和激活状态来抉择对应的款式:

class Style {
  // 给文本节点设置款式
  text(node) {
    node.fill({color: this.merge('color')
    }).css({'font-family': this.merge('fontFamily'),
      'font-size': this.merge('fontSize'),
      'font-weight': this.merge('fontWeight'),
      'font-style': this.merge('fontStyle'),
      'text-decoration': this.merge('textDecoration')
    })
  }
}

merge就是用来判断应用哪个款式的办法:

class Style {
  // 这里的 root 不是根节点,而是代表非节点的款式
  merge(prop, root) {
    // 三级及以下节点的款式
    let defaultConfig = this.themeConfig.node
    if (root) {// 非节点的款式
      defaultConfig = this.themeConfig
    } else if (this.ctx.layerIndex === 0) {// 根节点
      defaultConfig = this.themeConfig.root
    } else if (this.ctx.layerIndex === 1) {// 二级节点
      defaultConfig = this.themeConfig.second
    }
    // 激活状态
    if (this.ctx.nodeData.data.isActive) {
      // 如果节点有独自设置了款式,那么优先应用节点的
      if (this.ctx.nodeData.data.activeStyle && this.ctx.nodeData.data.activeStyle[prop] !== undefined) {return this.ctx.nodeData.data.activeStyle[prop];
      } else if (defaultConfig.active && defaultConfig.active[prop]) {// 否则应用主题默认的
        return defaultConfig.active[prop]
      }
    }
    // 优先应用节点自身的款式
    return this.ctx.nodeData.data[prop] !== undefined ? this.ctx.nodeData.data[prop] : defaultConfig[prop]
  }
}

咱们会先判断一个节点本身是否设置了该款式,有的话那就优先应用本身的,这样来达到每个节点都能够进行个性化的能力。

款式编辑就是把所有这些可配置的款式通过可视化的控件来展现与批改,实现上,能够监听节点的激活事件,而后关上款式编辑面板,先回显以后的款式,而后当批改了某个款式就通过相应的命令设置到以后激活节点上:

能够看到辨别了常态与选中态,这部分代码很简略,能够参考:Style.vue。

除了节点款式编辑,对于非节点的款式也是同样的形式进行批改,先获取到以后的主题配置,而后进行回显,用户批改了就通过相应的办法进行设置:

这部分的代码在 BaseStyle.vue。

快捷键

快捷键简略来说就是监听到按下了特定的按键后执行特定的操作,实现上其实也是一种公布订阅模式,先注册快捷键,而后监听到了该按键就执行对应的办法。

首先键值都是数字,不容易记忆,所以咱们须要保护一份键名到键值的映射表,像上面这样:

const map = {
    'Backspace': 8,
    'Tab': 9,
    'Enter': 13,
      // ...
}

残缺映射表请点这里:keyMap.js。

快捷键蕴含三种:单个按键、组合键、多个”或“关系的按键,能够应用一个对象来保留键值及回调:

{'Enter': [() => {}],
  'Control+Enter': [],
  'Del|Backspace': []}

而后增加一个注册快捷键的办法:

class KeyCommand {
  // 注册快捷键
  addShortcut(key, fn) {
    // 把或的快捷键转换成单个按键进行解决
    key.split(/\s*\|\s*/).forEach((item) => {if (this.shortcutMap[item]) {this.shortcutMap[item].push(fn)
      } else {this.shortcutMap[item] = [fn]
      }
    })
  }
}

比方注册一个删除节点的快捷键:

this.mindMap.keyCommand.addShortcut('Del|Backspace', () => {this.removeNode()
})

有了注册表,当然须要监听按键事件才行:

class KeyCommand {bindEvent() {window.addEventListener('keydown', (e) => {
      // 遍历注册的所有键值,看本次是否匹配,匹配到了哪个就执行它的回调队列
      Object.keys(this.shortcutMap).forEach((key) => {if (this.checkKey(e, key)) {e.stopPropagation()
          e.preventDefault()
          this.shortcutMap[key].forEach((fn) => {fn()
          })
        }
      })
    })
  }
}

checkKey办法用来查看注册的键值是否和本次按下的匹配,须要阐明的是组合键个别指的是 ctrlaltshift 三个键和其余按键的组合,如果按下了这三个键,事件对象 e 里对应的字段会被置为 true,而后再联合keyCode 字段判断是否匹配到了组合键。

class KeyCommand {checkKey(e, key) {
        // 获取事件对象里的键值数组
        let o = this.getOriginEventCodeArr(e)
        // 注册的键值数组,let k = this.getKeyCodeArr(key)
        // 查看两个数组是否雷同,雷同则阐明匹配胜利
        if (this.isSame(o, k)) {return true}
        return false
    }
}

getOriginEventCodeArr办法通过事件对象获取按下的键值,返回一个数组:

getOriginEventCodeArr(e) {let arr = []
    // 按下了 control 键
    if (e.ctrlKey || e.metaKey) {arr.push(keyMap['Control'])
    }
    // 按下了 alt 键
    if (e.altKey) {arr.push(keyMap['Alt'])
    }
    // 按下了 shift 键
    if (e.shiftKey) {arr.push(keyMap['Shift'])
    }
    // 同时按下了其余按键
    if (!arr.includes(e.keyCode)) {arr.push(e.keyCode)
    }
    return arr
}

getKeyCodeArr办法用来获取注册的键值数组,除了组合键,其余都只有一项,组合键的话通过 + 把字符串切割成数组:

getKeyCodeArr(key) {let keyArr = key.split(/\s*\+\s*/)
    let arr = []
    keyArr.forEach((item) => {arr.push(keyMap[item])
    })
    return arr
}

拖动、放大放大

首先请看一下根本构造:

// 画布
this.svg = SVG().addTo(this.el).size(this.width, this.height)
// 思维导图节点理论的容器
this.draw = this.svg.group()

所以拖动、放大放大都是操作这个 g 元素,对它利用相干变换即可。拖动的话只有监听鼠标挪动事件,而后批改 g 元素的 translate 属性:

class View {constructor() {
        // 鼠标按下时的起始偏移量
        this.sx = 0
        this.sy = 0
        // 以后实时的偏移量
        this.x = 0
        this.y = 0
        // 拖动视图
        this.mindMap.event.on('mousedown', () => {
            this.sx = this.x
            this.sy = this.y
        })
        this.mindMap.event.on('drag', (e, event) => {
            // event.mousemoveOffset 示意本次鼠标按下后挪动的间隔
            this.x = this.sx + event.mousemoveOffset.x
            this.y = this.sy + event.mousemoveOffset.y
            this.transform()})
    }
    
    // 设置变换
    transform() {
        this.mindMap.draw.transform({
            scale: this.scale,
            origin: 'left center',
            translate: [this.x, this.y],
        })
    }
}

放大放大也很简略,监听鼠标的滚轮事件,而后增大或减小 this.scale 的值即可:

this.scale = 1

// 放大放大视图
this.mindMap.event.on('mousewheel', (e, dir) => {
    // // 放大
    if (dir === 'down') {this.scale += 0.1} else { // 放大
        this.scale -= 0.1
    }
    this.transform()})

多选节点

多选节点也是一个不可短少的性能,比方我想同时删除多个节点,或者给多个节点设置同样的款式,挨个操作节点显然比较慢,市面上的思维导图个别都是鼠标左键按着拖动进行多选,右键拖动挪动画布,然而笔者的集体习惯把它反了一下。

多选其实很简略,鼠标按下为终点,鼠标挪动的实时地位为起点,那么如果某个节点在这两个点组成的矩形区域内就相当于被选中了,须要留神的是要思考变换问题,比方拖动和放大放大后,那么节点的 lefttop也须要变换一下:

class Select {
    // 检测节点是否在选区内
    checkInNodes() {
        // 获取以后的变换信息
        let {scaleX, scaleY, translateX, translateY} = this.mindMap.draw.transform()
        let minx = Math.min(this.mouseDownX, this.mouseMoveX)
        let miny = Math.min(this.mouseDownY, this.mouseMoveY)
        let maxx = Math.max(this.mouseDownX, this.mouseMoveX)
        let maxy = Math.max(this.mouseDownY, this.mouseMoveY)
        // 遍历节点树
        bfsWalk(this.mindMap.renderer.root, (node) => {let { left, top, width, height} = node
            // 节点的地位须要进行相应的变换
            let right = (left + width) * scaleX + translateX
            let bottom = (top + height) * scaleY + translateY
            left = left * scaleX + translateX
            top = top * scaleY + translateY
            // 判断是否残缺的在选区矩形内,你也能够改成局部区域重合也算选中
            if (
                left >= minx &&
                right <= maxx &&
                top >= miny &&
                bottom <= maxy
            ) {// 在选区内,激活节点} else if (node.nodeData.data.isActive) {// 不再选区内,如果以后是激活状态则勾销激活}
        })
    }
}

另外一个细节是当鼠标挪动到画布边缘时 g 元素须要进行挪动变换,比方鼠标以后曾经移底边旁边了,那么 g 元素主动往上挪动(当然,鼠标按下的终点地位也须要同步变动),否则画布外的节点就没方法被选中了:

残缺代码请参考 Select.js。

导出

其实导出的范畴很大,能够导出为 svg、图片、纯文本、markdownpdfjson、甚至是其余思维导图的格局,有些纯靠前端也很难实现,所以本大节只介绍如何导出为svg图片

导出 svg

导出 svg 很简略,因为咱们自身就是用 svg 绘制的,所以只有把 svg 整个节点转换成 html 字符串导出就能够了,然而间接这样是不行的,因为实际上思维导图只占画布的一部分,剩下的大片空白其实没用,另外如果放大后,思维导图局部曾经超出画布了,那么导出的又不残缺,所以咱们想要导出的应该是下图暗影所示的内容,即残缺的思维导图图形,而且是本来的大小,与缩放无关:

下面的【拖动、放大放大】大节里介绍了思维导图所有的节点都是通过一个 g 元素来包裹的,相干变换成果也是利用在这个元素上,咱们的思路是先去除它的放大放大成果,这样能获取到它本来的宽高,而后把画布也就是 svg 元素调整成这个宽高,而后再想方法把 g 元素挪动到 svg 的地位上和它重合,这样导出 svg 刚好就是原大小且残缺的,导出胜利后再把 svg 元素复原之前的变换及大小即可。

接下来一步步图示:

1. 初始状态

2. 拖动 + 放大

3. 去除它的放大放大变换

// 获取以后的变换数据
const origTransform = this.mindMap.draw.transform()
// 去除放大放大的变换成果,和 translate 一样也是在之前的根底上操作的,所以除以以后的缩放失去 1
this.mindMap.draw.scale(1 / origTransform.scaleX, 1 / origTransform.scaleY)

4. 把 svg 画布调整为 g 元素的理论大小

// rbox 是 svgjs 提供的用来获取变换后的地位和尺寸信息,其实是 getBoundingClientRect 办法的包装办法
const rect = this.mindMap.draw.rbox()
this.mindMap.svg.size(rect.wdith, rect.height)

svg元素变成左上方暗影区域的大小,另外能够看到因为 g 元素超出以后的 svg 范畴,曾经看不见了。

5. 把 g 元素挪动到 svg 左上角

const rect = this.mindMap.draw.rbox()
const elRect = this.mindMap.el.getBoundingClientRect()
this.mindMap.draw.translate(-rect.x + elRect.left, -rect.y + elRect.top)

这样 g 元素刚好能够残缺显示:

6. 导出 svg 元素即可

残缺代码如下:

class Export {
    // 获取要导出的 svg 数据
    getSvgData() {
        const svg = this.mindMap.svg
        const draw = this.mindMap.draw
        // 保留原始信息
        const origWidth = svg.width()
        const origHeight = svg.height()
        const origTransform = draw.transform()
        const elRect = this.mindMap.el.getBoundingClientRect()
        // 去除放大放大的变换成果
        draw.scale(1 / origTransform.scaleX, 1 / origTransform.scaleY)
        // 获取变换后的地位尺寸信息,其实是 getBoundingClientRect 办法的包装办法
        const rect = draw.rbox()
        // 将 svg 设置为理论内容的宽高
        svg.size(rect.wdith, rect.height)
        // 把 g 挪动到和 svg 刚好重合
        draw.translate(-rect.x + elRect.left, -rect.y + elRect.top)
        // 克隆一下 svg 节点
        const clone = svg.clone()
        // 复原原先的大小和变换信息
        svg.size(origWidth, origHeight)
        draw.transform(origTransform)
        return {
            node: clone,// 节点对象
            str: clone.svg()// html 字符串}
    }
    
    // 导出 svg 文件
    svg() {let { str} = this.getSvgData()
        // 转换成 blob 数据
        let blob = new Blob([str], {type: 'image/svg+xml'});
        let file = URL.createObjectURL(blob)
        // 触发下载
        let a = document.createElement('a')
        a.href = file
        a.download = fileName
        a.click()}
}

导出 png

导出 png 是在导出 svg 的根底上进行的,咱们上一步曾经获取到了要导出的 svg 的内容,所以这一步就是要想方法把 svg 转成 png,首先咱们晓得img 标签是能够间接显示 svg 文件的,所以咱们能够通过 img 标签来关上 svg,而后再把图片绘制到canvas 上,最初导出为 png 格局即可。

不过这之前还有另外一个问题要解决,就是如果 svg 外面存在 image 图片元素的话,且图片是通过外链形式援用的(无论同源还是非同源),绘制到 canvas 上一律都显示不进去,个别有两个解决办法:一是把所有图片元素从 svg 外面剔除,而后手动绘制到 canvas 上;二是把图片 url 都转换成 data:url 格局,简略起见,笔者抉择的是第二种办法:

class Export {async getSvgData() {
        // ...
        // 把图片的 url 转换成 data:url 类型,否则导出会失落图片
        let imageList = clone.find('image')
        let task = imageList.map(async (item) => {let imgUlr = item.attr('href') || item.attr('xlink:href')
            let imgData = await imgToDataUrl(imgUlr)
            item.attr('href', imgData)
        })
        await Promise.all(task)
        return {
            node: clone,
            str: clone.svg()}
    }
}

imgToDataUrl办法也是通过 canvas 来把图片转换成 data:url。这样转换后的svg 内容再绘制到 canvas 上就能失常显示了:

class Export {
    // 导出 png
    async png() {let { str} = await this.getSvgData()
        // 转换成 blob 数据
        let blob = new Blob([str], {type: 'image/svg+xml'})
        // 转换成对象 URL
        let svgUrl = URL.createObjectURL(blob)
        // 绘制到 canvas 上,转换成 png
        let imgDataUrl = await this.svgToPng(svgUrl)
        // 下载
        let a = document.createElement('a')
        a.href = file
        a.download = fileName
        a.click()}
    
    // svg 转 png
    svgToPng(svgSrc) {return new Promise((resolve, reject) => {const img = new Image()
            // 跨域图片须要增加这个属性,否则画布被净化了无奈导出图片
            img.setAttribute('crossOrigin', 'anonymous')
            img.onload = async () => {
                try {let canvas = document.createElement('canvas')
                    canvas.width = img.width + this.exportPadding * 2
                    canvas.height = img.height + this.exportPadding * 2
                    let ctx = canvas.getContext('2d')
                    // 图片绘制到 canvas 里
                    ctx.drawImage(img, 0, 0, img.width, img.height, this.exportPadding, this.exportPadding, img.width, img.height)
                    resolve(canvas.toDataURL())
                } catch (error) {reject(error)
                }
            }
            img.onerror = (e) => {reject(e)
            }
            img.src = svgSrc
        })
    }
}

到这里导出就实现了,不过下面省略了一个细节,就是背景的绘制,实际上咱们之前背景相干款式都是设置到容器 el 元素上的,那么导出前就须要设置到 svg 或者 canvas 上,否则导出就没有背景了,相干代码能够浏览 Export.js。

总结

本文介绍了实现一个 web 思维导图波及到的一些技术点,须要阐明的是,因笔者程度限度,代码的实现上较毛糙,而且性能上存在肯定问题,所以仅供参考,另外因为是笔者第一次应用 svg,所以难免会有svg 方面的谬误,或者有更好的实现,欢送留言探讨。

其余还有一些常见性能,比方小窗口导航、自在主题等,有趣味的能够自行实现,下一篇次要会介绍一下另外三种变种构造的实现,敬请期待。

退出移动版