Web思維導(dǎo)圖實(shí)現(xiàn)的技術(shù)點(diǎn)分析(附完整源碼)

簡(jiǎn)介

思維導(dǎo)圖是一種常見的表達(dá)發(fā)散性思維的有效工具,市面上有非常多的工具可以用來畫思維導(dǎo)圖,有免費(fèi)的也有收費(fèi)的,此外也有一些可以用來幫助快速實(shí)現(xiàn)的JavaScript類庫,如:jsMind、KityMinder。

本文會(huì)完整的介紹如何從頭實(shí)現(xiàn)一個(gè)簡(jiǎn)易的思維導(dǎo)圖,最終成果預(yù)覽:https://wanglin2.github.io/mind-map/。

技術(shù)選型

這種圖形類的繪制一般有兩種選擇:svgcanvas,因?yàn)樗季S導(dǎo)圖主要是節(jié)點(diǎn)與線的連接,使用與html比較接近的svg比較容易操作,svg的類庫在試用了svgjssnap后,有些需求在snap里沒有找到對(duì)應(yīng)的方法,所以筆者最終選擇了svgjs。

為了能跨框架使用,所以思維導(dǎo)圖的主體部分作為一個(gè)單獨(dú)的npm包來開發(fā)及發(fā)布,通過的方式來組織代碼,示例頁面的開發(fā)使用的是vue2.x全家桶。

整體思路

筆者最初的思路是先寫一個(gè)渲染器,根據(jù)輸入的思維導(dǎo)圖數(shù)據(jù),渲染成svg節(jié)點(diǎn),計(jì)算好各個(gè)節(jié)點(diǎn)的位置,然后顯示到畫布,最后給節(jié)點(diǎn)連上線即可,接下來對(duì)思維導(dǎo)圖的操作都只需要維護(hù)這份數(shù)據(jù),數(shù)據(jù)變化了就清空畫布,然后重新渲染,這種數(shù)據(jù)驅(qū)動(dòng)的思想很簡(jiǎn)單,在最初的開發(fā)中也沒有任何問題,一切都很順利,因?yàn)槟M數(shù)據(jù)就寫了四五個(gè)節(jié)點(diǎn),然而后來當(dāng)我把節(jié)點(diǎn)數(shù)量增加到幾十個(gè)的時(shí)候,發(fā)現(xiàn)涼了,太卡了,點(diǎn)擊節(jié)點(diǎn)激活或者展開收縮節(jié)點(diǎn)的時(shí)候一秒左右才有反應(yīng),就算只是個(gè)demo也無法讓人接受。

卡的原因一方面是因?yàn)橛?jì)算節(jié)點(diǎn)位置,每種布局結(jié)構(gòu)最少都需要三次遍歷節(jié)點(diǎn)樹,加上一些計(jì)算邏輯,會(huì)比較耗時(shí),另一方面是因?yàn)殇秩竟?jié)點(diǎn)內(nèi)容,因?yàn)橐粋€(gè)思維導(dǎo)圖節(jié)點(diǎn)除了文本,還要支持圖片、圖標(biāo)、標(biāo)簽等信息、svg不像html會(huì)自動(dòng)按流式布局來幫你排版,所以每種信息節(jié)點(diǎn)都需要手動(dòng)計(jì)算它們的位置,所以也是很耗時(shí)的一個(gè)操作,并且因?yàn)?code>svg元素也算是dom節(jié)點(diǎn),所以數(shù)量多了又要頻繁操作,當(dāng)然就卡了。

卡頓的原因找到了,怎么解決呢?一種方法是不用svg,改用canvas,但是筆者發(fā)現(xiàn)該問題的時(shí)候已經(jīng)寫了較多代碼,而且就算用canvas樹的遍歷也無法避免,所以筆者最后采用的方法的是不再每次都完全重新渲染,而是按需進(jìn)行渲染,比如點(diǎn)擊節(jié)點(diǎn)激活該節(jié)點(diǎn)的時(shí)候,不需要重新渲染其他節(jié)點(diǎn),只需要重新渲染被點(diǎn)擊的節(jié)點(diǎn)就可以了,又比如某個(gè)節(jié)點(diǎn)收縮或展開時(shí),其他節(jié)點(diǎn)只是位置需要變化,節(jié)點(diǎn)內(nèi)容并不需要重新渲染,所以只需要重新計(jì)算其他節(jié)點(diǎn)的位置并把它們移動(dòng)過去即可,這樣額外的好處是還可以讓它們通過動(dòng)畫的方式移動(dòng)過去,其他相關(guān)的操作也是如此,盡量只更新必要的節(jié)點(diǎn)和進(jìn)行必要的操作,改造完后雖然還是會(huì)存在一定卡頓的現(xiàn)象,但是相比之前已經(jīng)好了很多。

數(shù)據(jù)結(jié)構(gòu)

思維導(dǎo)圖可以看成就是一棵樹,我把它稱作渲染樹,所以基本的結(jié)構(gòu)就是樹的結(jié)構(gòu),每個(gè)節(jié)點(diǎn)保存節(jié)點(diǎn)本身的信息再加上子節(jié)點(diǎn)的信息,具體來說,大概需要包含節(jié)點(diǎn)的各種內(nèi)容(文本、圖片、圖標(biāo)等固定格式)、節(jié)點(diǎn)展開狀態(tài)、子節(jié)點(diǎn)等等,此外還要包括該節(jié)點(diǎn)的私有樣式,用來覆蓋主題的默認(rèn)樣式,這樣可以對(duì)每個(gè)節(jié)點(diǎn)進(jìn)行個(gè)性化:

{
  "data": {
    "text": "根節(jié)點(diǎn)",
    "expand": true,
    "color": "#fff",
    // ...
    "children": []
  }

詳細(xì)結(jié)構(gòu)可參考:節(jié)點(diǎn)結(jié)構(gòu)

僅有這棵渲染樹是不夠的,我們需要再定義一個(gè)節(jié)點(diǎn)類,當(dāng)遍歷渲染樹的時(shí)候,每個(gè)數(shù)據(jù)節(jié)點(diǎn)都會(huì)創(chuàng)建一個(gè)節(jié)點(diǎn)實(shí)例,用來保存該節(jié)點(diǎn)的狀態(tài),以及執(zhí)行渲染、計(jì)算寬高、綁定事件等等相關(guān)操作:

// 節(jié)點(diǎn)類
class Node {
  constructor(opt = {}) {
    this.nodeData = opt.data// 節(jié)點(diǎn)真實(shí)數(shù)據(jù),就是上述說的渲染樹的節(jié)點(diǎn)
    this.isRoot =  opt.isRoot// 是否是根節(jié)點(diǎn)
    this.layerIndex = opt.layerIndex// 節(jié)點(diǎn)層級(jí)
    this.width = 0// 節(jié)點(diǎn)寬
    this.height = 0// 節(jié)點(diǎn)高
    this.left = opt.left || 0// left
    this.top = opt.top || 0// top
    this.parent = opt.parent || null// 父節(jié)點(diǎn)
    this.children = []// 子節(jié)點(diǎn)
    // ...
  }
  
  // ...
}

因?yàn)橐粋€(gè)節(jié)點(diǎn)可能包含文本、圖片等多種信息,所以我們使用一個(gè)g元素來作為節(jié)點(diǎn)容器,文本就創(chuàng)建一個(gè)text節(jié)點(diǎn),需要邊框的話就再創(chuàng)建一個(gè)rect節(jié)點(diǎn),節(jié)點(diǎn)的最終大小就是文本節(jié)點(diǎn)的大小再加上內(nèi)邊距,比如我們要渲染一個(gè)帶邊框的只有文本的節(jié)點(diǎn):

