Web思維導圖實現的技術點分析(附完整源碼)


簡介

思維導圖是一種常見的表達發散性思維的有效工具,市面上有非常多的工具可以用來畫思維導圖,有免費的也有收費的,此外也有一些可以用來幫助快速實現的JavaScript類庫,如:jsMindKityMinder

本文會完整的介紹如何從頭實現一個簡易的思維導圖,最終成果預覽:https://wanglin2.github.io/mind-map/

技術選型

這種圖形類的繪制一般有兩種選擇:svgcanvas,因為思維導圖主要是節點與線的連接,使用與html比較接近的svg比較容易操作,svg的類庫在試用了svgjssnap后,有些需求在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

邏輯結構圖

思維導圖有多種結構,我們先看最基礎的【邏輯結構圖】如何進行布局計算,其他的幾種會在下一篇里進行介紹。

file

邏輯結構圖如上圖所示,子節點在父節點的右側,然后父節點相對於子節點總體來說是垂直居中的。

節點定位

這個思路源於筆者在網上看到的,首先根節點我們把它定位到畫布中間的位置,然后遍歷子節點,那么子節點的left就是根節點的left +根節點的width+它們之間的間距marginX,如下圖所示:

file

然后再遍歷每個子節點的子節點(其實就是遞歸遍歷)以同樣的方式進行計算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

如圖所示:

file

第一個子節點的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)

事情到這里並沒有結束,請看下圖:

file

可以看到對於每個節點來說,位置都是正確的,但是,整體來看就不對了,因為發生了重疊,原因很簡單,因為【二級節點1】的子節點太多了,子節點占的總高度已經超出了該節點自身的高,因為【二級節點】的定位是依據【二級節點】的總高度來計算的,並沒有考慮到其子節點,解決方法也很簡單,再來一輪遍歷,當發現某個節點的子節點所占總高度大於其自身的高度時,就讓該節點前后的節點都往外挪一挪,比如上圖,假設子節點所占的高度比節點自身的高度多出了100px,那我們就讓【二級節點2】向下移動50px,如果它上面還有節點的話也讓它向上移動50px,需要注意的是,這個調整的過程需要一直往父節點上冒泡,比如:

file

【子節點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)
    }
  })
}

到此【邏輯結構圖】的整個布局計算就完成了,當然,有一個小小小的問題:

file

就是嚴格來說,某個節點可能不再相對於其所有子節點居中了,而是相對於所有子孫節點居中,其實這樣問題也不大,實在有強迫症的話,可以自行思考一下如何優化(然后偷偷告訴筆者),這部分完整代碼請移步LogicalStructure.js

節點連線

節點定位好了,接下來就要進行連線,把節點和其所有子節點連接起來,連線風格有很多,可以使用直線,也可以使用曲線,直線的話很簡單,因為所有節點的lefttopwidthheight都已經知道了,所以連接線的轉折點坐標都可以輕松計算出來:

file

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

file

這種簡單的曲線可以使用二次貝塞爾曲線,起點坐標為根節點的中間點:

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)

image-20210718110652705.png

再看下級節點的連線:

image-20210718111334085.png

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

image-20210718134525691.png

  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)
  }
}

image-20210718184835414.png

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)

image-20210718191124627.png

到這里,一個基本可用的思維導圖就完成了。

補充一個小細節,就是上面一直提到的移動節點,代碼其實很簡單:

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

因為translate是在之前的基礎上進行變換的,所以需要先獲取到當前的變換,然后相減得到本次的增量,至於動畫,使用svgjs只要順便執行一下animate方法就可以了。

1.gif

命令

前面的代碼已經涉及到幾個命令了,我們把會修改節點狀態的操作通過命令來調用,每調用一個命令就會保存一份當前的節點數據副本,用來回退和前進。

命令類似於發布訂閱者,先注冊命令,然后再觸發命令的執行:

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]
  }
}

我們會先判斷一個節點自身是否設置了該樣式,有的話那就優先使用自身的,這樣來達到每個節點都可以進行個性化的能力。

樣式編輯就是把所有這些可配置的樣式通過可視化的控件來展示與修改,實現上,可以監聽節點的激活事件,然后打開樣式編輯面板,先回顯當前的樣式,然后當修改了某個樣式就通過相應的命令設置到當前激活節點上:

image-20210718222150055.png

可以看到區分了常態與選中態,這部分代碼很簡單,可以參考:Style.vue

除了節點樣式編輯,對於非節點的樣式也是同樣的方式進行修改,先獲取到當前的主題配置,然后進行回顯,用戶修改了就通過相應的方法進行設置:

image-20210718222612078.png

這部分的代碼在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
}

拖動、放大縮小

首先請看一下基本結構:

image-20210720191943989.png

image-20210720192008277.png

// 畫布
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],
        })
    }
}

2.gif

放大縮小也很簡單,監聽鼠標的滾輪事件,然后增大或減小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

多選節點

多選節點也是一個不可缺少的功能,比如我想同時刪除多個節點,或者給多個節點設置同樣的樣式,挨個操作節點顯然比較慢,市面上的思維導圖一般都是鼠標左鍵按着拖動進行多選,右鍵拖動移動畫布,但是筆者的個人習慣把它反了一下。

多選其實很簡單,鼠標按下為起點,鼠標移動的實時位置為終點,那么如果某個節點在這兩個點組成的矩形區域內就相當於被選中了,需要注意的是要考慮變換問題,比如拖動和放大縮小后,那么節點的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元素自動往上移動(當然,鼠標按下的起點位置也需要同步變化),否則畫布外的節點就沒辦法被選中了:

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

完整代碼請參考Select.js

導出

其實導出的范圍很大,可以導出為svg、圖片、純文本、markdownpdfjson、甚至是其他思維導圖的格式,有些純靠前端也很難實現,所以本小節只介紹如何導出為svg圖片

導出svg

導出svg很簡單,因為我們本身就是用svg繪制的,所以只要把svg整個節點轉換成html字符串導出就可以了,但是直接這樣是不行的,因為實際上思維導圖只占畫布的一部分,剩下的大片空白其實沒用,另外如果放大后,思維導圖部分已經超出畫布了,那么導出的又不完整,所以我們想要導出的應該是下圖陰影所示的內容,即完整的思維導圖圖形,而且是原本的大小,與縮放無關:

image-20210720200816281.png

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

接下來一步步圖示:

1.初始狀態

image-20210721183307656.png

2.拖動+放大

image-20210721183340310.png

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

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

image-20210721183823754.png

4.把svg畫布調整為g元素的實際大小

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

image-20210721184140488.png

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)

image-20210721185453825.png

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

image-20210721190700979.png

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方面的錯誤,或者有更好的實現,歡迎留言探討。

其他還有一些常見功能,比如小窗口導航、自由主題等,有興趣的可以自行實現,下一篇主要會介紹一下另外三種變種結構的實現,敬請期待。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM