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

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

终点坐标为各个子节点的左侧中间:

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

那么只要确定一个控制点即可,具体这个点可以自己调节,找一个看的顺眼的位置即可,笔者最终选择的是:

let cx = x1 + (x2 - x1) * 0.2let 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(‘
’) // 定位和显示文本编辑框 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-size、line-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,地址:https://github.com/wanglin2/mind-map/blob/main/web/src/pages/Edit/components/BaseStyle.vue

快捷键

=======

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

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

const map = { ‘Backspace’: 8, ‘Tab’: 9, ‘Enter’: 13, // …}

完整映射表请点这里:keyMap.js,地址:https://github.com/wanglin2/mind-map/blob/main/simple-mind-map/src/utils/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方法用来检查注册的键值是否和本次按下的匹配,需要说明的是组合键一般指的是ctrl、alt、shift三个键和其他按键的组合,如果按下了这三个键,事件对象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()})

多选节点

========

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

多选其实很简单,鼠标按下为起点,鼠标移动的实时位置为终点,那么如果某个节点在这两个点组成的矩形区域内就相当于被选中了,需要注意的是要考虑变换问题,比如拖动和放大缩小后,那么节点的left和top也需要变换一下:

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,地址:https://github.com/wanglin2/mind-map/blob/main/simple-mind-map/src/Select.js

导出

======

其实导出的范围很大,可以导出为svg、图片、纯文本、markdown、pdf、json、甚至是其他思维导图的格式,有些纯靠前端也很难实现,所以本小节只介绍如何导出为svg和图片。

导出svg


导出svg很简单,因为我们本身就是用svg绘制的,所以只要把svg整个节点转换成html字符串导出就可以了,但是直接这样是不行的,因为实际上思维导图只占画布的一部分,剩下的大片空白其实没用。

另外,如果放大后,思维导图部分已经超出画布了,那么导出的又不完整,所以我们想要导出的应该是下图阴影所示的内容,即完整的思维导图图形,而且是原本的大小,与缩放无关:

上面的【拖动、放大缩小】小节里介绍了思维导图所有的节点都是通过一个g元素来包裹的,相关变换效果也是应用在这个元素上,我们的思路是先去除它的放大缩小效果,这样能获取到它原本的宽高。

然后,把画布也就是svg元素调整成这个宽高,然后再想办法把g元素移动到svg的位置上和它重合,这样导出svg刚好就是原大小且完整的,导出成功后再把svg元素恢复之前的变换及大小即可。

接下来一步步图示:

1、初始状态

2、拖动+放大

3、去除它的放大缩小变换

// 获取当前的变换数据const origTransform = this.mindMap.draw.transform()// 去除放大缩小的变换效果,和translate一样也是在之前的基础上操作的,所以除以当前的缩放得到1this.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 }) }}

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(资料价值较高,非无偿)

最后

给大家分享一些关于HTML的面试题,有需要的朋友可以戳这里获取,先到先得哦。


img.onerror = (e) => {                reject(e)            }            img.src = svgSrc        })    }}

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-k18wSuDZ-1711656295638)]

[外链图片转存中…(img-aeJDQvEq-1711656295638)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

[外链图片转存中…(img-kyDW39TG-1711656295639)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(资料价值较高,非无偿)

最后

给大家分享一些关于HTML的面试题,有需要的朋友可以戳这里获取,先到先得哦。

[外链图片转存中…(img-fKK3oyrv-1711656295639)]
[外链图片转存中…(img-2cjsRBcM-1711656295640)]

  • 21
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值