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

上面说过每个节点是相对于其所有子节点居中显示的,那么如果我们知道所有子节点的总高度,那么第一个子节点的top也就确定了:

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

如图所示:

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

那么怎么计算childrenAreaHeight呢?首先第一次遍历到一个节点时,我们会给它创建一个Node实例,然后触发计算该节点的大小,所以只有当所有子节点都遍历完回来后我们才能计算总高度,那么显然可以在后序遍历的时候来计算。

但是要计算节点的top只能在下一次遍历渲染树时,为什么不在计算完一个节点的childrenAreaHeight后立即就计算其子节点的top呢?原因很简单,当前节点的top都还没确定,怎么确定其子节点的位置呢?

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

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

接下来开启第二轮遍历,这轮遍历可以计算所有节点的top,因为此时节点树已经创建成功了,所以可以不用再遍历渲染树,直接遍历节点树:

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

事情到这里并没有结束,请看下图:

可以看到对于每个节点来说,位置都是正确的,但是,整体来看就不对了,因为发生了重叠,原因很简单,因为【二级节点1】的子节点太多了,子节点占的总高度已经超出了该节点自身的高。

因为【二级节点】的定位是依据【二级节点】的总高度来计算的,并没有考虑到其子节点,解决方法也很简单,再来一轮遍历,当发现某个节点的子节点所占总高度大于其自身的高度时,就让该节点前后的节点都往外挪一挪。

比如上图,假设子节点所占的高度比节点自身的高度多出了100px,那我们就让【二级节点2】向下移动50px,如果它上面还有节点的话也让它向上移动50px,需要注意的是,这个调整的过程需要一直往父节点上冒泡,比如:

【子节点1-2】的子元素总高度明显大于其自身,所以【子节点1-1】需要往上移动,这样显然还不够,假设上面还有【二级节点0】的子节点,那么它们可能也要发生重叠了。

而且下方的【子节点2-1-1】和【子节点1-2-3】显然挨的太近了,所以【子节点1-1】自己的兄弟节点调整完后,父节点【二级节点1】的兄弟节点也需要同样进行调整,上面的往上移,下面的往下移,一直到根节点为止:

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

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

updateBrothers(node, addHeight) { if (node.parent) { let childrenList = node.parent.children // 找到自己处于第几个节点 let index = childrenList.findIndex((item) => { return item === node }) childrenList.forEach((item, _index) => { if (item === node) { return } let _offset = 0 // 上面的节点往上移 if (_index < index) { _offset = -addHeight } else if (_index > index) { // 下面的节点往下移 _offset = addHeight } // 移动节点 item.top += _offset // 节点自身移动了,还需要同步移动其所有下级节点 if (item.children && item.children.length) { this.updateChildren(item.children, ‘top’, _offset) } }) // 向上遍历,移动父节点的兄弟节点 this.updateBrothers(node.parent, addHeight) }}

// 更新节点的所有子节点的位置updateChildren(children, prop, offset) { children.forEach((item) => { item[prop] += offset if (item.children && item.children.length) { this.updateChildren(item.children, prop, offset) } })}

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

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

节点连线


节点定位好了,接下来就要进行连线,把节点和其所有子节点连接起来,连线风格有很多,可以使用直线,也可以使用曲线,直线的话很简单,因为所有节点的left、top、width、height都已经知道了,所以连接线的转折点坐标都可以轻松计算出来:

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

这种简单的曲线可以使用二次贝塞尔曲线,起点坐标为根节点的中间点:

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

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

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

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

img

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

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

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

最后的最后

面试题千万不要死记,一定要自己理解,用自己的方式表达出来,在这里预祝各位成功拿下自己心仪的offer。
需要完整面试题的朋友可以点击蓝色字体免费获取

大厂面试题

面试题目录

阿里一直到现在。**

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

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

[外链图片转存中…(img-dKgUCFxU-1713605783326)]

[外链图片转存中…(img-gw21mM4u-1713605783326)]

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

[外链图片转存中…(img-PlDeBqPu-1713605783327)]

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

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

[外链图片转存中…(img-CIHgPtEb-1713605783327)]

最后的最后

面试题千万不要死记,一定要自己理解,用自己的方式表达出来,在这里预祝各位成功拿下自己心仪的offer。
需要完整面试题的朋友可以点击蓝色字体免费获取

[外链图片转存中…(img-JMnJN77R-1713605783327)]

[外链图片转存中…(img-wCYCckvM-1713605783328)]

[外链图片转存中…(img-B43j4Uk0-1713605783328)]

[外链图片转存中…(img-c0xjMOdL-1713605783328)]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值