import {
    G,
    Rect,
    Text
} from '@svgdotjs/svg.js'
class Node {
  constructor(opt = {}) {
    // ...
    this.group = new G()// 節(jié)點(diǎn)容器
    this.getSize()
    this.render()
  }
  // 計(jì)算節(jié)點(diǎn)寬高
  getSize() {
    let textData = this.createTextNode()
    this.width = textData.width + 20// 左右內(nèi)邊距各10
    this.height = textData.height + 10// 上下內(nèi)邊距各5
  }
  // 創(chuàng)建文本節(jié)點(diǎn)
  createTextNode() {
    let node = new Text().text(this.nodeData.data.text)
    let { width, height } = node.bbox()// 獲取文本節(jié)點(diǎn)的寬高
    return {
      node,
      width,
      height
    }
  }
  // 渲染節(jié)點(diǎn)
  render() {
    let textData = this.createTextNode()
    textData.node.x(10).y(5)// 文字節(jié)點(diǎn)相對(duì)于容器偏移內(nèi)邊距的大小
    // 創(chuàng)建一個(gè)矩形來作為邊框
    this.group.rect(this.width, this.height).x(0).y(0)
    // 文本節(jié)點(diǎn)添加到節(jié)點(diǎn)容器里
    this.group.add(textData.node)
    // 在畫布上定位該節(jié)點(diǎn)
    this.group.translate(this.left, this.top)
    // 容器添加到畫布上
    this.draw.add(this.group)
  }
}

如果還需要渲染圖片的話,就需要再創(chuàng)建一個(gè)image節(jié)點(diǎn),那么節(jié)點(diǎn)的總高度就需要再加上圖片的高,節(jié)點(diǎn)的總寬就是圖片和文字中較寬的那個(gè)大小,文字節(jié)點(diǎn)的位置計(jì)算也需要根據(jù)節(jié)點(diǎn)的總寬度及文字節(jié)點(diǎn)的寬度來計(jì)算,需要再渲染其他類型的信息也是一樣,總之,所有節(jié)點(diǎn)的位置都需要自行計(jì)算,還是有點(diǎn)繁瑣的。

節(jié)點(diǎn)類完整代碼請(qǐng)看:Node.js

邏輯結(jié)構(gòu)圖

思維導(dǎo)圖有多種結(jié)構(gòu),我們先看最基礎(chǔ)的【邏輯結(jié)構(gòu)圖】如何進(jìn)行布局計(jì)算,其他的幾種會(huì)在下一篇里進(jìn)行介紹。

file

邏輯結(jié)構(gòu)圖如上圖所示,子節(jié)點(diǎn)在父節(jié)點(diǎn)的右側(cè),然后父節(jié)點(diǎn)相對(duì)于子節(jié)點(diǎn)總體來說是垂直居中的。

節(jié)點(diǎn)定位

這個(gè)思路源于筆者在網(wǎng)上看到的,首先根節(jié)點(diǎn)我們把它定位到畫布中間的位置,然后遍歷子節(jié)點(diǎn),那么子節(jié)點(diǎn)的left就是根節(jié)點(diǎn)的left+根節(jié)點(diǎn)的width+它們之間的間距marginX,如下圖所示:

file

然后再遍歷每個(gè)子節(jié)點(diǎn)的子節(jié)點(diǎn)(其實(shí)就是遞歸遍歷)以同樣的方式進(jìn)行計(jì)算left,這樣一次遍歷完成后所有節(jié)點(diǎn)的left值就計(jì)算好了。

class Render {
  // 第一次遍歷渲染樹
  walk(this.renderer.renderTree, null, (cur, parent, isRoot, layerIndex) => {
    // 先序遍歷
    // 創(chuàng)建節(jié)點(diǎn)實(shí)例
    let newNode = new Node({
      data: cur,// 節(jié)點(diǎn)數(shù)據(jù)
      layerIndex// 層級(jí)
    })
    // 節(jié)點(diǎn)實(shí)例關(guān)聯(lián)到節(jié)點(diǎn)數(shù)據(jù)上
    cur._node = newNode
    // 根節(jié)點(diǎn)
    if (isRoot) {
      this.root = newNode
      // 定位在畫布中心位置
      newNode.left = (this.mindMap.width - node.width) / 2
      newNode.top = (this.mindMap.height - node.height) / 2
    } else {// 非根節(jié)點(diǎn)
      // 互相收集
      newNode.parent = parent._node
      parent._node.addChildren(newNode)
      // 定位到父節(jié)點(diǎn)右側(cè)
      newNode.left = parent._node.left + parent._node.width + marginX
    }
  }, null, true, 0)
}

接下來是top,首先最開始也只有根節(jié)點(diǎn)的top是確定的,那么子節(jié)點(diǎn)怎么根據(jù)父節(jié)點(diǎn)的top進(jìn)行定位呢?上面說過每個(gè)節(jié)點(diǎn)是相對(duì)于其所有子節(jié)點(diǎn)居中顯示的,那么如果我們知道所有子節(jié)點(diǎn)的總高度,那么第一個(gè)子節(jié)點(diǎn)的top也就確定了:

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

如圖所示:

file

第一個(gè)子節(jié)點(diǎn)的top確定了,其他節(jié)點(diǎn)只要在前一個(gè)節(jié)點(diǎn)的top上累加即可。

那么怎么計(jì)算childrenAreaHeight呢?首先第一次遍歷到一個(gè)節(jié)點(diǎn)時(shí),我們會(huì)給它創(chuàng)建一個(gè)Node實(shí)例,然后觸發(fā)計(jì)算該節(jié)點(diǎn)的大小,所以只有當(dāng)所有子節(jié)點(diǎn)都遍歷完回來后我們才能計(jì)算總高度,那么顯然可以在后序遍歷的時(shí)候來計(jì)算,但是要計(jì)算節(jié)點(diǎn)的top只能在下一次遍歷渲染樹時(shí),為什么不在計(jì)算完一個(gè)節(jié)點(diǎn)的childrenAreaHeight后立即就計(jì)算其子節(jié)點(diǎn)的top呢?原因很簡(jiǎn)單,當(dāng)前節(jié)點(diǎn)的top都還沒確定,怎么確定其子節(jié)點(diǎn)的位置呢?

// 第一次遍歷
walk(this.renderer.renderTree, null, (cur, parent, isRoot, layerIndex) => {
  // 先序遍歷
  // ...
}, (cur, parent, isRoot, layerIndex) => {
  // 后序遍歷
  // 計(jì)算該節(jié)點(diǎn)所有子節(jié)點(diǎn)所占高度之和,包括節(jié)點(diǎn)之間的margin、節(jié)點(diǎn)整體前后的間距
  let len = cur._node.children
  cur._node.childrenAreaHeight = cur._node.children.reduce((h, node) => {
    return h + node.height
  }, 0) + (len + 1) * marginY
}, true, 0)

總結(jié)一下,在第一輪遍歷渲染樹時(shí),我們?cè)谙刃虮闅v時(shí)創(chuàng)建Node實(shí)例,然后計(jì)算節(jié)點(diǎn)的left,在后序遍歷時(shí)計(jì)算每個(gè)節(jié)點(diǎn)的所有子節(jié)點(diǎn)的所占的總高度。

接下來開啟第二輪遍歷,這輪遍歷可以計(jì)算所有節(jié)點(diǎn)的top,因?yàn)榇藭r(shí)節(jié)點(diǎn)樹已經(jīng)創(chuàng)建成功了,所以可以不用再遍歷渲染樹,直接遍歷節(jié)點(diǎn)樹:

// 第二次遍歷
walk(this.root, null, (node, parent, isRoot, layerIndex) => {
  if (node.children && node.children.length > 0) {
    // 第一個(gè)子節(jié)點(diǎn)的top值 = 該節(jié)點(diǎn)中心的top值 - 子節(jié)點(diǎn)的高度之和的一半
    let top = node.top + node.height / 2 - node.childrenAreaHeight / 2
    let totalTop = top + marginY// node.childrenAreaHeight是包括子節(jié)點(diǎn)整體前后的間距的
    node.children.forEach((cur) => {
      cur.top = totalTop
      totalTop += cur.height + marginY// 在上一個(gè)節(jié)點(diǎn)的top基礎(chǔ)上加上間距marginY和該節(jié)點(diǎn)的height
    })
  }
}, null, true)

事情到這里并沒有結(jié)束,請(qǐng)看下圖:

file

可以看到對(duì)于每個(gè)節(jié)點(diǎn)來說,位置都是正確的,但是,整體來看就不對(duì)了,因?yàn)榘l(fā)生了重疊,原因很簡(jiǎn)單,因?yàn)椤径?jí)節(jié)點(diǎn)1】的子節(jié)點(diǎn)太多了,子節(jié)點(diǎn)占的總高度已經(jīng)超出了該節(jié)點(diǎn)自身的高,因?yàn)椤径?jí)節(jié)點(diǎn)】的定位是依據(jù)【二級(jí)節(jié)點(diǎn)】的總高度來計(jì)算的,并沒有考慮到其子節(jié)點(diǎn),解決方法也很簡(jiǎn)單,再來一輪遍歷,當(dāng)發(fā)現(xiàn)某個(gè)節(jié)點(diǎn)的子節(jié)點(diǎn)所占總高度大于其自身的高度時(shí),就讓該節(jié)點(diǎn)前后的節(jié)點(diǎn)都往外挪一挪,比如上圖,假設(shè)子節(jié)點(diǎn)所占的高度比節(jié)點(diǎn)自身的高度多出了100px,那我們就讓【二級(jí)節(jié)點(diǎn)2】向下移動(dòng)50px,如果它上面還有節(jié)點(diǎn)的話也讓它向上移動(dòng)50px,需要注意的是,這個(gè)調(diào)整的過程需要一直往父節(jié)點(diǎn)上冒泡,比如:

file

【子節(jié)點(diǎn)1-2】的子元素總高度明顯大于其自身,所以【子節(jié)點(diǎn)1-1】需要往上移動(dòng),這樣顯然還不夠,假設(shè)上面還有【二級(jí)節(jié)點(diǎn)0】的子節(jié)點(diǎn),那么它們可能也要發(fā)生重疊了,而且下方的【子節(jié)點(diǎn)2-1-1】和【子節(jié)點(diǎn)1-2-3】顯然挨的太近了,所以【子節(jié)點(diǎn)1-1】自己的兄弟節(jié)點(diǎn)調(diào)整完后,父節(jié)點(diǎn)【二級(jí)節(jié)點(diǎn)1】的兄弟節(jié)點(diǎn)也需要同樣進(jìn)行調(diào)整,上面的往上移,下面的往下移,一直到根節(jié)點(diǎn)為止:

// 第三次遍歷
walk(this.root, null, (node, parent, isRoot, layerIndex) => {
  // 判斷子節(jié)點(diǎn)所占的高度之和((除去子節(jié)點(diǎn)整體前后的margin))是否大于該節(jié)點(diǎn)自身
  let difference = node.childrenAreaHeight - marginY * 2 - node.height
  // 大于則前后的兄弟節(jié)點(diǎn)需要調(diào)整位置
  if (difference > 0) {
    this.updateBrothers(node, difference / 2)
  }
}, null, true)

updateBrothers用來向上遞歸移動(dòng)兄弟節(jié)點(diǎn):

updateBrothers(node, addHeight) {
  if (node.parent) {
    let childrenList = node.parent.children
    // 找到自己處于第幾個(gè)節(jié)點(diǎn)
    let index = childrenList.findIndex((item) => {
      return item === node
    })
    childrenList.forEach((item, _index) => {
      if (item === node) {
        return
      }
      let _offset = 0
      // 上面的節(jié)點(diǎn)往上移
      if (_index < index) {
        _offset = -addHeight
      } else if (_index > index) { // 下面的節(jié)點(diǎn)往下移
        _offset = addHeight
      }
      // 移動(dòng)節(jié)點(diǎn)
      item.top += _offset
      // 節(jié)點(diǎn)自身移動(dòng)了,還需要同步移動(dòng)其所有下級(jí)節(jié)點(diǎn)
      if (item.children && item.children.length) {
        this.updateChildren(item.children, 'top', _offset)
      }
    })
    // 向上遍歷,移動(dòng)父節(jié)點(diǎn)的兄弟節(jié)點(diǎn)
    this.updateBrothers(node.parent, addHeight)
  }
}
// 更新節(jié)點(diǎn)的所有子節(jié)點(diǎn)的位置
updateChildren(children, prop, offset) {
  children.forEach((item) => {
    item[prop] += offset
    if (item.children && item.children.length) {
      this.updateChildren(item.children, prop, offset)
    }
  })
}

到此【邏輯結(jié)構(gòu)圖】的整個(gè)布局計(jì)算就完成了,當(dāng)然,有一個(gè)小小小的問題:

file

就是嚴(yán)格來說,某個(gè)節(jié)點(diǎn)可能不再相對(duì)于其所有子節(jié)點(diǎn)居中了,而是相對(duì)于所有子孫節(jié)點(diǎn)居中,其實(shí)這樣問題也不大,實(shí)在有強(qiáng)迫癥的話,可以自行思考一下如何優(yōu)化(然后偷偷告訴筆者),這部分完整代碼請(qǐng)移步LogicalStructure.js。

節(jié)點(diǎn)連線

節(jié)點(diǎn)定位好了,接下來就要進(jìn)行連線,把節(jié)點(diǎn)和其所有子節(jié)點(diǎn)連接起來,連線風(fēng)格有很多,可以使用直線,也可以使用曲線,直線的話很簡(jiǎn)單,因?yàn)樗泄?jié)點(diǎn)的left、top、width、height都已經(jīng)知道了,所以連接線的轉(zhuǎn)折點(diǎn)坐標(biāo)都可以輕松計(jì)算出來:

file

我們重點(diǎn)看一下曲線連接,如之前的圖片所示,根節(jié)點(diǎn)的連線和其他節(jié)點(diǎn)的線是不一樣的,根節(jié)點(diǎn)到其子節(jié)點(diǎn)的如下所示:

