Web思维导图实现的技术点分析(下)

举报
街角小林 发表于 2022/10/28 21:41:09 2022/10/28
【摘要】 命令前面的代码已经涉及到几个命令了,我们把会修改节点状态的操作通过命令来调用,每调用一个命令就会保存一份当前的节点数据副本,用来回退和前进。命令类似于发布订阅者,先注册命令,然后再触发命令的执行:class Command { constructor() { // 保存命令 this.commands = {} // 保存历史副本 this.history = [...

命令

前面的代码已经涉及到几个命令了,我们把会修改节点状态的操作通过命令来调用,每调用一个命令就会保存一份当前的节点数据副本,用来回退和前进。

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

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方面的错误,或者有更好的实现,欢迎留言探讨。

其他还有一些常见功能,比如小窗口导航、自由主题等,有兴趣的可以自行实现,下一篇主要会介绍一下另外三种变种结构的实现,敬请期待。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。