From 2de0334e3bb9dabfa4c19ac3ef98dfa293a8e4cf Mon Sep 17 00:00:00 2001 From: wanglin2 <1013335014@qq.com> Date: Tue, 11 Apr 2023 16:52:38 +0800 Subject: [PATCH 01/21] =?UTF-8?q?Feature=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E8=BD=B4=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- simple-mind-map/src/Render.js | 5 +- simple-mind-map/src/layouts/Timeline.js | 318 ++++++++++++++++++++++++ simple-mind-map/src/utils/constant.js | 10 +- web/src/pages/Edit/components/Edit.vue | 2 +- 4 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 simple-mind-map/src/layouts/Timeline.js diff --git a/simple-mind-map/src/Render.js b/simple-mind-map/src/Render.js index 28b9e83f..a1b160b7 100644 --- a/simple-mind-map/src/Render.js +++ b/simple-mind-map/src/Render.js @@ -3,6 +3,7 @@ import LogicalStructure from './layouts/LogicalStructure' import MindMap from './layouts/MindMap' import CatalogOrganization from './layouts/CatalogOrganization' import OrganizationStructure from './layouts/OrganizationStructure' +import Timeline from './layouts/Timeline' import TextEdit from './TextEdit' import { copyNodeTree, simpleDeepClone, walk } from './utils' import { shapeList } from './Shape' @@ -18,7 +19,9 @@ const layouts = { // 目录组织图 [CONSTANTS.LAYOUT.CATALOG_ORGANIZATION]: CatalogOrganization, // 组织结构图 - [CONSTANTS.LAYOUT.ORGANIZATION_STRUCTURE]: OrganizationStructure + [CONSTANTS.LAYOUT.ORGANIZATION_STRUCTURE]: OrganizationStructure, + // 时间轴 + [CONSTANTS.LAYOUT.TIMELINE]: Timeline } // 渲染 diff --git a/simple-mind-map/src/layouts/Timeline.js b/simple-mind-map/src/layouts/Timeline.js new file mode 100644 index 00000000..3a45be1b --- /dev/null +++ b/simple-mind-map/src/layouts/Timeline.js @@ -0,0 +1,318 @@ +import Base from './Base' +import { walk, asyncRun } from '../utils' + +// 时间轴 +class CatalogOrganization extends Base { + // 构造函数 + constructor(opt = {}) { + super(opt) + } + + // 布局 + doLayout(callback) { + let task = [ + () => { + this.computedBaseValue() + }, + () => { + this.computedLeftTopValue() + }, + () => { + this.adjustLeftTopValue() + }, + () => { + callback(this.root) + } + ] + asyncRun(task) + } + + // 遍历数据创建节点、计算根节点的位置,计算根节点的子节点的top值 + computedBaseValue() { + walk( + this.renderer.renderTree, + null, + (cur, parent, isRoot, layerIndex) => { + let newNode = this.createNode(cur, parent, isRoot, layerIndex) + // 根节点定位在画布中心位置 + if (isRoot) { + this.setNodeCenter(newNode) + } else { + // 非根节点 + if (parent._node.isRoot) { + newNode.top = + parent._node.top + + (cur._node.height > parent._node.height + ? -(cur._node.height - parent._node.height) / 2 + : (parent._node.height - cur._node.height) / 2) + } + } + if (!cur.data.expand) { + return true + } + }, + (cur, parent, isRoot, layerIndex) => {}, + true, + 0 + ) + } + + // 遍历节点树计算节点的left、top + computedLeftTopValue() { + walk( + this.root, + null, + (node, parent, isRoot, layerIndex) => { + if ( + node.nodeData.data.expand && + node.children && + node.children.length + ) { + let marginX = this.getMarginX(layerIndex + 1) + let marginY = this.getMarginY(layerIndex + 1) + if (isRoot) { + let left = node.left + node.width + let totalLeft = left + marginX + node.children.forEach(cur => { + cur.left = totalLeft + totalLeft += cur.width + marginX + }) + } else { + let totalTop = node.top + node.height + marginY + node.expandBtnSize + node.children.forEach(cur => { + cur.left = node.left + node.width * 0.5 + cur.top = totalTop + totalTop += cur.height + marginY + node.expandBtnSize + }) + } + } + }, + null, + true + ) + } + + // 调整节点left、top + adjustLeftTopValue() { + walk( + this.root, + null, + (node, parent, isRoot, layerIndex) => { + if (!node.nodeData.data.expand) { + return + } + // 调整left + if (node.isRoot) { + this.updateBrothersLeft(node) + } + // 调整top + let len = node.children.length + if (parent && !parent.isRoot && len > 0) { + let marginY = this.getMarginY(layerIndex + 1) + let totalHeight = + node.children.reduce((h, item) => { + return h + item.height + }, 0) + + (len + 1) * marginY + + len * node.expandBtnSize + this.updateBrothersTop(node, totalHeight) + } + }, + null, + true + ) + } + + // 递归计算节点的宽度 + getNodeAreaWidth(node) { + let widthArr = [] + let loop = (node, width) => { + if (node.children.length) { + width += node.width / 2 + node.children.forEach(item => { + loop(item, width) + }) + } else { + width += node.width + widthArr.push(width) + } + } + loop(node, 0) + return Math.max(...widthArr) + } + + // 调整兄弟节点的left + updateBrothersLeft(node) { + let childrenList = node.children + let totalAddWidth = 0 + childrenList.forEach(item => { + item.left += totalAddWidth + if (item.children && item.children.length) { + this.updateChildren(item.children, 'left', totalAddWidth) + } + let areaWidth = this.getNodeAreaWidth(item) + let difference = areaWidth - item.width + if (difference > 0) { + totalAddWidth += difference + } + }) + } + + // 调整兄弟节点的top + updateBrothersTop(node, addHeight) { + if (node.parent && !node.parent.isRoot) { + let childrenList = node.parent.children + let index = childrenList.findIndex(item => { + return item === node + }) + childrenList.forEach((item, _index) => { + if (item.hasCustomPosition()) { + // 适配自定义位置 + return + } + let _offset = 0 + // 下面的节点往下移 + if (_index > index) { + _offset = addHeight + } + item.top += _offset + // 同步更新子节点的位置 + if (item.children && item.children.length) { + this.updateChildren(item.children, 'top', _offset) + } + }) + // 更新父节点的位置 + this.updateBrothersTop(node.parent, addHeight) + } + } + + // 绘制连线,连接该节点到其子节点 + renderLine(node, lines, style) { + if (node.children.length <= 0) { + return [] + } + let { left, top, width, height, expandBtnSize } = node + let len = node.children.length + if (node.isRoot) { + // 根节点 + let prevBother = node + node.children.forEach((item, index) => { + let y2 = item.top + item.height + // 节点使用横线风格,需要额外渲染横线 + let nodeUseLineStylePath = this.mindMap.themeConfig.nodeUseLineStyle + ? ` L ${item.left},${y2} L ${item.left + item.width},${y2}` + : '' + let path = + `M ${prevBother.left + prevBother.width},${ + node.top + node.height / 2 + } L ${item.left},${node.top + node.height / 2}` + nodeUseLineStylePath + // 竖线 + lines[index].plot(path) + style && style(lines[index], item) + prevBother = item + }) + } else { + // 非根节点 + let y1 = top + height + let maxy = -Infinity + let x2 = node.left + node.width * 0.3 + node.children.forEach((item, index) => { + // 为了适配自定义位置,下面做了各种位置的兼容 + let y2 = item.top + item.height / 2 + if (y2 > maxy) { + maxy = y2 + } + // 水平线 + let path = '' + let _left = item.left + let _isLeft = item.left + item.width < x2 + let _isXCenter = false + if (_isLeft) { + // 水平位置在父节点左边 + _left = item.left + item.width + } else if (item.left < x2 && item.left + item.width > x2) { + // 水平位置在父节点之间 + _isXCenter = true + y2 = item.top + maxy = y2 + } + if (y2 > top && y2 < y1) { + // 自定义位置的情况:垂直位置节点在父节点之间 + path = `M ${ + _isLeft ? node.left : node.left + node.width + },${y2} L ${_left},${y2}` + } else if (y2 < y1) { + // 自定义位置的情况:垂直位置节点在父节点上面 + if (_isXCenter) { + y2 = item.top + item.height + _left = x2 + } + path = `M ${x2},${top} L ${x2},${y2} L ${_left},${y2}` + } else { + if (_isXCenter) { + _left = x2 + } + path = `M ${x2},${y2} L ${_left},${y2}` + } + // 节点使用横线风格,需要额外渲染横线 + let nodeUseLineStylePath = this.mindMap.themeConfig.nodeUseLineStyle + ? ` L ${_left},${y2 - item.height / 2} L ${_left},${ + y2 + item.height / 2 + }` + : '' + path += nodeUseLineStylePath + lines[index].plot(path) + style && style(lines[index], item) + }) + // 竖线 + if (len > 0) { + let lin2 = this.draw.path() + expandBtnSize = len > 0 ? expandBtnSize : 0 + node.style.line(lin2) + if (maxy < y1 + expandBtnSize) { + lin2.hide() + } else { + lin2.plot(`M ${x2},${y1 + expandBtnSize} L ${x2},${maxy}`) + lin2.show() + } + node._lines.push(lin2) + style && style(lin2, node) + } + } + } + + // 渲染按钮 + renderExpandBtn(node, btn) { + let { width, height, expandBtnSize, isRoot } = node + if (!isRoot) { + let { translateX, translateY } = btn.transform() + btn.translate( + width * 0.3 - expandBtnSize / 2 - translateX, + height + expandBtnSize / 2 - translateY + ) + } + } + + // 创建概要节点 + renderGeneralization(node, gLine, gNode) { + let { + top, + bottom, + right, + generalizationLineMargin, + generalizationNodeMargin + } = this.getNodeBoundaries(node, 'h') + let x1 = right + generalizationLineMargin + let y1 = top + let x2 = right + generalizationLineMargin + let y2 = bottom + let cx = x1 + 20 + let cy = y1 + (y2 - y1) / 2 + let path = `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}` + gLine.plot(path) + gNode.left = right + generalizationNodeMargin + gNode.top = top + (bottom - top - gNode.height) / 2 + } +} + +export default CatalogOrganization diff --git a/simple-mind-map/src/utils/constant.js b/simple-mind-map/src/utils/constant.js index da156f31..32525e77 100644 --- a/simple-mind-map/src/utils/constant.js +++ b/simple-mind-map/src/utils/constant.js @@ -154,7 +154,8 @@ export const CONSTANTS = { LOGICAL_STRUCTURE: 'logicalStructure', MIND_MAP: 'mindMap', ORGANIZATION_STRUCTURE: 'organizationStructure', - CATALOG_ORGANIZATION: 'catalogOrganization' + CATALOG_ORGANIZATION: 'catalogOrganization', + TIMELINE: 'timeline' }, DIR: { UP: 'up', @@ -217,11 +218,16 @@ export const layoutList = [ { name: '目录组织图', value: CONSTANTS.LAYOUT.CATALOG_ORGANIZATION, + }, + { + name: '时间轴', + value: CONSTANTS.LAYOUT.TIMELINE, } ] export const layoutValueList = [ CONSTANTS.LAYOUT.LOGICAL_STRUCTURE, CONSTANTS.LAYOUT.MIND_MAP, CONSTANTS.LAYOUT.CATALOG_ORGANIZATION, - CONSTANTS.LAYOUT.ORGANIZATION_STRUCTURE + CONSTANTS.LAYOUT.ORGANIZATION_STRUCTURE, + CONSTANTS.LAYOUT.TIMELINE ] \ No newline at end of file diff --git a/web/src/pages/Edit/components/Edit.vue b/web/src/pages/Edit/components/Edit.vue index f6e16f70..e2536432 100644 --- a/web/src/pages/Edit/components/Edit.vue +++ b/web/src/pages/Edit/components/Edit.vue @@ -272,7 +272,7 @@ export default { this.mindMap = new MindMap({ el: this.$refs.mindMapContainer, data: root, - layout: layout, + layout: 'timeline', theme: theme.template, themeConfig: theme.config, viewData: view, From 25ecde894829cb6f3a3063baf0cc4d6c74f9b3c0 Mon Sep 17 00:00:00 2001 From: wanglin2 <1013335014@qq.com> Date: Tue, 11 Apr 2023 22:23:26 +0800 Subject: [PATCH 02/21] =?UTF-8?q?Fix:=E4=BF=AE=E5=A4=8D=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E5=92=8C=E7=94=BB=E5=B8=83=E5=8F=B3=E9=94=AE?= =?UTF-8?q?=E7=9A=84=E5=86=B2=E7=AA=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- simple-mind-map/src/Node.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/simple-mind-map/src/Node.js b/simple-mind-map/src/Node.js index fa208f11..8f1b8267 100644 --- a/simple-mind-map/src/Node.js +++ b/simple-mind-map/src/Node.js @@ -339,6 +339,9 @@ class Node { this.active(e) }) this.group.on('mousedown', e => { + if (this.isRoot && e.which === 3) { + e.stopPropagation() + } if (!this.isRoot) { e.stopPropagation() } From 876908e922fbbaafc21f3b67ec514fccc02a419b Mon Sep 17 00:00:00 2001 From: wanglin2 <1013335014@qq.com> Date: Tue, 11 Apr 2023 22:25:18 +0800 Subject: [PATCH 03/21] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/Doc/zh/course15/index.md | 123 +++++++++++++++++++++++- web/src/pages/Doc/zh/course15/index.vue | 98 ++++++++++++++++++- web/src/pages/Doc/zh/course16/index.md | 2 +- web/src/pages/Doc/zh/course16/index.vue | 4 +- web/src/pages/Edit/components/Edit.vue | 2 +- 5 files changed, 224 insertions(+), 5 deletions(-) diff --git a/web/src/pages/Doc/zh/course15/index.md b/web/src/pages/Doc/zh/course15/index.md index 9e7b5c95..1f4d7a91 100644 --- a/web/src/pages/Doc/zh/course15/index.md +++ b/web/src/pages/Doc/zh/course15/index.md @@ -1,3 +1,124 @@ # 如何渲染一个右键菜单 -编写中。。。 \ No newline at end of file +右键菜单可以方便的完成一些功能,大体上分两种,一是在画布上点击右键,二是在节点上点击右键,两者的功能肯定是不一样的,甚至根节点和其他节点功能上也有些不同,比如根节点不能添加同级节点,也不能被删除等等。 + +右键菜单的UI界面需要你自行开发,可以设置成绝对定位或固定定位,然后让它显示在鼠标右键点击的位置即可。 + +## 右键菜单的显示和隐藏 + +首先监听`node_contextmenu`事件在右键点击节点时显示菜单: + +```js +// 当前右键点击的类型 +const type = ref('') +// 如果点击的节点,那么代表被点击的节点 +const currentNode = shallowRef(null) +// 菜单显示的位置 +const left = ref(0) +const top = ref(0) +// 是否显示菜单 +const show = ref(false) + +mindMap.on('node_contextmenu', (e, node) => { + type.value = 'node' + left.value = e.clientX + 10 + top.value = e.clientY + 10 + show.value = true + currentNode.value = node +}) +``` + +你可以根据当前点击的节点来判断一些操作是否可用。比如根节点不能删除,不能插入同级节点,又比如同级第一个节点不能再被往上移,同级最后一个节点不能被往下移。 + +对于画布的处理会比较麻烦,不能直接监听`contextmenu`事件,因为会和右键多选节点冲突,所以需要结合`mousedown`事件和`mouseup`事件来处理。 + +```js +// 记录鼠标右键按下的位置 +const mousedownX = ref(0) +const mousedownY = ref(0) +const isMousedown = ref(false) + +mindMap.on('svg_mousedown', (e) => { + // 如果不是右键点击直接返回 + if (e.which !== 3) { + return + } + mousedownX.value = e.clientX + mousedownY.value = e.clientY + isMousedown.value = true +}) + +mindMap.on('mouseup', (e) => { + if (!isMousedown.value) { + return + } + isMousedown.value = false + // 如果鼠标松开和按下的距离大于3,则不认为是点击事件 + if ( + Math.abs(mousedownX.value - e.clientX) > 3 || + Math.abs(mousedownX.value - e.clientY) > 3 + ) { + hide() + return + } + type.value = 'svg' + left.value = e.clientX + 10 + top.value = e.clientY + 10 + show.value = true +}) +``` + +很简单,其实就是判断鼠标按下和松开的距离是否很小,是的话就认为是点击事件,否则应该是鼠标拖动。 + +右键菜单显示了,肯定就需要隐藏,当左键点击了画布、左键点击了节点、左键点击了展开收起按钮时都需要隐藏右键菜单。 + +```js +const hide = () => { + show.value = false + left.value = 0 + top.value = 0 + type.value = '' +} +mindMap.on('node_click', hide) +mindMap.on('draw_click', hide) +mindMap.on('expand_btn_click', hide) +``` + +## 复制、剪切、粘贴的实现 + +接下来介绍一下复制、剪切、粘贴的实现。 + +一般来说你的右键菜单中肯定也会添加这三个按钮,另外快捷键操作也是必不可少的,但是这三个快捷键是没有内置的,所以你需要自己注册一下: + +```js +mindMap.keyCommand.addShortcut('Control+c', copy) +mindMap.keyCommand.addShortcut('Control+v', paste) +mindMap.keyCommand.addShortcut('Control+x', cut) +``` + +如需删除调用`removeShortcut`方法即可。 + +接下来实现一下这三个方法: + +```js +// 保存复制/剪切的节点的数据,后续可以原来粘贴 +let copyData = null + +const copy = () => { + copyData = mindMap.renderer.copyNode() +} + +const cut = () => { + mindMap.execCommand('CUT_NODE', _copyData => { + copyData = _copyData + }) +} + +const paste = () => { + mindMap.execCommand('PASTE_NODE', copyData) +} +``` + +## 完整示例 + +