file

這種簡(jiǎn)單的曲線可以使用二次貝塞爾曲線,起點(diǎn)坐標(biāo)為根節(jié)點(diǎn)的中間點(diǎn):

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

終點(diǎn)坐標(biāo)為各個(gè)子節(jié)點(diǎn)的左側(cè)中間:

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

那么只要確定一個(gè)控制點(diǎn)即可,具體這個(gè)點(diǎn)可以自己調(diào)節(jié),找一個(gè)看的順眼的位置即可,筆者最終選擇的是:

let cx = x1 + (x2 - x1) * 0.2
let cy = y1 + (y2 - y1) * 0.8)
image-20210718110652705.png

再看下級(jí)節(jié)點(diǎn)的連線:

image-20210718111334085.png

可以看到有兩段彎曲,所以需要使用三次貝塞爾曲線,也是一樣,自己選擇兩個(gè)合適的控制點(diǎn)位置,筆者的選擇如下圖,兩個(gè)控制點(diǎn)的x處于起點(diǎn)和終點(diǎn)的中間:

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

接下來給Node類加個(gè)渲染連線的方法即可:

class Node {
  // 渲染節(jié)點(diǎn)到其子節(jié)點(diǎn)的連線
  renderLine() {
    let { layerIndex, isRoot, top, left, width, height } = this
    this.children.forEach((item, index) => {
      // 根節(jié)點(diǎn)的連線起點(diǎn)在節(jié)點(diǎn)中間,其他都在右側(cè)
      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)
    })
  }
}

// 根節(jié)點(diǎn)到其子節(jié)點(diǎn)的連線
const quadraticCurvePath = (x1, y1, x2, y2) => {
  // 二次貝塞爾曲線的控制點(diǎn)
  let cx = x1 + (x2 - x1) * 0.2
  let cy = y1 + (y2 - y1) * 0.8
  return `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}`
}

// 其他節(jié)點(diǎn)到其子節(jié)點(diǎn)的連線
const cubicBezierPath = (x1, y1, x2, y2) => {
  // 三次貝塞爾曲線的兩個(gè)控制點(diǎn)
  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}`
}

節(jié)點(diǎn)激活

點(diǎn)擊某個(gè)節(jié)點(diǎn)就相對(duì)于把它激活,為了能有點(diǎn)反饋,所以需要給它加一點(diǎn)激活的樣式,通常都是給它加個(gè)邊框,但是筆者不滿足于此,筆者認(rèn)為節(jié)點(diǎn)所有的樣式,激活時(shí)都可以改變,這樣可以更好的與主題融合,也就是節(jié)點(diǎn)的所有樣式都有兩種狀態(tài),普通狀態(tài)和激活狀態(tài),缺點(diǎn)是激活和取消激活時(shí)的操作多了,會(huì)帶來一點(diǎn)卡頓。

實(shí)現(xiàn)上可以監(jiān)聽節(jié)點(diǎn)的單擊事件,然后設(shè)置節(jié)點(diǎn)的激活標(biāo)志,因?yàn)橥瑫r(shí)是可以存在多個(gè)激活節(jié)點(diǎn)的,所以用一個(gè)數(shù)組來保存所有的激活節(jié)點(diǎn)。

class Node {
  bindEvent() {
    this.group.on('click', (e) => {
      e.stopPropagation()
      // 已經(jīng)是激活狀態(tài)就直接返回
      if (this.nodeData.data.isActive) {
        return
      }
      // 清除當(dāng)前已經(jīng)激活節(jié)點(diǎn)的激活狀態(tài)
      this.renderer.clearActive()
      // 執(zhí)行激活 點(diǎn)擊節(jié)點(diǎn)的激活狀態(tài) 的命令
      this.mindMap.execCommand('SET_NODE_ACTIVE', this, true)
      // 添加到激活列表里
      this.renderer.addActiveNode(this)
    })
  }
}

SET_NODE_ACTIVE命令會(huì)重新渲染該節(jié)點(diǎn),所以我們只要在渲染節(jié)點(diǎn)的邏輯里判斷節(jié)點(diǎn)的激活狀態(tài)來應(yīng)用不同的樣式即可,具體在后序的樣式與主題小節(jié)里細(xì)說。

文字編輯

文字編輯比較簡(jiǎn)單,監(jiān)聽節(jié)點(diǎn)容器的雙擊事件,然后獲取文字節(jié)點(diǎn)的寬高和位置,最后再蓋一個(gè)同樣大小的編輯層在上面即可,編輯完監(jiān)聽回車鍵,隱藏編輯層,修改節(jié)點(diǎn)數(shù)據(jù)然后重新渲染該節(jié)點(diǎn),如果節(jié)點(diǎn)大小變化了就更新其他節(jié)點(diǎn)的位置。

class Node {
  // 綁定事件
  bindEvent() {
    this.group.on('dblclick', (e) => {
      e.stopPropagation()
      this.showEditTextBox()
    })
  }
  
  // 顯示文本編輯層
  showEditTextBox() {
    // 獲取text節(jié)點(diǎn)的位置和尺寸信息
    let rect = this._textData.node.node.getBoundingClientRect()
    // 文本編輯層節(jié)點(diǎn)沒有創(chuàng)建過就創(chuàng)建一個(gè)
    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'
  }
}

有個(gè)小細(xì)節(jié),就是當(dāng)節(jié)點(diǎn)支持個(gè)性化的時(shí)候,需要把節(jié)點(diǎn)文字的樣式,比如font-size、line-height之類樣式也設(shè)置到這個(gè)編輯節(jié)點(diǎn)上,這樣可以盡量保持一致性,雖然是個(gè)蓋上去的層,但是并不會(huì)讓人感覺很突兀。

class Node {
  // 注冊(cè)快捷鍵
  registerCommand() {
    // 注冊(cè)回車快捷鍵
    this.mindMap.keyCommand.addShortcut('Enter', () => {
      this.hideEditTextBox()
    })
  }

  // 關(guān)閉文本編輯框
  hideEditTextBox() {
    // 遍歷當(dāng)前激活的節(jié)點(diǎn)列表,修改它們的文字信息
    this.renderer.activeNodeList.forEach((node) => {
      // 這個(gè)方法會(huì)去掉html字符串里的標(biāo)簽及把br標(biāo)簽替換成\n
      let str = getStrWithBrFromHtml(this.textEditNode.innerHTML)
      // 執(zhí)行 設(shè)置節(jié)點(diǎn)文本 的命令
      this.mindMap.execCommand('SET_NODE_TEXT', this, str)
      // 更新其他節(jié)點(diǎn)
      this.mindMap.render()
    })
    // 隱藏文本編輯層
    this.textEditNode.style.display = 'none'
    this.textEditNode.innerHTML = ''
  }
}

上面涉及到了其他兩個(gè)概念,一個(gè)是注冊(cè)快捷鍵,另一個(gè)是執(zhí)行命令,這兩個(gè)話題后面的小節(jié)里會(huì)進(jìn)行介紹,節(jié)點(diǎn)編輯類完整代碼:TextEdit.js.

展開與收起

有時(shí)候節(jié)點(diǎn)太多了,我們不需要全部都顯示,那么可以通過展開和收起來只顯示需要的節(jié)點(diǎn),首先需要給有子節(jié)點(diǎn)的節(jié)點(diǎn)渲染一個(gè)展開收起按鈕,然后綁定點(diǎn)擊事件,切換節(jié)點(diǎn)的展開和收縮狀態(tài):

class Node {
  renderExpandBtn() {
    // 沒有子節(jié)點(diǎn)或是根節(jié)點(diǎn)直接返回
    if (!this.nodeData.children || this.nodeData.children.length <= 0 || this.isRoot) {
      return
    }
    // 按鈕容器
    this._expandBtn = new G()
    let iconSvg
    // 根據(jù)節(jié)點(diǎn)的展開狀態(tài)來判斷渲染哪個(gè)圖標(biāo),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)
    // 因?yàn)閳D標(biāo)都是路徑path元素,鼠標(biāo)很難點(diǎn)擊到,所以渲染一個(gè)透明的圓來響應(yīng)鼠標(biāo)事件
    let fillNode = new Circle().size(this.expandBtnSize)
    // 添加到容器里
    this._expandBtn.add(fillNode).add(node)
    // 綁定點(diǎn)擊事件
    this._expandBtn.on('click', (e) => {
      e.stopPropagation()
      // 執(zhí)行展開收縮的命令
      this.mindMap.execCommand('SET_NODE_EXPAND', this, !this.nodeData.data.expand)
    })
    // 設(shè)置按鈕的顯示位置,顯示到節(jié)點(diǎn)的右側(cè)垂直居中的位置
    this._expandBtn.translate(width, height / 2)
    // 添加到節(jié)點(diǎn)的容器里
    this.group.add(this._expandBtn)
  }
}
image-20210718184835414.png

SET_NODE_EXPAND命令會(huì)設(shè)置節(jié)點(diǎn)的展開收起狀態(tài),并渲染或刪除其所有子孫節(jié)點(diǎn),達(dá)到展開或收起的效果,并且還需要重新計(jì)算和移動(dòng)其他所有節(jié)點(diǎn)的位置,此外遍歷樹計(jì)算位置的相關(guān)代碼也需要加上展開收縮的判斷:

// 第一次遍歷
walk(this.renderer.renderTree, null, (cur, parent, isRoot, layerIndex) => {
  // ...
}, (cur, parent, isRoot, layerIndex) => {
  // 后序遍歷
  if (cur.data.expand) {// 展開狀態(tài)
    cur._node.childrenAreaHeight = cur._node.children.reduce((h, node) => {
      return h + node.height
    }, 0) + (len + 1) * marginY
  } else {// 如果該節(jié)點(diǎn)為收起狀態(tài),那么其childrenAreaHeight顯然應(yīng)該為0
    cur._node.childrenAreaHeight = 0
  }
}, true, 0)
// 第二次遍歷
walk(this.root, null, (node, parent, isRoot, layerIndex) => {
  // 只計(jì)算展開狀態(tài)節(jié)點(diǎn)的子節(jié)點(diǎn)
  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) => {
  // 收起狀態(tài)不用再去判斷子節(jié)點(diǎn)高度
  if (!node.nodeData.data.expand) {
    return;
  }
  let difference = node.childrenAreaHeight - marginY * 2 - node.height
  // ...
  }, null, true)
image-20210718191124627.png

到這里,一個(gè)基本可用的思維導(dǎo)圖就完成了。

補(bǔ)充一個(gè)小細(xì)節(jié),就是上面一直提到的移動(dòng)節(jié)點(diǎn),代碼其實(shí)很簡(jiǎn)單:

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

因?yàn)?code>translate是在之前的基礎(chǔ)上進(jìn)行變換的,所以需要先獲取到當(dāng)前的變換,然后相減得到本次的增量,至于動(dòng)畫,使用svgjs只要順便執(zhí)行一下animate方法就可以了。

1.gif

命令

前面的代碼已經(jīng)涉及到幾個(gè)命令了,我們把會(huì)修改節(jié)點(diǎn)狀態(tài)的操作通過命令來調(diào)用,每調(diào)用一個(gè)命令就會(huì)保存一份當(dāng)前的節(jié)點(diǎn)數(shù)據(jù)副本,用來回退和前進(jìn)。

命令類似于發(fā)布訂閱者,先注冊(cè)命令,然后再觸發(fā)命令的執(zhí)行:

class Command {
  constructor() {
    // 保存命令
    this.commands = {}
    // 保存歷史副本
    this.history = []
    // 當(dāng)前所在的歷史位置
    this.activeHistoryIndex = 0
  }

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

  // 執(zhí)行命令
  exec(name, ...args) {
    if (this.commands[name]) {
      this.commands[name].forEach((fn) => {
        fn(...args)
      })
      // 保存當(dāng)前數(shù)據(jù)副本到歷史列表里
      this.addHistory()
    }
  }

  // 保存當(dāng)前數(shù)據(jù)副本到歷史列表里
  addHistory() {
    // 深拷貝一份當(dāng)前數(shù)據(jù)
    let data = this.getCopyData()
    this.history.push(data)
    this.activeHistoryIndex = this.history.length - 1
  }
}

比如之前的SET_NODE_ACTIVE命令會(huì)先注冊(cè):

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

  // 設(shè)置節(jié)點(diǎn)是否激活
  setNodeActive(node, active) {
    // 設(shè)置節(jié)點(diǎn)激活狀態(tài)
    this.setNodeData(node, {
      isActive: active
    })
    // 重新渲染節(jié)點(diǎn)內(nèi)容
    node.renderNode()
  }
}

回退與前進(jìn)

上一節(jié)的命令里已經(jīng)保存了所有操作后的副本數(shù)據(jù),所以回退和前進(jìn)就只要操作指針activeHistoryIndex,然后獲取到這個(gè)位置的歷史數(shù)據(jù),復(fù)制一份替換當(dāng)前的渲染樹,最后再觸發(fā)重新渲染即可,這里會(huì)進(jìn)行整體全部的重新渲染,所以會(huì)稍微有點(diǎn)卡頓。

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

  // 前進(jìn)
  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) {
      // 替換當(dāng)前的渲染樹
      this.renderTree = data
      this.mindMap.reRender()
    }
  }

  // 前進(jìn)
  forward(step) {
    let data = this.mindMap.command.forward(step)
    if (data) {
      this.renderTree = data
      this.mindMap.reRender()
    }
  }
}

樣式與主題

主題包括節(jié)點(diǎn)的所有樣式,比如顏色、填充、字體、邊框、內(nèi)邊距等等,也包括連線的粗細(xì)、顏色,及畫布的背景顏色或圖片等等。

一個(gè)主題的結(jié)構(gòu)大致如下:

export default {
    // 節(jié)點(diǎn)內(nèi)邊距
    paddingX: 15,
    paddingY: 5,
    // 連線的粗細(xì)
    lineWidth: 1,
    // 連線的顏色
    lineColor: '#549688',
    // 背景顏色
    backgroundColor: '#fafafa',
    // ...
    // 根節(jié)點(diǎn)樣式
    root: {
        fillColor: '#549688',
        fontFamily: '微軟雅黑, Microsoft YaHei',
        color: '#fff',
        // ...
        active: {
            borderColor: 'rgb(57, 80, 96)',
            borderWidth: 3,
            borderDasharray: 'none',
            // ...
        }
    },
    // 二級(jí)節(jié)點(diǎn)樣式
    second: {
        marginX: 100,
        marginY: 40,
        fillColor: '#fff',
        // ...
        active: {
            // ...
        }
    },
    // 三級(jí)及以下節(jié)點(diǎn)樣式
    node: {
        marginX: 50,
        marginY: 0,
        fillColor: 'transparent',
        // ...
        active: {
            // ...
        }
    }
}

最外層的是非節(jié)點(diǎn)樣式,對(duì)于節(jié)點(diǎn)來說,也分成了三種類型,分別是根節(jié)點(diǎn)、二級(jí)節(jié)點(diǎn)及其他節(jié)點(diǎn),每種節(jié)點(diǎn)里面又分成了常態(tài)樣式和激活時(shí)的樣式,它們能設(shè)置的樣式是完全一樣的,完整結(jié)構(gòu)請(qǐng)看default.js

創(chuàng)建節(jié)點(diǎn)的每個(gè)信息元素時(shí)都會(huì)給它應(yīng)用相關(guān)的樣式,比如之前提到的文本元素和邊框元素:

class Node {
  // 創(chuàng)建文本節(jié)點(diǎn)
  createTextNode() {
    let node = new Text().text(this.nodeData.data.text)
    // 給文本節(jié)點(diǎn)應(yīng)用樣式
    this.style.text(node)
    let { width, height } = node.bbox()
    return {
      node: g,
      width,
      height
    }
  }
  
  // 渲染節(jié)點(diǎn)
  render() {
    let textData = this.createTextNode()
    textData.node.translate(10, 5)
    // 給邊框節(jié)點(diǎn)應(yīng)用樣式
    this.style.rect(this.group.rect(this.width, this.height).x(0).y(0))
    // ...
  }
}

style是樣式類Style的實(shí)例,每個(gè)節(jié)點(diǎn)都會(huì)實(shí)例化一個(gè)(其實(shí)沒必要,后續(xù)可能會(huì)修改),用來給各種元素設(shè)置樣式,它會(huì)根據(jù)節(jié)點(diǎn)的類型和激活狀態(tài)來選擇對(duì)應(yīng)的樣式:

class Style {
  // 給文本節(jié)點(diǎn)設(shè)置樣式
  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就是用來判斷使用哪個(gè)樣式的方法:

class Style {
  // 這里的root不是根節(jié)點(diǎn),而是代表非節(jié)點(diǎn)的樣式
  merge(prop, root) {
    // 三級(jí)及以下節(jié)點(diǎn)的樣式
    let defaultConfig = this.themeConfig.node
    if (root) {// 非節(jié)點(diǎn)的樣式
      defaultConfig = this.themeConfig
    } else if (this.ctx.layerIndex === 0) {// 根節(jié)點(diǎn)
      defaultConfig = this.themeConfig.root
    } else if (this.ctx.layerIndex === 1) {// 二級(jí)節(jié)點(diǎn)
      defaultConfig = this.themeConfig.second
    }
    // 激活狀態(tài)
    if (this.ctx.nodeData.data.isActive) {
      // 如果節(jié)點(diǎn)有單獨(dú)設(shè)置了樣式,那么優(yōu)先使用節(jié)點(diǎn)的
      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]) {// 否則使用主題默認(rèn)的
        return defaultConfig.active[prop]
      }
    }
    // 優(yōu)先使用節(jié)點(diǎn)本身的樣式
    return this.ctx.nodeData.data[prop] !== undefined ? this.ctx.nodeData.data[prop] : defaultConfig[prop]
  }
}

我們會(huì)先判斷一個(gè)節(jié)點(diǎn)自身是否設(shè)置了該樣式,有的話那就優(yōu)先使用自身的,這樣來達(dá)到每個(gè)節(jié)點(diǎn)都可以進(jìn)行個(gè)性化的能力。

樣式編輯就是把所有這些可配置的樣式通過可視化的控件來展示與修改,實(shí)現(xiàn)上,可以監(jiān)聽節(jié)點(diǎn)的激活事件,然后打開樣式編輯面板,先回顯當(dāng)前的樣式,然后當(dāng)修改了某個(gè)樣式就通過相應(yīng)的命令設(shè)置到當(dāng)前激活節(jié)點(diǎn)上:

image-20210718222150055.png

可以看到區(qū)分了常態(tài)與選中態(tài),這部分代碼很簡(jiǎn)單,可以參考:Style.vue。

除了節(jié)點(diǎn)樣式編輯,對(duì)于非節(jié)點(diǎn)的樣式也是同樣的方式進(jìn)行修改,先獲取到當(dāng)前的主題配置,然后進(jìn)行回顯,用戶修改了就通過相應(yīng)的方法進(jìn)行設(shè)置:

image-20210718222612078.png

這部分的代碼在BaseStyle.vue。

快捷鍵

快捷鍵簡(jiǎn)單來說就是監(jiān)聽到按下了特定的按鍵后執(zhí)行特定的操作,實(shí)現(xiàn)上其實(shí)也是一種發(fā)布訂閱模式,先注冊(cè)快捷鍵,然后監(jiān)聽到了該按鍵就執(zhí)行對(duì)應(yīng)的方法。

首先鍵值都是數(shù)字,不容易記憶,所以我們需要維護(hù)一份鍵名到鍵值的映射表,像下面這樣:

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

完整映射表請(qǐng)點(diǎn)這里:keyMap.js。

快捷鍵包含三種:?jiǎn)蝹€(gè)按鍵、組合鍵、多個(gè)”或“關(guān)系的按鍵,可以使用一個(gè)對(duì)象來保存鍵值及回調(diào):

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

然后添加一個(gè)注冊(cè)快捷鍵的方法:

class KeyCommand {
  // 注冊(cè)快捷鍵
  addShortcut(key, fn) {
    // 把或的快捷鍵轉(zhuǎn)換成單個(gè)按鍵進(jìn)行處理
    key.split(/\s*\|\s*/).forEach((item) => {
      if (this.shortcutMap[item]) {
        this.shortcutMap[item].push(fn)
      } else {
        this.shortcutMap[item] = [fn]
      }
    })
  }
}

比如注冊(cè)一個(gè)刪除節(jié)點(diǎn)的快捷鍵:

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

有了注冊(cè)表,當(dāng)然需要監(jiān)聽按鍵事件才行:

class KeyCommand {
  bindEvent() {
    window.addEventListener('keydown', (e) => {
      // 遍歷注冊(cè)的所有鍵值,看本次是否匹配,匹配到了哪個(gè)就執(zhí)行它的回調(diào)隊(duì)列
      Object.keys(this.shortcutMap).forEach((key) => {
        if (this.checkKey(e, key)) {
          e.stopPropagation()
          e.preventDefault()
          this.shortcutMap[key].forEach((fn) => {
            fn()
          })
        }
      })
    })
  }
}

checkKey方法用來檢查注冊(cè)的鍵值是否和本次按下的匹配,需要說明的是組合鍵一般指的是ctrl、altshift三個(gè)鍵和其他按鍵的組合,如果按下了這三個(gè)鍵,事件對(duì)象e里對(duì)應(yīng)的字段會(huì)被置為true,然后再結(jié)合keyCode字段判斷是否匹配到了組合鍵。

class KeyCommand {
    checkKey(e, key) {
        // 獲取事件對(duì)象里的鍵值數(shù)組
        let o = this.getOriginEventCodeArr(e)
        // 注冊(cè)的鍵值數(shù)組,
        let k = this.getKeyCodeArr(key)
        // 檢查兩個(gè)數(shù)組是否相同,相同則說明匹配成功
        if (this.isSame(o, k)) {
            return true
        }
        return false
    }
}

getOriginEventCodeArr方法通過事件對(duì)象獲取按下的鍵值,返回一個(gè)數(shù)組:

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'])
    }
    // 同時(shí)按下了其他按鍵
    if (!arr.includes(e.keyCode)) {
        arr.push(e.keyCode)
    }
    return arr
}

getKeyCodeArr方法用來獲取注冊(cè)的鍵值數(shù)組,除了組合鍵,其他都只有一項(xiàng),組合鍵的話通過+把字符串切割成數(shù)組:

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

拖動(dòng)、放大縮小

首先請(qǐng)看一下基本結(jié)構(gòu):

image-20210720191943989.png
image-20210720192008277.png
// 畫布
this.svg = SVG().addTo(this.el).size(this.width, this.height)
// 思維導(dǎo)圖節(jié)點(diǎn)實(shí)際的容器
this.draw = this.svg.group()

所以拖動(dòng)、放大縮小都是操作這個(gè)g元素,對(duì)它應(yīng)用相關(guān)變換即可。拖動(dòng)的話只要監(jiān)聽鼠標(biāo)移動(dòng)事件,然后修改g元素的translate屬性:

class View {
    constructor() {
        // 鼠標(biāo)按下時(shí)的起始偏移量
        this.sx = 0
        this.sy = 0
        // 當(dāng)前實(shí)時(shí)的偏移量
        this.x = 0
        this.y = 0
        // 拖動(dòng)視圖
        this.mindMap.event.on('mousedown', () => {
            this.sx = this.x
            this.sy = this.y
        })
        this.mindMap.event.on('drag', (e, event) => {
            // event.mousemoveOffset表示本次鼠標(biāo)按下后移動(dòng)的距離
            this.x = this.sx + event.mousemoveOffset.x
            this.y = this.sy + event.mousemoveOffset.y
            this.transform()
        })
    }
    
    // 設(shè)置變換
    transform() {
        this.mindMap.draw.transform({
            scale: this.scale,
            origin: 'left center',
            translate: [this.x, this.y],
        })
    }
}
2.gif

放大縮小也很簡(jiǎn)單,監(jiān)聽鼠標(biāo)的滾輪事件,然后增大或減小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()
})
3.gif

多選節(jié)點(diǎn)

多選節(jié)點(diǎn)也是一個(gè)不可缺少的功能,比如我想同時(shí)刪除多個(gè)節(jié)點(diǎn),或者給多個(gè)節(jié)點(diǎn)設(shè)置同樣的樣式,挨個(gè)操作節(jié)點(diǎn)顯然比較慢,市面上的思維導(dǎo)圖一般都是鼠標(biāo)左鍵按著拖動(dòng)進(jìn)行多選,右鍵拖動(dòng)移動(dòng)畫布,但是筆者的個(gè)人習(xí)慣把它反了一下。

多選其實(shí)很簡(jiǎn)單,鼠標(biāo)按下為起點(diǎn),鼠標(biāo)移動(dòng)的實(shí)時(shí)位置為終點(diǎn),那么如果某個(gè)節(jié)點(diǎn)在這兩個(gè)點(diǎn)組成的矩形區(qū)域內(nèi)就相當(dāng)于被選中了,需要注意的是要考慮變換問題,比如拖動(dòng)和放大縮小后,那么節(jié)點(diǎn)的lefttop也需要變換一下:

class Select {
    // 檢測(cè)節(jié)點(diǎn)是否在選區(qū)內(nèi)
    checkInNodes() {
        // 獲取當(dāng)前的變換信息
        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)
        // 遍歷節(jié)點(diǎn)樹
        bfsWalk(this.mindMap.renderer.root, (node) => {
            let { left, top, width, height } = node
            // 節(jié)點(diǎn)的位置需要進(jìn)行相應(yīng)的變換
            let right = (left + width) * scaleX + translateX
            let bottom = (top + height) * scaleY + translateY
            left = left * scaleX + translateX
            top = top * scaleY + translateY
            // 判斷是否完整的在選區(qū)矩形內(nèi),你也可以改成部分區(qū)域重合也算選中
            if (
                left >= minx &&
                right <= maxx &&
                top >= miny &&
                bottom <= maxy
            ) {
                // 在選區(qū)內(nèi),激活節(jié)點(diǎn)
            } else if (node.nodeData.data.isActive) {
                // 不再選區(qū)內(nèi),如果當(dāng)前是激活狀態(tài)則取消激活
            }
        })
    }
}

另外一個(gè)細(xì)節(jié)是當(dāng)鼠標(biāo)移動(dòng)到畫布邊緣時(shí)g元素需要進(jìn)行移動(dòng)變換,比如鼠標(biāo)當(dāng)前已經(jīng)移底邊旁邊了,那么g元素自動(dòng)往上移動(dòng)(當(dāng)然,鼠標(biāo)按下的起點(diǎn)位置也需要同步變化),否則畫布外的節(jié)點(diǎn)就沒辦法被選中了:

2021-07-21-19-54-48.gif

完整代碼請(qǐng)參考Select.js。

導(dǎo)出

其實(shí)導(dǎo)出的范圍很大,可以導(dǎo)出為svg、圖片、純文本、markdownpdf、json、甚至是其他思維導(dǎo)圖的格式,有些純靠前端也很難實(shí)現(xiàn),所以本小節(jié)只介紹如何導(dǎo)出為svg圖片

導(dǎo)出svg

導(dǎo)出svg很簡(jiǎn)單,因?yàn)槲覀儽旧砭褪怯?code>svg繪制的,所以只要把svg整個(gè)節(jié)點(diǎn)轉(zhuǎn)換成html字符串導(dǎo)出就可以了,但是直接這樣是不行的,因?yàn)閷?shí)際上思維導(dǎo)圖只占畫布的一部分,剩下的大片空白其實(shí)沒用,另外如果放大后,思維導(dǎo)圖部分已經(jīng)超出畫布了,那么導(dǎo)出的又不完整,所以我們想要導(dǎo)出的應(yīng)該是下圖陰影所示的內(nèi)容,即完整的思維導(dǎo)圖圖形,而且是原本的大小,與縮放無關(guān):

image-20210720200816281.png

上面的【拖動(dòng)、放大縮小】小節(jié)里介紹了思維導(dǎo)圖所有的節(jié)點(diǎn)都是通過一個(gè)g元素來包裹的,相關(guān)變換效果也是應(yīng)用在這個(gè)元素上,我們的思路是先去除它的放大縮小效果,這樣能獲取到它原本的寬高,然后把畫布也就是svg元素調(diào)整成這個(gè)寬高,然后再想辦法把g元素移動(dòng)到svg的位置上和它重合,這樣導(dǎo)出svg剛好就是原大小且完整的,導(dǎo)出成功后再把svg元素恢復(fù)之前的變換及大小即可。

接下來一步步圖示:

1.初始狀態(tài)

image-20210721183307656.png

2.拖動(dòng)+放大

image-20210721183340310.png

3.去除它的放大縮小變換

// 獲取當(dāng)前的變換數(shù)據(jù)
const origTransform = this.mindMap.draw.transform()
// 去除放大縮小的變換效果,和translate一樣也是在之前的基礎(chǔ)上操作的,所以除以當(dāng)前的縮放得到1
this.mindMap.draw.scale(1 / origTransform.scaleX, 1 / origTransform.scaleY)
image-20210721183823754.png

4.把svg畫布調(diào)整為g元素的實(shí)際大小

// rbox是svgjs提供的用來獲取變換后的位置和尺寸信息,其實(shí)是getBoundingClientRect方法的包裝方法
const rect = this.mindMap.draw.rbox()
this.mindMap.svg.size(rect.wdith, rect.height)
image-20210721184140488.png

svg元素變成左上方陰影區(qū)域的大小,另外可以看到因?yàn)?code>g元素超出當(dāng)前的svg范圍,已經(jīng)看不見了。

5.把g元素移動(dòng)到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)
image-20210721185453825.png

這樣g元素剛好可以完整顯示:

image-20210721190700979.png

6.導(dǎo)出svg元素即可

完整代碼如下:

class Export {
    // 獲取要導(dǎo)出的svg數(shù)據(jù)
    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)
        // 獲取變換后的位置尺寸信息,其實(shí)是getBoundingClientRect方法的包裝方法
        const rect = draw.rbox()
        // 將svg設(shè)置為實(shí)際內(nèi)容的寬高
        svg.size(rect.wdith, rect.height)
        // 把g移動(dòng)到和svg剛好重合
        draw.translate(-rect.x + elRect.left, -rect.y + elRect.top)
        // 克隆一下svg節(jié)點(diǎn)
        const clone = svg.clone()
        // 恢復(fù)原先的大小和變換信息
        svg.size(origWidth, origHeight)
        draw.transform(origTransform)
        return {
            node: clone,// 節(jié)點(diǎn)對(duì)象
            str: clone.svg()// html字符串
        }
    }
    
    // 導(dǎo)出svg文件
    svg() {
        let { str } = this.getSvgData()
        // 轉(zhuǎn)換成blob數(shù)據(jù)
        let blob = new Blob([str], {
            type: 'image/svg+xml'
        });
        let file = URL.createObjectURL(blob)
        // 觸發(fā)下載
        let a = document.createElement('a')
        a.href = file
        a.download = fileName
        a.click()
    }
}

導(dǎo)出png

導(dǎo)出png是在導(dǎo)出svg的基礎(chǔ)上進(jìn)行的,我們上一步已經(jīng)獲取到了要導(dǎo)出的svg的內(nèi)容,所以這一步就是要想辦法把svg轉(zhuǎn)成png,首先我們知道img標(biāo)簽是可以直接顯示svg文件的,所以我們可以通過img標(biāo)簽來打開svg,然后再把圖片繪制到canvas上,最后導(dǎo)出為png格式即可。

不過這之前還有另外一個(gè)問題要解決,就是如果svg里面存在image圖片元素的話,且圖片是通過外鏈方式引用的(無論同源還是非同源),繪制到canvas上一律都顯示不出來,一般有兩個(gè)解決方法:一是把所有圖片元素從svg里面剔除,然后手動(dòng)繪制到canvas上;二是把圖片url都轉(zhuǎn)換成data:url格式,簡(jiǎn)單起見,筆者選擇的是第二種方法:

class Export {
    async getSvgData() {
        // ...
        // 把圖片的url轉(zhuǎn)換成data:url類型,否則導(dǎo)出會(huì)丟失圖片
        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來把圖片轉(zhuǎn)換成data:url。這樣轉(zhuǎn)換后的svg內(nèi)容再繪制到canvas上就能正常顯示了:

class Export {
    // 導(dǎo)出png
    async png() {
        let { str } = await this.getSvgData()
        // 轉(zhuǎn)換成blob數(shù)據(jù)
        let blob = new Blob([str], {
            type: 'image/svg+xml'
        })
        // 轉(zhuǎn)換成對(duì)象URL
        let svgUrl = URL.createObjectURL(blob)
        // 繪制到canvas上,轉(zhuǎn)換成png
        let imgDataUrl = await this.svgToPng(svgUrl)
        // 下載
        let a = document.createElement('a')
        a.href = file
        a.download = fileName
        a.click()
    }
    
    // svg轉(zhuǎn)png
    svgToPng(svgSrc) {
        return new Promise((resolve, reject) => {
            const img = new Image()
            // 跨域圖片需要添加這個(gè)屬性,否則畫布被污染了無法導(dǎo)出圖片
            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
        })
    }
}

到這里導(dǎo)出就完成了,不過上面省略了一個(gè)細(xì)節(jié),就是背景的繪制,實(shí)際上我們之前背景相關(guān)樣式都是設(shè)置到容器el元素上的,那么導(dǎo)出前就需要設(shè)置到svg或者canvas上,否則導(dǎo)出就沒有背景了,相關(guān)代碼可以閱讀Export.js。

總結(jié)

本文介紹了實(shí)現(xiàn)一個(gè)web思維導(dǎo)圖涉及到的一些技術(shù)點(diǎn),需要說明的是,因筆者水平限制,代碼的實(shí)現(xiàn)上較粗糙,而且性能上存在一定問題,所以僅供參考,另外因?yàn)槭枪P者第一次使用svg,所以難免會(huì)有svg方面的錯(cuò)誤,或者有更好的實(shí)現(xiàn),歡迎留言探討。

其他還有一些常見功能,比如小窗口導(dǎo)航、自由主題等,有興趣的可以自行實(shí)現(xiàn),下一篇主要會(huì)介紹一下另外三種變種結(jié)構(gòu)的實(shí)現(xiàn),敬請(qǐng)期待。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 技術(shù)點(diǎn): 不定期更新補(bǔ)充 頁面引用svg symbol標(biāo)簽創(chuàng)建icon p:nth-child(2) 與 p:nt...
    wwmin_閱讀 1,593評(píng)論 0 52
  • 主要針對(duì) vue 源碼的核心流程進(jìn)行梳理: 各個(gè)流程圖生效,晚點(diǎn)再補(bǔ)上,這里是完整的 pdf 鏈接 文件[http...
    jluemmmm閱讀 313評(píng)論 0 0
  • $HTML, HTTP,web綜合問題 1、前端需要注意哪些SEO 2、 的title和alt有什么區(qū)別 3、HT...
    Hebborn_hb閱讀 4,783評(píng)論 0 20
  • Mac上最強(qiáng)的思維導(dǎo)圖工具是什么?今天小編與大家分享OmniGraffle Pro 7 Mac中文激活版,一款享有...
    過客_fad6閱讀 28,941評(píng)論 2 5
  • 表情是什么,我認(rèn)為表情就是表現(xiàn)出來的情緒。表情可以傳達(dá)很多信息。高興了當(dāng)然就笑了,難過就哭了。兩者是相互影響密不可...
    Persistenc_6aea閱讀 129,752評(píng)論 2 7

友情鏈接更多精彩內(nèi)容