diff --git a/README.md b/README.md index 12a304a2..208560d1 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ MIT +如果已过期,可以微信添加`wanglinguanfang`拉你入群。 + # 请作者喝杯咖啡 > 厚椰乳一盒 + 纯牛奶半盒 + 冰块 + 咖啡液 = 生椰拿铁 yyds diff --git a/index.html b/index.html index ffed24e8..728adb78 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -一个简单的web思维导图实现
\ No newline at end of file +思绪思维导图
\ No newline at end of file diff --git a/qrcode.jpg b/qrcode.jpg index 507f3135..03f72210 100644 Binary files a/qrcode.jpg and b/qrcode.jpg differ diff --git a/simple-mind-map/full.js b/simple-mind-map/full.js index 51ac4c7b..c119beb8 100644 --- a/simple-mind-map/full.js +++ b/simple-mind-map/full.js @@ -8,13 +8,21 @@ import Drag from './src/plugins/Drag.js' import Select from './src/plugins/Select.js' import AssociativeLine from './src/plugins/AssociativeLine' import RichText from './src/plugins/RichText' +import NodeImgAdjust from './src/plugins/NodeImgAdjust.js' +import TouchEvent from './src/plugins/TouchEvent.js' import xmind from './src/parse/xmind.js' import markdown from './src/parse/markdown.js' import icons from './src/svg/icons.js' +import * as constants from './src/constants/constant.js' +import themes from './src/themes/index.js' +import * as defaultTheme from './src/themes/default.js' MindMap.xmind = xmind MindMap.markdown = markdown MindMap.iconList = icons.nodeIconList +MindMap.constants = constants +MindMap.themes = themes +MindMap.defaultTheme = defaultTheme MindMap .usePlugin(MiniMap) @@ -26,5 +34,7 @@ MindMap .usePlugin(Select) .usePlugin(AssociativeLine) .usePlugin(RichText) + .usePlugin(TouchEvent) + .usePlugin(NodeImgAdjust) export default MindMap \ No newline at end of file diff --git a/simple-mind-map/index.js b/simple-mind-map/index.js index 3802b389..8cc6b91c 100644 --- a/simple-mind-map/index.js +++ b/simple-mind-map/index.js @@ -11,127 +11,7 @@ import { layoutValueList, CONSTANTS } from './src/constants/constant' import { SVG } from '@svgdotjs/svg.js' import { simpleDeepClone } from './src/utils' import defaultTheme, { checkIsNodeSizeIndependenceConfig } from './src/themes/default' - -// 默认选项配置 -const defaultOpt = { - // 是否只读 - readonly: false, - // 布局 - layout: CONSTANTS.LAYOUT.LOGICAL_STRUCTURE, - // 如果结构为鱼骨图,那么可以通过该选项控制倾斜角度 - fishboneDeg: 45, - // 主题 - theme: 'default', // 内置主题:default(默认主题) - // 主题配置,会和所选择的主题进行合并 - themeConfig: {}, - // 放大缩小的增量比例 - scaleRatio: 0.1, - // 最多显示几个标签 - maxTag: 5, - // 导出图片时的内边距 - exportPadding: 20, - // 展开收缩按钮尺寸 - expandBtnSize: 20, - // 节点里图片和文字的间距 - imgTextMargin: 5, - // 节点里各种文字信息的间距,如图标和文字的间距 - textContentMargin: 2, - // 多选节点时鼠标移动到边缘时的画布移动偏移量 - selectTranslateStep: 3, - // 多选节点时鼠标移动距边缘多少距离时开始偏移 - selectTranslateLimit: 20, - // 自定义节点备注内容显示 - customNoteContentShow: null, - /* - { - show(){}, - hide(){} - } - */ - // 是否开启节点自由拖拽 - enableFreeDrag: false, - // 水印配置 - watermarkConfig: { - text: '', - lineSpacing: 100, - textSpacing: 100, - angle: 30, - textStyle: { - color: '#999', - opacity: 0.5, - fontSize: 14 - } - }, - // 达到该宽度文本自动换行 - textAutoWrapWidth: 500, - // 自定义鼠标滚轮事件处理 - // 可以传一个函数,回调参数为事件对象 - customHandleMousewheel: null, - // 鼠标滚动的行为,如果customHandleMousewheel传了自定义函数,这个属性不生效 - mousewheelAction: CONSTANTS.MOUSE_WHEEL_ACTION.ZOOM,// zoom(放大缩小)、move(上下移动) - // 当mousewheelAction设为move时,可以通过该属性控制鼠标滚动一下视图移动的步长,单位px - mousewheelMoveStep: 100, - // 默认插入的二级节点的文字 - defaultInsertSecondLevelNodeText: '二级节点', - // 默认插入的二级以下节点的文字 - defaultInsertBelowSecondLevelNodeText: '分支主题', - // 展开收起按钮的颜色 - expandBtnStyle: { - color: '#808080', - fill: '#fff' - }, - // 自定义展开收起按钮的图标 - expandBtnIcon: { - open: '',// svg字符串 - close: '' - }, - // 是否只有当鼠标在画布内才响应快捷键事件 - enableShortcutOnlyWhenMouseInSvg: true, - // 是否开启节点动画过渡 - enableNodeTransitionMove: true, - // 如果开启节点动画过渡,可以通过该属性设置过渡的时间,单位ms - nodeTransitionMoveDuration: 300, - // 初始根节点的位置 - initRootNodePosition: null, - // 导出png、svg、pdf时的图形内边距 - exportPaddingX: 10, - exportPaddingY: 10, - // 节点文本编辑框的z-index - nodeTextEditZIndex: 3000, - // 节点备注浮层的z-index - nodeNoteTooltipZIndex: 3000, - // 是否在点击了画布外的区域时结束节点文本的编辑状态 - isEndNodeTextEditOnClickOuter: true, - // 最大历史记录数 - maxHistoryCount: 1000, - // 是否一直显示节点的展开收起按钮,默认为鼠标移上去和激活时才显示 - alwaysShowExpandBtn: false, - // 扩展节点可插入的图标 - iconList: [ - // { - // name: '',// 分组名称 - // type: '',// 分组的值 - // list: [// 分组下的图标列表 - // { - // name: '',// 图标名称 - // icon:''// 图标,可以传svg或图片 - // } - // ] - // } - ], - // 节点最大缓存数量 - maxNodeCacheCount: 1000, - // 关联线默认文字 - defaultAssociativeLineText: '关联', - // 思维导图适应画布大小时的内边距 - fitPadding: 50, - // 是否开启按住ctrl键多选节点功能 - enableCtrlKeyNodeSelection: true, - // 设置为左键多选节点,右键拖动画布 - useLeftKeySelectionRightKeyDrag: false, - // 节点即将进入编辑前的回调方法,如果该方法返回true以外的值,那么将取消编辑,函数可以返回一个值,或一个Promise,回调参数为节点实例 - beforeTextEdit: null -} +import { defaultOpt } from './src/constants/defaultOptions' // 思维导图 class MindMap { @@ -314,7 +194,7 @@ class MindMap { this.opt.layout = layout this.view.reset() this.renderer.setLayout() - this.render() + this.render(null, CONSTANTS.CHANGE_LAYOUT) } // 执行命令 diff --git a/simple-mind-map/package.json b/simple-mind-map/package.json index 9c15737f..8939adc3 100644 --- a/simple-mind-map/package.json +++ b/simple-mind-map/package.json @@ -1,6 +1,6 @@ { "name": "simple-mind-map", - "version": "0.6.2", + "version": "0.6.6", "description": "一个简单的web在线思维导图", "authors": [ { diff --git a/simple-mind-map/src/constants/constant.js b/simple-mind-map/src/constants/constant.js index a441e99d..64826939 100644 --- a/simple-mind-map/src/constants/constant.js +++ b/simple-mind-map/src/constants/constant.js @@ -157,6 +157,7 @@ export const themeList = [ // 常量 export const CONSTANTS = { CHANGE_THEME: 'changeTheme', + CHANGE_LAYOUT: 'changeLayout', SET_DATA: 'setData', TRANSFORM_TO_NORMAL_NODE: 'transformAllNodesToNormalNode', MODE: { @@ -170,7 +171,8 @@ export const CONSTANTS = { CATALOG_ORGANIZATION: 'catalogOrganization', TIMELINE: 'timeline', TIMELINE2: 'timeline2', - FISHBONE: 'fishbone' + FISHBONE: 'fishbone', + VERTICAL_TIMELINE: 'verticalTimeline' }, DIR: { UP: 'up', @@ -206,8 +208,10 @@ export const CONSTANTS = { BOTTOM: 'bottom', CENTER: 'center' }, - TIMELINE_DIR: { + LAYOUT_GROW_DIR: { + LEFT: 'left', TOP: 'top', + RIGHT: 'right', BOTTOM: 'bottom' } } @@ -246,6 +250,10 @@ export const layoutList = [ name: '时间轴2', value: CONSTANTS.LAYOUT.TIMELINE2, }, + { + name: '竖向时间轴', + value: CONSTANTS.LAYOUT.VERTICAL_TIMELINE, + }, { name: '鱼骨图', value: CONSTANTS.LAYOUT.FISHBONE, @@ -258,6 +266,7 @@ export const layoutValueList = [ CONSTANTS.LAYOUT.ORGANIZATION_STRUCTURE, CONSTANTS.LAYOUT.TIMELINE, CONSTANTS.LAYOUT.TIMELINE2, + CONSTANTS.LAYOUT.VERTICAL_TIMELINE, CONSTANTS.LAYOUT.FISHBONE ] diff --git a/simple-mind-map/src/constants/defaultOptions.js b/simple-mind-map/src/constants/defaultOptions.js new file mode 100644 index 00000000..26abae16 --- /dev/null +++ b/simple-mind-map/src/constants/defaultOptions.js @@ -0,0 +1,130 @@ +import { CONSTANTS } from './constant' + +// 默认选项配置 +export const defaultOpt = { + // 是否只读 + readonly: false, + // 布局 + layout: CONSTANTS.LAYOUT.LOGICAL_STRUCTURE, + // 如果结构为鱼骨图,那么可以通过该选项控制倾斜角度 + fishboneDeg: 45, + // 主题 + theme: 'default', // 内置主题:default(默认主题) + // 主题配置,会和所选择的主题进行合并 + themeConfig: {}, + // 放大缩小的增量比例 + scaleRatio: 0.2, + // 鼠标缩放是否以鼠标当前位置为中心点,否则以画布中心点 + mouseScaleCenterUseMousePosition: true, + // 最多显示几个标签 + maxTag: 5, + // 导出图片时的内边距 + exportPadding: 20, + // 展开收缩按钮尺寸 + expandBtnSize: 20, + // 节点里图片和文字的间距 + imgTextMargin: 5, + // 节点里各种文字信息的间距,如图标和文字的间距 + textContentMargin: 2, + // 多选节点时鼠标移动到边缘时的画布移动偏移量 + selectTranslateStep: 3, + // 多选节点时鼠标移动距边缘多少距离时开始偏移 + selectTranslateLimit: 20, + // 自定义节点备注内容显示 + customNoteContentShow: null, + /* + { + show(){}, + hide(){} + } + */ + // 是否开启节点自由拖拽 + enableFreeDrag: false, + // 水印配置 + watermarkConfig: { + text: '', + lineSpacing: 100, + textSpacing: 100, + angle: 30, + textStyle: { + color: '#999', + opacity: 0.5, + fontSize: 14 + } + }, + // 达到该宽度文本自动换行 + textAutoWrapWidth: 500, + // 自定义鼠标滚轮事件处理 + // 可以传一个函数,回调参数为事件对象 + customHandleMousewheel: null, + // 鼠标滚动的行为,如果customHandleMousewheel传了自定义函数,这个属性不生效 + mousewheelAction: CONSTANTS.MOUSE_WHEEL_ACTION.ZOOM, // zoom(放大缩小)、move(上下移动) + // 当mousewheelAction设为move时,可以通过该属性控制鼠标滚动一下视图移动的步长,单位px + mousewheelMoveStep: 100, + // 当mousewheelAction设为zoom时,默认向前滚动是缩小,向后滚动是放大,如果该属性设为true,那么会反过来 + mousewheelZoomActionReverse: false, + // 默认插入的二级节点的文字 + defaultInsertSecondLevelNodeText: '二级节点', + // 默认插入的二级以下节点的文字 + defaultInsertBelowSecondLevelNodeText: '分支主题', + // 展开收起按钮的颜色 + expandBtnStyle: { + color: '#808080', + fill: '#fff' + }, + // 自定义展开收起按钮的图标 + expandBtnIcon: { + open: '', // svg字符串 + close: '' + }, + // 是否只有当鼠标在画布内才响应快捷键事件 + enableShortcutOnlyWhenMouseInSvg: true, + // 是否开启节点动画过渡 + enableNodeTransitionMove: true, + // 如果开启节点动画过渡,可以通过该属性设置过渡的时间,单位ms + nodeTransitionMoveDuration: 300, + // 初始根节点的位置 + initRootNodePosition: null, + // 导出png、svg、pdf时的图形内边距 + exportPaddingX: 10, + exportPaddingY: 10, + // 节点文本编辑框的z-index + nodeTextEditZIndex: 3000, + // 节点备注浮层的z-index + nodeNoteTooltipZIndex: 3000, + // 是否在点击了画布外的区域时结束节点文本的编辑状态 + isEndNodeTextEditOnClickOuter: true, + // 最大历史记录数 + maxHistoryCount: 1000, + // 是否一直显示节点的展开收起按钮,默认为鼠标移上去和激活时才显示 + alwaysShowExpandBtn: false, + // 扩展节点可插入的图标 + iconList: [ + // { + // name: '',// 分组名称 + // type: '',// 分组的值 + // list: [// 分组下的图标列表 + // { + // name: '',// 图标名称 + // icon:''// 图标,可以传svg或图片 + // } + // ] + // } + ], + // 节点最大缓存数量 + maxNodeCacheCount: 1000, + // 关联线默认文字 + defaultAssociativeLineText: '关联', + // 思维导图适应画布大小时的内边距 + fitPadding: 50, + // 是否开启按住ctrl键多选节点功能 + enableCtrlKeyNodeSelection: true, + // 设置为左键多选节点,右键拖动画布 + useLeftKeySelectionRightKeyDrag: false, + // 节点即将进入编辑前的回调方法,如果该方法返回true以外的值,那么将取消编辑,函数可以返回一个值,或一个Promise,回调参数为节点实例 + beforeTextEdit: null, + // 是否开启自定义节点内容 + isUseCustomNodeContent: false, + // 自定义返回节点内容的方法 + customCreateNodeContent: null +} diff --git a/simple-mind-map/src/core/render/Render.js b/simple-mind-map/src/core/render/Render.js index b2e3464a..708b689f 100644 --- a/simple-mind-map/src/core/render/Render.js +++ b/simple-mind-map/src/core/render/Render.js @@ -4,6 +4,7 @@ import MindMap from '../../layouts/MindMap' import CatalogOrganization from '../../layouts/CatalogOrganization' import OrganizationStructure from '../../layouts/OrganizationStructure' import Timeline from '../../layouts/Timeline' +import VerticalTimeline from '../../layouts/VerticalTimeline' import Fishbone from '../../layouts/Fishbone' import TextEdit from './TextEdit' import { copyNodeTree, simpleDeepClone, walk } from '../../utils' @@ -25,6 +26,8 @@ const layouts = { [CONSTANTS.LAYOUT.TIMELINE]: Timeline, // 时间轴2 [CONSTANTS.LAYOUT.TIMELINE2]: Timeline, + // 竖向时间轴 + [CONSTANTS.LAYOUT.VERTICAL_TIMELINE]: VerticalTimeline, // 鱼骨图 [CONSTANTS.LAYOUT.FISHBONE]: Fishbone, } @@ -425,6 +428,9 @@ class Render { let { defaultInsertSecondLevelNodeText, defaultInsertBelowSecondLevelNodeText } = this.mindMap.opt let list = appointNodes.length > 0 ? appointNodes : this.activeNodeList let first = list[0] + if (first.isGeneralization) { + return + } if (first.isRoot) { this.insertChildNode(openEdit, appointNodes, appointData) } else { @@ -455,6 +461,9 @@ class Render { let { defaultInsertSecondLevelNodeText, defaultInsertBelowSecondLevelNodeText } = this.mindMap.opt let list = appointNodes.length > 0 ? appointNodes : this.activeNodeList list.forEach(node => { + if (node.isGeneralization) { + return + } if (!node.nodeData.children) { node.nodeData.children = [] } @@ -687,11 +696,11 @@ class Render { if (node.isRoot) { return } - let copyData = copyNodeTree({}, node, false, true) + // let copyData = copyNodeTree({}, node, false, true) this.removeActiveNode(node) this.removeOneNode(node) this.mindMap.emit('node_active', null, this.activeNodeList) - toNode.nodeData.children.push(copyData) + toNode.nodeData.children.push(node.nodeData) this.mindMap.render() if (toNode.isRoot) { toNode.destroy() @@ -863,13 +872,14 @@ class Render { } // 设置节点图片 - setNodeImage(node, { url, title, width, height }) { + setNodeImage(node, { url, title, width, height, custom = false }) { this.setNodeDataRender(node, { image: url, imageTitle: title || '', imageSize: { width, - height + height, + custom } }) } diff --git a/simple-mind-map/src/core/render/TextEdit.js b/simple-mind-map/src/core/render/TextEdit.js index 888ef7e3..6ae819a9 100644 --- a/simple-mind-map/src/core/render/TextEdit.js +++ b/simple-mind-map/src/core/render/TextEdit.js @@ -65,11 +65,17 @@ export default class TextEdit { } // 显示文本编辑框 - async show(node) { - if (typeof this.mindMap.opt.beforeTextEdit === 'function') { + // isInserting:是否是刚创建的节点 + async show(node, e, isInserting = false) { + // 使用了自定义节点内容那么不响应编辑事件 + if (node.isUseCustomNodeContent()) { + return + } + let { beforeTextEdit } = this.mindMap.opt + if (typeof beforeTextEdit === 'function') { let isShow = false try { - isShow = await this.mindMap.opt.beforeTextEdit(node) + isShow = await beforeTextEdit(node, isInserting) } catch (error) { isShow = false } @@ -80,7 +86,7 @@ export default class TextEdit { this.mindMap.view.translateXY(offsetLeft, offsetTop) let rect = node._textData.node.node.getBoundingClientRect() if (this.mindMap.richText) { - this.mindMap.richText.showEditText(node, rect) + this.mindMap.richText.showEditText(node, rect, isInserting) return } this.showEditTextBox(node, rect) diff --git a/simple-mind-map/src/core/render/node/Node.js b/simple-mind-map/src/core/render/node/Node.js index 3d2bab6f..e0f36761 100644 --- a/simple-mind-map/src/core/render/node/Node.js +++ b/simple-mind-map/src/core/render/node/Node.js @@ -1,7 +1,7 @@ import Style from './Style' import Shape from './Shape' -import { asyncRun } from '../../../utils' -import { G, Rect } from '@svgdotjs/svg.js' +import { asyncRun, nodeToHTML } from '../../../utils' +import { G, Rect, ForeignObject, SVG } from '@svgdotjs/svg.js' import nodeGeneralizationMethods from './nodeGeneralization' import nodeExpandBtnMethods from './nodeExpandBtn' import nodeCommandWrapsMethods from './nodeCommandWraps' @@ -59,6 +59,7 @@ class Node { this.group = null this.shapeNode = null // 节点形状节点 // 节点内容对象 + this._customNodeContent = null this._imgData = null this._iconData = null this._textData = null @@ -154,6 +155,13 @@ class Node { // 创建节点的各个内容对象数据 createNodeData() { + // 自定义节点内容 + let { isUseCustomNodeContent, customCreateNodeContent } = this.mindMap.opt + if (isUseCustomNodeContent && customCreateNodeContent) { + this._customNodeContent = customCreateNodeContent(this) + } + // 如果没有返回内容,那么还是使用内置的节点内容 + if (this._customNodeContent) return this._imgData = this.createImgNode() this._iconData = this.createIconNode() this._textData = this.createTextNode() @@ -176,6 +184,14 @@ class Node { // 计算节点尺寸信息 getNodeRect() { + // 自定义节点内容 + if (this.isUseCustomNodeContent()) { + let rect = this.measureCustomNodeContentSize(this._customNodeContent) + return { + width: rect.width, + height: rect.height + } + } // 宽高 let imgContentWidth = 0 let imgContentHeight = 0 @@ -253,19 +269,20 @@ class Node { this.group.add(this.shapeNode) this.updateNodeShape() // 渲染一个隐藏的矩形区域,用来触发展开收起按钮的显示 - if (!this.mindMap.opt.alwaysShowExpandBtn) { - if (!this._unVisibleRectRegionNode) { - this._unVisibleRectRegionNode = new Rect() - } - this._unVisibleRectRegionNode.fill({ - color: 'transparent' - }).size(this.expandBtnSize, height).x(width).y(0) - this.group.add(this._unVisibleRectRegionNode) - } + this.renderExpandBtnPlaceholderRect() // 概要节点添加一个带所属节点id的类名 if (this.isGeneralization && this.generalizationBelongNode) { this.group.addClass('generalization_' + this.generalizationBelongNode.uid) } + // 如果存在自定义节点内容,那么使用自定义节点内容 + if (this.isUseCustomNodeContent()) { + let foreignObject = new ForeignObject() + foreignObject.width(width) + foreignObject.height(height) + foreignObject.add(SVG(this._customNodeContent)) + this.group.add(foreignObject) + return + } // 图片节点 let imgHeight = 0 if (this._imgData) { @@ -339,6 +356,21 @@ class Node { this.group.add(textContentNested) } + // 渲染展开收起按钮的隐藏占位元素 + renderExpandBtnPlaceholderRect() { + if (!this.mindMap.opt.alwaysShowExpandBtn) { + let { width, height } = this + if (!this._unVisibleRectRegionNode) { + this._unVisibleRectRegionNode = new Rect() + this._unVisibleRectRegionNode.fill({ + color: 'transparent' + }) + this.group.add(this._unVisibleRectRegionNode) + } + this.renderer.layout.renderExpandBtnRect(this._unVisibleRectRegionNode, this.expandBtnSize, width, height, this) + } + } + // 给节点绑定事件 bindGroupEvent() { // 单击事件,选中节点 @@ -408,7 +440,8 @@ class Node { // 右键菜单事件 this.group.on('contextmenu', e => { // 按住ctrl键点击鼠标左键不知为何触发的是contextmenu事件 - if (this.mindMap.opt.readonly || e.ctrlKey) {// || this.isGeneralization + if (this.mindMap.opt.readonly || e.ctrlKey) { + // || this.isGeneralization return } e.stopPropagation() @@ -442,8 +475,11 @@ class Node { if (!this.group) { return } - let { enableNodeTransitionMove, nodeTransitionMoveDuration, alwaysShowExpandBtn } = - this.mindMap.opt + let { + enableNodeTransitionMove, + nodeTransitionMoveDuration, + alwaysShowExpandBtn + } = this.mindMap.opt if (alwaysShowExpandBtn) { // 需要移除展开收缩按钮 if (this._expandBtn && this.nodeData.children.length <= 0) { @@ -518,6 +554,10 @@ class Node { this.needLayout = false this.layout() } + if (this.needRerenderExpandBtnPlaceholderRect) { + this.needRerenderExpandBtnPlaceholderRect = false + this.renderExpandBtnPlaceholderRect() + } this.update() } // 子节点 @@ -553,7 +593,7 @@ class Node { delete this.nodeData.inserting this.active() setTimeout(() => { - this.mindMap.emit('node_dblclick', this) + this.mindMap.emit('node_dblclick', this, null, true) }, 0) } } @@ -758,7 +798,7 @@ class Node { // 获取padding值 getPaddingVale() { - let { isActive }= this.nodeData.data + let { isActive } = this.nodeData.data return { paddingX: this.getStyle('paddingX', true, isActive), paddingY: this.getStyle('paddingY', true, isActive) diff --git a/simple-mind-map/src/core/render/node/nodeCreateContents.js b/simple-mind-map/src/core/render/node/nodeCreateContents.js index e175455f..9822484a 100644 --- a/simple-mind-map/src/core/render/node/nodeCreateContents.js +++ b/simple-mind-map/src/core/render/node/nodeCreateContents.js @@ -17,6 +17,15 @@ function createImgNode() { node.on('dblclick', e => { this.mindMap.emit('node_img_dblclick', this, e) }) + node.on('mouseenter', e => { + this.mindMap.emit('node_img_mouseenter', this, node, e) + }) + node.on('mouseleave', e => { + this.mindMap.emit('node_img_mouseleave', this, node, e) + }) + node.on('mousemove', e => { + this.mindMap.emit('node_img_mousemove', this, node, e) + }) return { node, width: imgSize[0], @@ -26,9 +35,12 @@ function createImgNode() { // 获取图片显示宽高 function getImgShowSize() { + const { custom, width, height } = this.nodeData.data.imageSize + // 如果是自定义了图片的宽高,那么不受最大宽高限制 + if (custom) return [width, height] return resizeImgSize( - this.nodeData.data.imageSize.width, - this.nodeData.data.imageSize.height, + width, + height, this.mindMap.themeConfig.imgMaxWidth, this.mindMap.themeConfig.imgMaxHeight ) @@ -89,7 +101,7 @@ function createRichTextNode() { el.style.maxWidth = this.mindMap.opt.textAutoWrapWidth + 'px' this.mindMap.el.appendChild(div) let { width, height } = el.getBoundingClientRect() - width = Math.ceil(width) + width = Math.ceil(width) + 1// 修复getBoundingClientRect方法对实际宽度是小数的元素获取到的值是整数,导致宽度不够文本发生换行的问题 height = Math.ceil(height) g.attr('data-width', width) g.attr('data-height', height) @@ -280,6 +292,32 @@ function createNoteNode() { } } +// 测量自定义节点内容元素的宽高 +let warpEl = null +function measureCustomNodeContentSize (content) { + if (!warpEl) { + warpEl = document.createElement('div') + warpEl.style.cssText = ` + position: fixed; + left: -99999px; + top: -99999px; + ` + this.mindMap.el.appendChild(warpEl) + } + warpEl.innerHTML = '' + warpEl.appendChild(content) + let rect = warpEl.getBoundingClientRect() + return { + width: rect.width, + height: rect.height + } +} + +// 是否使用的是自定义节点内容 +function isUseCustomNodeContent() { + return !!this._customNodeContent +} + export default { createImgNode, getImgShowSize, @@ -288,5 +326,7 @@ export default { createTextNode, createHyperlinkNode, createTagNode, - createNoteNode + createNoteNode, + measureCustomNodeContentSize, + isUseCustomNodeContent } \ No newline at end of file diff --git a/simple-mind-map/src/core/view/View.js b/simple-mind-map/src/core/view/View.js index 98df7ee0..2f89bf66 100644 --- a/simple-mind-map/src/core/view/View.js +++ b/simple-mind-map/src/core/view/View.js @@ -60,29 +60,38 @@ class View { }) // 放大缩小视图 this.mindMap.event.on('mousewheel', (e, dir, event, isTouchPad) => { + let { + customHandleMousewheel, + mousewheelAction, + mouseScaleCenterUseMousePosition, + mousewheelMoveStep, + mousewheelZoomActionReverse + } = this.mindMap.opt + // 是否自定义鼠标滚轮事件 if ( - this.mindMap.opt.customHandleMousewheel && - typeof this.mindMap.opt.customHandleMousewheel === 'function' + customHandleMousewheel && + typeof customHandleMousewheel === 'function' ) { - return this.mindMap.opt.customHandleMousewheel(e) + return customHandleMousewheel(e) } - if ( - this.mindMap.opt.mousewheelAction === CONSTANTS.MOUSE_WHEEL_ACTION.ZOOM - ) { + // 鼠标滚轮事件控制缩放 + if (mousewheelAction === CONSTANTS.MOUSE_WHEEL_ACTION.ZOOM) { + let cx = mouseScaleCenterUseMousePosition ? e.clientX : undefined + let cy = mouseScaleCenterUseMousePosition ? e.clientY : undefined switch (dir) { // 鼠标滚轮,向上和向左,都是缩小 case CONSTANTS.DIR.UP: case CONSTANTS.DIR.LEFT: - this.narrow() + mousewheelZoomActionReverse ? this.enlarge(cx, cy, isTouchPad) : this.narrow(cx, cy, isTouchPad) break // 鼠标滚轮,向下和向右,都是放大 case CONSTANTS.DIR.DOWN: case CONSTANTS.DIR.RIGHT: - this.enlarge() + mousewheelZoomActionReverse ? this.narrow(cx, cy, isTouchPad) : this.enlarge(cx, cy, isTouchPad) break } - } else { - let step = this.mindMap.opt.mousewheelMoveStep + } else {// 鼠标滚轮事件控制画布移动 + let step = mousewheelMoveStep if (isTouchPad) { step = 5 } @@ -170,8 +179,8 @@ class View { // 应用变换 transform() { this.mindMap.draw.transform({ + origin: [0, 0], scale: this.scale, - // origin: 'center center', translate: [this.x, this.y] }) this.mindMap.emit('view_data_change', this.getTransformData()) @@ -190,26 +199,45 @@ class View { } // 缩小 - narrow() { - if (this.scale - this.mindMap.opt.scaleRatio > 0.1) { - this.scale -= this.mindMap.opt.scaleRatio - } else { - this.scale = 0.1 - } + narrow(cx, cy, isTouchPad) { + const scaleRatio = this.mindMap.opt.scaleRatio / (isTouchPad ? 5 : 1) + const scale = Math.max(this.scale - scaleRatio, 0.1) + this.scaleInCenter(scale, cx, cy) this.transform() this.mindMap.emit('scale', this.scale) } // 放大 - enlarge() { - this.scale += this.mindMap.opt.scaleRatio + enlarge(cx, cy, isTouchPad) { + const scaleRatio = this.mindMap.opt.scaleRatio / (isTouchPad ? 5 : 1) + const scale = this.scale + scaleRatio + this.scaleInCenter(scale, cx, cy) this.transform() this.mindMap.emit('scale', this.scale) } - // 设置缩放 - setScale(scale) { + // 基于指定中心进行缩放,cx,cy 可不指定,此时会使用画布中心点 + scaleInCenter(scale, cx, cy) { + if (cx === undefined || cy === undefined) { + cx = this.mindMap.width / 2 + cy = this.mindMap.height / 2 + } + const prevScale = this.scale + const ratio = 1 - scale / prevScale + const dx = (cx - this.x) * ratio + const dy = (cy - this.y) * ratio + this.x += dx + this.y += dy this.scale = scale + } + + // 设置缩放 + setScale(scale, cx, cy) { + if (cx !== undefined && cy !== undefined) { + this.scaleInCenter(scale, cx, cy) + } else { + this.scale = scale + } this.transform() this.mindMap.emit('scale', this.scale) } diff --git a/simple-mind-map/src/layouts/Base.js b/simple-mind-map/src/layouts/Base.js index 701fffbf..ca27fcd4 100644 --- a/simple-mind-map/src/layouts/Base.js +++ b/simple-mind-map/src/layouts/Base.js @@ -48,6 +48,20 @@ class Base { return [CONSTANTS.CHANGE_THEME, CONSTANTS.TRANSFORM_TO_NORMAL_NODE].includes(this.renderer.renderSource) } + // 层级类型改变 + checkIsLayerTypeChange(oldIndex, newIndex) { + if (oldIndex >= 2 && newIndex >= 2) return false + if (oldIndex >= 2 && newIndex < 2) return true + if (oldIndex < 2 && newIndex >= 2) return true + } + + // 检查是否是结构布局改变重新渲染展开收起按钮占位元素 + checkIsLayoutChangeRerenderExpandBtnPlaceholderRect(node) { + if (this.renderer.renderSource === CONSTANTS.CHANGE_LAYOUT) { + node.needRerenderExpandBtnPlaceholderRect = true + } + } + // 创建节点实例 createNode(data, parent, isRoot, layerIndex) { // 创建节点 @@ -55,11 +69,13 @@ class Base { // 数据上保存了节点引用,那么直接复用节点 if (data && data._node && !this.renderer.reRender) { newNode = data._node + let isLayerTypeChange = this.checkIsLayerTypeChange(newNode.layerIndex, layerIndex) newNode.reset() newNode.layerIndex = layerIndex this.cacheNode(data._node.uid, newNode) + this.checkIsLayoutChangeRerenderExpandBtnPlaceholderRect(newNode) // 主题或主题配置改变了需要重新计算节点大小和布局 - if (this.checkIsNeedResizeSources()) { + if (this.checkIsNeedResizeSources() || isLayerTypeChange) { newNode.getSize() newNode.needLayout = true } @@ -68,16 +84,18 @@ class Base { newNode = this.lru.get(data.data.uid) // 保存该节点上一次的数据 let lastData = JSON.stringify(newNode.nodeData.data) + let isLayerTypeChange = this.checkIsLayerTypeChange(newNode.layerIndex, layerIndex) newNode.reset() newNode.nodeData = newNode.handleData(data || {}) newNode.layerIndex = layerIndex this.cacheNode(data.data.uid, newNode) + this.checkIsLayoutChangeRerenderExpandBtnPlaceholderRect(newNode) data._node = newNode // 主题或主题配置改变了需要重新计算节点大小和布局 let isResizeSource = this.checkIsNeedResizeSources() // 节点数据改变了需要重新计算节点大小和布局 let isNodeDataChange = lastData !== JSON.stringify(data.data) - if (isResizeSource || isNodeDataChange) { + if (isResizeSource || isNodeDataChange || isLayerTypeChange) { newNode.getSize() newNode.needLayout = true } diff --git a/simple-mind-map/src/layouts/CatalogOrganization.js b/simple-mind-map/src/layouts/CatalogOrganization.js index 72a00175..3e780c0d 100644 --- a/simple-mind-map/src/layouts/CatalogOrganization.js +++ b/simple-mind-map/src/layouts/CatalogOrganization.js @@ -349,6 +349,11 @@ class CatalogOrganization extends Base { gNode.left = right + generalizationNodeMargin gNode.top = top + (bottom - top - gNode.height) / 2 } + + // 渲染展开收起按钮的隐藏占位元素 + renderExpandBtnRect(rect, expandBtnSize, width, height, node) { + rect.size(width, expandBtnSize).x(0).y(height) + } } export default CatalogOrganization diff --git a/simple-mind-map/src/layouts/Fishbone.js b/simple-mind-map/src/layouts/Fishbone.js index e8581852..b11c2634 100644 --- a/simple-mind-map/src/layouts/Fishbone.js +++ b/simple-mind-map/src/layouts/Fishbone.js @@ -51,8 +51,8 @@ class Fishbone extends Base { // 节点生长方向 newNode.dir = index % 2 === 0 - ? CONSTANTS.TIMELINE_DIR.TOP - : CONSTANTS.TIMELINE_DIR.BOTTOM + ? CONSTANTS.LAYOUT_GROW_DIR.TOP + : CONSTANTS.LAYOUT_GROW_DIR.BOTTOM } // 计算二级节点的top值 if (parent._node.isRoot) { @@ -222,7 +222,7 @@ class Fishbone extends Base { // 检查节点是否是上方节点 checkIsTop(node) { - return node.dir === CONSTANTS.TIMELINE_DIR.TOP + return node.dir === CONSTANTS.LAYOUT_GROW_DIR.TOP } // 绘制连线,连接该节点到其子节点 @@ -239,7 +239,7 @@ class Fishbone extends Base { // 当前节点是根节点 // 根节点的子节点是和根节点同一水平线排列 let maxx = -Infinity - node.children.forEach((item) => { + node.children.forEach(item => { if (item.left > maxx) { maxx = item.left } @@ -250,15 +250,15 @@ class Fishbone extends Base { let line = this.draw.path() if (this.checkIsTop(item)) { line.plot( - `M ${nodeLineX - offsetX},${item.top + item.height + offset} L ${item.left},${ - item.top + item.height - }` + `M ${nodeLineX - offsetX},${item.top + item.height + offset} L ${ + item.left + },${item.top + item.height}` ) } else { line.plot( - `M ${nodeLineX - offsetX},${ - item.top - offset - } L ${nodeLineX},${item.top}` + `M ${nodeLineX - offsetX},${item.top - offset} L ${nodeLineX},${ + item.top + }` ) } node.style.line(line) @@ -373,6 +373,27 @@ class Fishbone extends Base { gNode.left = right + generalizationNodeMargin gNode.top = top + (bottom - top - gNode.height) / 2 } + + // 渲染展开收起按钮的隐藏占位元素 + renderExpandBtnRect(rect, expandBtnSize, width, height, node) { + let dir = '' + if (node.dir === CONSTANTS.LAYOUT_GROW_DIR.TOP) { + dir = + node.layerIndex === 1 + ? CONSTANTS.LAYOUT_GROW_DIR.TOP + : CONSTANTS.LAYOUT_GROW_DIR.BOTTOM + } else { + dir = + node.layerIndex === 1 + ? CONSTANTS.LAYOUT_GROW_DIR.BOTTOM + : CONSTANTS.LAYOUT_GROW_DIR.TOP + } + if (dir === CONSTANTS.LAYOUT_GROW_DIR.TOP) { + rect.size(width, expandBtnSize).x(0).y(-expandBtnSize) + } else { + rect.size(width, expandBtnSize).x(0).y(height) + } + } } export default Fishbone diff --git a/simple-mind-map/src/layouts/FishboneBottom.js b/simple-mind-map/src/layouts/FishboneBottom.js index 25adc205..ca66b6a6 100644 --- a/simple-mind-map/src/layouts/FishboneBottom.js +++ b/simple-mind-map/src/layouts/FishboneBottom.js @@ -52,8 +52,8 @@ class Fishbone extends Base { // 节点生长方向 newNode.dir = index % 2 === 0 - ? CONSTANTS.TIMELINE_DIR.TOP - : CONSTANTS.TIMELINE_DIR.BOTTOM + ? CONSTANTS.LAYOUT_GROW_DIR.TOP + : CONSTANTS.LAYOUT_GROW_DIR.BOTTOM } // 计算二级节点的top值 if (parent._node.isRoot) { diff --git a/simple-mind-map/src/layouts/FishboneTop.js b/simple-mind-map/src/layouts/FishboneTop.js index f7799027..0f4fa57a 100644 --- a/simple-mind-map/src/layouts/FishboneTop.js +++ b/simple-mind-map/src/layouts/FishboneTop.js @@ -52,8 +52,8 @@ class Fishbone extends Base { // 节点生长方向 newNode.dir = index % 2 === 0 - ? CONSTANTS.TIMELINE_DIR.TOP - : CONSTANTS.TIMELINE_DIR.BOTTOM + ? CONSTANTS.LAYOUT_GROW_DIR.TOP + : CONSTANTS.LAYOUT_GROW_DIR.BOTTOM } // 计算二级节点的top值 if (parent._node.isRoot) { @@ -281,7 +281,7 @@ class Fishbone extends Base { if ( node.parent && node.parent.isRoot && - node.dir === CONSTANTS.TIMELINE_DIR.TOP + node.dir === CONSTANTS.LAYOUT_GROW_DIR.TOP ) { line.plot( `M ${x},${top} L ${x + lineLength},${ diff --git a/simple-mind-map/src/layouts/LogicalStructure.js b/simple-mind-map/src/layouts/LogicalStructure.js index c136b0b1..518bac05 100644 --- a/simple-mind-map/src/layouts/LogicalStructure.js +++ b/simple-mind-map/src/layouts/LogicalStructure.js @@ -172,9 +172,7 @@ class LogicalStructure extends Base { let x2 = item.left let y2 = item.top + item.height / 2 // 节点使用横线风格,需要额外渲染横线 - let nodeUseLineStyleOffset = nodeUseLineStyle - ? item.width - : 0 + let nodeUseLineStyleOffset = nodeUseLineStyle ? item.width : 0 y1 = nodeUseLineStyle && !node.isRoot ? y1 + height / 2 : y1 y2 = nodeUseLineStyle ? y2 + item.height / 2 : y2 let path = `M ${x1},${y1} L ${x1 + s1},${y1} L ${x1 + s1},${y2} L ${ @@ -260,10 +258,7 @@ class LogicalStructure extends Base { if (_x === translateX && _y === translateY) { return } - btn.translate( - _x - translateX, - _y - translateY - ) + btn.translate(_x - translateX, _y - translateY) } // 创建概要节点 @@ -286,6 +281,11 @@ class LogicalStructure extends Base { gNode.left = right + generalizationNodeMargin gNode.top = top + (bottom - top - gNode.height) / 2 } + + // 渲染展开收起按钮的隐藏占位元素 + renderExpandBtnRect(rect, expandBtnSize, width, height, node) { + rect.size(expandBtnSize, height).x(width).y(0) + } } export default LogicalStructure diff --git a/simple-mind-map/src/layouts/MindMap.js b/simple-mind-map/src/layouts/MindMap.js index 6c030c4e..bdab6c67 100644 --- a/simple-mind-map/src/layouts/MindMap.js +++ b/simple-mind-map/src/layouts/MindMap.js @@ -1,5 +1,6 @@ import Base from './Base' import { walk, asyncRun } from '../utils' +import { CONSTANTS } from '../constants/constant' // 思维导图 class MindMap extends Base { @@ -45,11 +46,14 @@ class MindMap extends Base { newNode.dir = parent._node.dir } else { // 节点生长方向 - newNode.dir = index % 2 === 0 ? 'right' : 'left' + newNode.dir = + index % 2 === 0 + ? CONSTANTS.LAYOUT_GROW_DIR.RIGHT + : CONSTANTS.LAYOUT_GROW_DIR.LEFT } // 根据生长方向定位到父节点的左侧或右侧 newNode.left = - newNode.dir === 'right' + newNode.dir === CONSTANTS.LAYOUT_GROW_DIR.RIGHT ? parent._node.left + parent._node.width + this.getMarginX(layerIndex) @@ -72,7 +76,7 @@ class MindMap extends Base { let leftChildrenAreaHeight = 0 let rightChildrenAreaHeight = 0 cur._node.children.forEach(item => { - if (item.dir === 'left') { + if (item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT) { leftLen++ leftChildrenAreaHeight += item.height } else { @@ -109,7 +113,7 @@ class MindMap extends Base { let leftTotalTop = baseTop - node.leftChildrenAreaHeight / 2 let rightTotalTop = baseTop - node.rightChildrenAreaHeight / 2 node.children.forEach(cur => { - if (cur.dir === 'left') { + if (cur.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT) { cur.top = leftTotalTop leftTotalTop += cur.height + marginY } else { @@ -162,7 +166,10 @@ class MindMap extends Base { return } let _offset = 0 - let addHeight = item.dir === 'left' ? leftAddHeight : rightAddHeight + let addHeight = + item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + ? leftAddHeight + : rightAddHeight // 上面的节点往上移 if (_index < index) { _offset = -addHeight @@ -208,10 +215,8 @@ class MindMap extends Base { let x1 = 0 let _s = 0 // 节点使用横线风格,需要额外渲染横线 - let nodeUseLineStyleOffset = nodeUseLineStyle - ? item.width - : 0 - if (item.dir === 'left') { + let nodeUseLineStyleOffset = nodeUseLineStyle ? item.width : 0 + if (item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT) { _s = -s1 x1 = node.layerIndex === 0 ? left : left - expandBtnSize nodeUseLineStyleOffset = -nodeUseLineStyleOffset @@ -220,7 +225,10 @@ class MindMap extends Base { x1 = node.layerIndex === 0 ? left + width : left + width + expandBtnSize } let y1 = top + height / 2 - let x2 = item.dir === 'left' ? item.left + item.width : item.left + let x2 = + item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + ? item.left + item.width + : item.left let y2 = item.top + item.height / 2 y1 = nodeUseLineStyle && !node.isRoot ? y1 + height / 2 : y1 y2 = nodeUseLineStyle ? y2 + item.height / 2 : y2 @@ -246,18 +254,21 @@ class MindMap extends Base { let x1 = node.layerIndex === 0 ? left + width / 2 - : item.dir === 'left' + : item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT ? left - expandBtnSize : left + width + expandBtnSize let y1 = top + height / 2 - let x2 = item.dir === 'left' ? item.left + item.width : item.left + let x2 = + item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + ? item.left + item.width + : item.left let y2 = item.top + item.height / 2 y1 = nodeUseLineStyle && !node.isRoot ? y1 + height / 2 : y1 y2 = nodeUseLineStyle ? y2 + item.height / 2 : y2 // 节点使用横线风格,需要额外渲染横线 let nodeUseLineStylePath = '' if (nodeUseLineStyle) { - if (item.dir === 'left') { + if (item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT) { nodeUseLineStylePath = ` L ${item.left},${y2}` } else { nodeUseLineStylePath = ` L ${item.left + item.width},${y2}` @@ -283,11 +294,14 @@ class MindMap extends Base { let x1 = node.layerIndex === 0 ? left + width / 2 - : item.dir === 'left' + : item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT ? left - expandBtnSize : left + width + expandBtnSize let y1 = top + height / 2 - let x2 = item.dir === 'left' ? item.left + item.width : item.left + let x2 = + item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + ? item.left + item.width + : item.left let y2 = item.top + item.height / 2 let path = '' y1 = nodeUseLineStyle && !node.isRoot ? y1 + height / 2 : y1 @@ -295,7 +309,7 @@ class MindMap extends Base { // 节点使用横线风格,需要额外渲染横线 let nodeUseLineStylePath = '' if (this.mindMap.themeConfig.nodeUseLineStyle) { - if (item.dir === 'left') { + if (item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT) { nodeUseLineStylePath = ` L ${item.left},${y2}` } else { nodeUseLineStylePath = ` L ${item.left + item.width},${y2}` @@ -320,7 +334,8 @@ class MindMap extends Base { ? height / 2 : 0 // 位置没有变化则返回 - let _x = (node.dir === 'left' ? 0 - expandBtnSize : width) + let _x = + node.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT ? 0 - expandBtnSize : width let _y = height / 2 + nodeUseLineStyleOffset if (_x === translateX && _y === translateY) { return @@ -332,7 +347,7 @@ class MindMap extends Base { // 创建概要节点 renderGeneralization(node, gLine, gNode) { - let isLeft = node.dir === 'left' + let isLeft = node.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT let { top, bottom, @@ -358,6 +373,15 @@ class MindMap extends Base { (isLeft ? gNode.width : 0) gNode.top = top + (bottom - top - gNode.height) / 2 } + + // 渲染展开收起按钮的隐藏占位元素 + renderExpandBtnRect(rect, expandBtnSize, width, height, node) { + if (node.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT) { + rect.size(expandBtnSize, height).x(-expandBtnSize).y(0) + } else { + rect.size(expandBtnSize, height).x(width).y(0) + } + } } export default MindMap diff --git a/simple-mind-map/src/layouts/OrganizationStructure.js b/simple-mind-map/src/layouts/OrganizationStructure.js index f03883a0..179ef4c2 100644 --- a/simple-mind-map/src/layouts/OrganizationStructure.js +++ b/simple-mind-map/src/layouts/OrganizationStructure.js @@ -255,6 +255,11 @@ class OrganizationStructure extends Base { gNode.top = bottom + generalizationNodeMargin gNode.left = left + (right - left - gNode.width) / 2 } + + // 渲染展开收起按钮的隐藏占位元素 + renderExpandBtnRect(rect, expandBtnSize, width, height, node) { + rect.size(width, expandBtnSize).x(0).y(height) + } } export default OrganizationStructure diff --git a/simple-mind-map/src/layouts/Timeline.js b/simple-mind-map/src/layouts/Timeline.js index 60a41db2..a3f166fa 100644 --- a/simple-mind-map/src/layouts/Timeline.js +++ b/simple-mind-map/src/layouts/Timeline.js @@ -50,8 +50,8 @@ class Timeline extends Base { // 节点生长方向 newNode.dir = index % 2 === 0 - ? CONSTANTS.TIMELINE_DIR.BOTTOM - : CONSTANTS.TIMELINE_DIR.TOP + ? CONSTANTS.LAYOUT_GROW_DIR.BOTTOM + : CONSTANTS.LAYOUT_GROW_DIR.TOP } } else { newNode.dir = '' @@ -151,7 +151,7 @@ class Timeline extends Base { if ( parent && parent.isRoot && - node.dir === CONSTANTS.TIMELINE_DIR.TOP + node.dir === CONSTANTS.LAYOUT_GROW_DIR.TOP ) { // 遍历二级节点的子节点 node.children.forEach(item => { @@ -280,7 +280,7 @@ class Timeline extends Base { if ( node.parent && node.parent.isRoot && - node.dir === CONSTANTS.TIMELINE_DIR.TOP + node.dir === CONSTANTS.LAYOUT_GROW_DIR.TOP ) { line.plot(`M ${x},${top} L ${x},${miny}`) } else { @@ -301,7 +301,7 @@ class Timeline extends Base { if ( node.parent && node.parent.isRoot && - node.dir === CONSTANTS.TIMELINE_DIR.TOP + node.dir === CONSTANTS.LAYOUT_GROW_DIR.TOP ) { btn.translate( width * 0.3 - expandBtnSize / 2 - translateX, @@ -336,6 +336,28 @@ class Timeline extends Base { gNode.left = right + generalizationNodeMargin gNode.top = top + (bottom - top - gNode.height) / 2 } + + // 渲染展开收起按钮的隐藏占位元素 + renderExpandBtnRect(rect, expandBtnSize, width, height, node) { + if (this.layout === CONSTANTS.LAYOUT.TIMELINE) { + rect.size(width, expandBtnSize).x(0).y(height) + } else { + let dir = '' + if (node.dir === CONSTANTS.LAYOUT_GROW_DIR.TOP) { + dir = + node.layerIndex === 1 + ? CONSTANTS.LAYOUT_GROW_DIR.TOP + : CONSTANTS.LAYOUT_GROW_DIR.BOTTOM + } else { + dir = CONSTANTS.LAYOUT_GROW_DIR.BOTTOM + } + if (dir === CONSTANTS.LAYOUT_GROW_DIR.TOP) { + rect.size(width, expandBtnSize).x(0).y(-expandBtnSize) + } else { + rect.size(width, expandBtnSize).x(0).y(height) + } + } + } } export default Timeline diff --git a/simple-mind-map/src/layouts/VerticalTimeline.js b/simple-mind-map/src/layouts/VerticalTimeline.js new file mode 100644 index 00000000..d7e24829 --- /dev/null +++ b/simple-mind-map/src/layouts/VerticalTimeline.js @@ -0,0 +1,431 @@ +import Base from './Base' +import { walk, asyncRun } from '../utils' +import { CONSTANTS } from '../constants/constant' + +// 竖向时间轴 +class VerticalTimeline extends Base { + // 构造函数 + constructor(opt = {}, layout) { + super(opt) + this.layout = layout + } + + // 布局 + doLayout(callback) { + let task = [ + () => { + this.computedBaseValue() + }, + () => { + this.computedTopValue() + }, + () => { + this.adjustLeftTopValue() + }, + () => { + callback(this.root) + } + ] + asyncRun(task) + } + + // 遍历数据创建节点、计算根节点的位置,计算根节点的子节点的top值 + computedBaseValue() { + walk( + this.renderer.renderTree, + null, + (cur, parent, isRoot, layerIndex, index) => { + let newNode = this.createNode(cur, parent, isRoot, layerIndex) + // 根节点定位在画布中心位置 + if (isRoot) { + this.setNodeCenter(newNode) + } else { + // 非根节点 + // 节点生长方向 + // 三级及以下节点以上级为准 + if (parent._node.dir) { + newNode.dir = parent._node.dir + } else { + newNode.dir = + index % 2 === 0 + ? CONSTANTS.LAYOUT_GROW_DIR.RIGHT + : CONSTANTS.LAYOUT_GROW_DIR.LEFT + } + // 定位二级节点的left + if (parent._node.isRoot) { + newNode.left = + parent._node.left + + (cur._node.width > parent._node.width + ? -(cur._node.width - parent._node.width) / 2 + : (parent._node.width - cur._node.width) / 2) + } else { + newNode.left = + newNode.dir === CONSTANTS.LAYOUT_GROW_DIR.RIGHT + ? parent._node.left + + parent._node.width + + this.getMarginX(layerIndex) + : parent._node.left - + this.getMarginX(layerIndex) - + newNode.width + } + } + if (!cur.data.expand) { + return true + } + }, + (cur, parent, isRoot, layerIndex) => { + // 返回时计算节点的areaHeight,也就是子节点所占的高度之和,包括外边距 + if (isRoot) { + return + } + let len = cur.data.expand === false ? 0 : cur._node.children.length + cur._node.childrenAreaHeight = len + ? cur._node.children.reduce((h, item) => { + return h + item.height + }, 0) + + (len + 1) * this.getMarginY(layerIndex + 1) + : 0 + }, + true, + 0 + ) + } + + // 遍历节点树计算节点的top + computedTopValue() { + walk( + this.root, + null, + (node, parent, isRoot, layerIndex, index) => { + if ( + node.nodeData.data.expand && + node.children && + node.children.length + ) { + let marginY = this.getMarginY(layerIndex + 1) + // 定位二级节点的top + if (isRoot) { + let top = node.top + node.height + let totalTop = top + marginY + node.children.forEach(cur => { + cur.top = totalTop + totalTop += cur.height + marginY + }) + } else { + // 定位三级及以下节点的top + let marginY = this.getMarginY(layerIndex + 1) + let baseTop = node.top + node.height / 2 + marginY + // 第一个子节点的top值 = 该节点中心的top值 - 子节点的高度之和的一半 + let totalTop = baseTop - node.childrenAreaHeight / 2 + node.children.forEach(cur => { + cur.top = totalTop + totalTop += cur.height + marginY + }) + } + } + }, + null, + true + ) + } + + // 调整节点left、top + adjustLeftTopValue() { + walk( + this.root, + null, + (node, parent, isRoot, layerIndex) => { + if (!node.nodeData.data.expand) { + return + } + if (isRoot) return + // 判断子节点所占的高度之和是否大于该节点自身,大于则需要调整位置 + let base = this.getMarginY(layerIndex + 1) * 2 + node.height + let difference = node.childrenAreaHeight - base + if (difference > 0) { + this.updateBrothers(node, difference / 2) + } + }, + null, + true + ) + } + + // 更新兄弟节点的top + 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.hasCustomPosition()) return + // 三级或三级以下节点自身位置不需要动 + if (!node.parent.isRoot && item === node) return + let _offset = 0 + // 二级节点上面的兄弟节点不需要移动,自身需要往下移动 + if (node.parent.isRoot) { + // 上面的节点不用移 + if (_index < index) { + _offset = 0 + } else if (_index > index) { + // 下面的节点往下移 + _offset = addHeight * 2 + } else { + // 自身也要移动 + _offset = addHeight + } + } else { + // 三级或三级以下节点两侧的兄弟节点向两侧移动 + // 上面的节点往上移 + 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) + } + } + + // 调整兄弟节点的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, lineStyle) { + if (lineStyle === 'curve') { + this.renderLineCurve(node, lines, style) + } else if (lineStyle === 'direct') { + this.renderLineDirect(node, lines, style) + } else { + this.renderLineStraight(node, lines, style) + } + } + + // 直线连接 + renderLineStraight(node, lines, style) { + if (node.children.length <= 0) { + return [] + } + let { expandBtnSize } = node + if (!this.mindMap.opt.alwaysShowExpandBtn) { + expandBtnSize = 0 + } + if (node.isRoot) { + // 当前节点是根节点 + let prevBother = node + // 根节点的子节点是和根节点同一水平线排列 + node.children.forEach((item, index) => { + let y1 = prevBother.top + prevBother.height + let y2 = item.top + let x = node.left + node.width / 2 + let path = `M ${x},${y1} L ${x},${y2}` + lines[index].plot(path) + style && style(lines[index], item) + prevBother = item + }) + } else { + // 当前节点为非根节点 + if (node.dir === CONSTANTS.LAYOUT_GROW_DIR.RIGHT) { + let nodeRight = node.left + node.width + let nodeYCenter = node.top + node.height / 2 + let marginX = this.getMarginX(node.layerIndex + 1) + let offset = (marginX - expandBtnSize) * 0.6 + node.children.forEach((item, index) => { + let itemLeft = item.left + let itemYCenter = item.top + item.height / 2 + let path = ` + M ${nodeRight},${nodeYCenter} + L ${nodeRight + offset},${nodeYCenter} + L ${nodeRight + offset},${itemYCenter} + L ${itemLeft},${itemYCenter}` + lines[index].plot(path) + style && style(lines[index], item) + }) + } else { + let nodeLeft = node.left + let nodeYCenter = node.top + node.height / 2 + let marginX = this.getMarginX(node.layerIndex + 1) + let offset = (marginX - expandBtnSize) * 0.6 + node.children.forEach((item, index) => { + let itemRight = item.left + item.width + let itemYCenter = item.top + item.height / 2 + let path = ` + M ${nodeLeft},${nodeYCenter} + L ${nodeLeft - offset},${nodeYCenter} + L ${nodeLeft - offset},${itemYCenter} + L ${itemRight},${itemYCenter}` + lines[index].plot(path) + style && style(lines[index], item) + }) + } + } + } + + // 直连 + renderLineDirect(node, lines, style) { + if (node.children.length <= 0) { + return [] + } + let { left, top, width, height, expandBtnSize } = node + if (!this.mindMap.opt.alwaysShowExpandBtn) { + expandBtnSize = 0 + } + node.children.forEach((item, index) => { + if (node.isRoot) { + let prevBother = node + // 根节点的子节点是和根节点同一水平线排列 + node.children.forEach((item, index) => { + let y1 = prevBother.top + prevBother.height + let y2 = item.top + let x = node.left + node.width / 2 + let path = `M ${x},${y1} L ${x},${y2}` + lines[index].plot(path) + style && style(lines[index], item) + prevBother = item + }) + } else { + let x1 = + item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + ? left - expandBtnSize + : left + width + expandBtnSize + let y1 = top + height / 2 + let x2 = + item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + ? item.left + item.width + : item.left + let y2 = item.top + item.height / 2 + let path = `M ${x1},${y1} L ${x2},${y2}` + lines[index].plot(path) + style && style(lines[index], item) + } + }) + } + + // 曲线风格连线 + renderLineCurve(node, lines, style) { + if (node.children.length <= 0) { + return [] + } + let { left, top, width, height, expandBtnSize } = node + if (!this.mindMap.opt.alwaysShowExpandBtn) { + expandBtnSize = 0 + } + node.children.forEach((item, index) => { + if (node.isRoot) { + let prevBother = node + // 根节点的子节点是和根节点同一水平线排列 + node.children.forEach((item, index) => { + let y1 = prevBother.top + prevBother.height + let y2 = item.top + let x = node.left + node.width / 2 + let path = `M ${x},${y1} L ${x},${y2}` + lines[index].plot(path) + style && style(lines[index], item) + prevBother = item + }) + } else { + let x1 = + item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + ? left - expandBtnSize + : left + width + expandBtnSize + let y1 = top + height / 2 + let x2 = + item.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + ? item.left + item.width + : item.left + let y2 = item.top + item.height / 2 + let path = this.cubicBezierPath(x1, y1, x2, y2) + lines[index].plot(path) + style && style(lines[index], item) + } + }) + } + + // 渲染按钮 + renderExpandBtn(node, btn) { + let { width, height, expandBtnSize, isRoot } = node + if (!isRoot) { + let { translateX, translateY } = btn.transform() + if (node.dir === CONSTANTS.LAYOUT_GROW_DIR.RIGHT) { + btn.translate(width - translateX, height / 2 - translateY) + } else { + btn.translate(-expandBtnSize - translateX, height / 2 - translateY) + } + } + } + + // 创建概要节点 + renderGeneralization(node, gLine, gNode) { + let isLeft = node.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + let { + top, + bottom, + left, + right, + generalizationLineMargin, + generalizationNodeMargin + } = this.getNodeBoundaries(node, 'h', isLeft) + let x = isLeft + ? left - generalizationLineMargin + : right + generalizationLineMargin + let x1 = x + let y1 = top + let x2 = x + let y2 = bottom + let cx = x1 + (isLeft ? -20 : 20) + let cy = y1 + (y2 - y1) / 2 + let path = `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}` + gLine.plot(path) + gNode.left = + x + + (isLeft ? -generalizationNodeMargin : generalizationNodeMargin) - + (isLeft ? gNode.width : 0) + gNode.top = top + (bottom - top - gNode.height) / 2 + } + + // 渲染展开收起按钮的隐藏占位元素 + renderExpandBtnRect(rect, expandBtnSize, width, height, node) { + if (node.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT) { + rect.size(expandBtnSize, height).x(-expandBtnSize).y(0) + } else { + rect.size(expandBtnSize, height).x(width).y(0) + } + } +} + +export default VerticalTimeline diff --git a/simple-mind-map/src/parse/xmind.js b/simple-mind-map/src/parse/xmind.js index 4f4a3faf..ed8810c2 100644 --- a/simple-mind-map/src/parse/xmind.js +++ b/simple-mind-map/src/parse/xmind.js @@ -1,5 +1,11 @@ import JSZip from 'jszip' import xmlConvert from 'xml-js' +import { + getTextFromHtml, + imgToDataUrl, + parseDataUrl, + getImageSize +} from '../utils/index' // 解析.xmind文件 const parseXmindFile = file => { @@ -10,7 +16,7 @@ const parseXmindFile = file => { let content = '' if (zip.files['content.json']) { let json = await zip.files['content.json'].async('string') - content = transformXmind(json) + content = await transformXmind(json, zip.files) } else if (zip.files['content.xml']) { let xml = await zip.files['content.xml'].async('string') let json = xmlConvert.xml2json(xml) @@ -33,18 +39,20 @@ const parseXmindFile = file => { } // 转换xmind数据 -const transformXmind = content => { +const transformXmind = async (content, files) => { let data = JSON.parse(content)[0] let nodeTree = data.rootTopic let newTree = {} - let walk = (node, newNode) => { + let waitLoadImageList = [] + let walk = async (node, newNode) => { newNode.data = { // 节点内容 text: node.title } // 节点备注 if (node.notes) { - newNode.data.note = (node.notes.realHTML || node.notes.plain).content + let notesData = node.notes.realHTML || node.notes.plain + newNode.data.note = notesData ? notesData.content || '' : '' } // 超链接 if (node.href && /^https?:\/\//.test(node.href)) { @@ -54,6 +62,42 @@ const transformXmind = content => { if (node.labels && node.labels.length > 0) { newNode.data.tag = node.labels } + // 图片 + if (node.image && /\.(jpg|jpeg|png|gif|webp)$/.test(node.image.src)) { + try { + // 处理异步逻辑 + let resolve = null + let promise = new Promise(_resolve => { + resolve = _resolve + }) + waitLoadImageList.push(promise) + // 读取图片 + let imageType = /\.([^.]+)$/.exec(node.image.src)[1] + let imageBase64 = + `data:image/${imageType};base64,` + + (await files['resources/' + node.image.src.split('/')[1]].async( + 'base64' + )) + newNode.data.image = imageBase64 + // 如果图片尺寸不存在 + if (!node.image.width && !node.image.height) { + let imageSize = await getImageSize(imageBase64) + newNode.data.imageSize = { + width: imageSize.width, + height: imageSize.height + } + } else { + newNode.data.imageSize = { + width: node.image.width, + height: node.image.height + } + } + resolve() + } catch (error) { + console.log(error) + resolve() + } + } // 子节点 newNode.children = [] if ( @@ -69,6 +113,7 @@ const transformXmind = content => { } } walk(nodeTree, newTree) + await Promise.all(waitLoadImageList) return newTree } @@ -157,8 +202,127 @@ const transformOldXmind = content => { return newTree } +// 数据转换为xmind文件 +const transformToXmind = async (data, name) => { + const id = 'simpleMindMap_' + Date.now() + const imageList = [] + // 转换核心数据 + let newTree = {} + let waitLoadImageList = [] + let walk = async (node, newNode, isRoot) => { + let newData = { + structureClass: 'org.xmind.ui.logic.right', + title: getTextFromHtml(node.data.text), // 节点文本 + children: { + attached: [] + } + } + // 备注 + if (node.data.note !== undefined) { + newData.notes = { + realHTML: { + content: node.data.note + }, + plain: { + content: node.data.note + } + } + } + // 超链接 + if (node.data.hyperlink !== undefined) { + newData.href = node.data.hyperlink + } + // 标签 + if (node.data.tag !== undefined) { + newData.labels = node.data.tag || [] + } + // 图片 + if (node.data.image) { + try { + // 处理异步逻辑 + let resolve = null + let promise = new Promise(_resolve => { + resolve = _resolve + }) + waitLoadImageList.push(promise) + let imgName = '' + let imgData = node.data.image + // 网络图片要先转换成data:url + if (/^https?:\/\//.test(node.data.image)) { + imgData = await imgToDataUrl(node.data.image) + } + // 从data:url中解析出图片类型和base64 + let dataUrlRes = parseDataUrl(imgData) + imgName = 'image_' + imageList.length + '.' + dataUrlRes.type + imageList.push({ + name: imgName, + data: dataUrlRes.base64 + }) + newData.image = { + src: 'xap:resources/' + imgName, + width: node.data.imageSize.width, + height: node.data.imageSize.height + } + resolve() + } catch (error) { + console.log(error) + resolve() + } + } + // 样式 + // 暂时不考虑样式 + if (isRoot) { + newData.class = 'topic' + newNode.id = id + newNode.class = 'sheet' + newNode.title = name + newNode.extensions = [] + newNode.topicPositioning = 'fixed' + newNode.topicOverlapping = 'overlap' + newNode.coreVersion = '2.100.0' + newNode.rootTopic = newData + } else { + Object.keys(newData).forEach(key => { + newNode[key] = newData[key] + }) + } + if (node.children && node.children.length > 0) { + node.children.forEach(child => { + let newChild = {} + walk(child, newChild) + newData.children.attached.push(newChild) + }) + } + } + walk(data, newTree, true) + await Promise.all(waitLoadImageList) + const contentData = [newTree] + // 创建压缩包 + const zip = new JSZip() + zip.file('content.json', JSON.stringify(contentData)) + zip.file( + 'metadata.json', + `{"modifier":"","dataStructureVersion":"1","layoutEngineVersion":"2","activeSheetId":"${id}"}` + ) + const manifestData = { + 'file-entries': { 'content.json': {}, 'metadata.json': {} } + } + // 图片 + if (imageList.length > 0) { + imageList.forEach(item => { + manifestData['file-entries']['resources/' + item.name] = {} + const img = zip.folder('resources') + img.file(item.name, item.data, { base64: true }) + }) + } + zip.file('manifest.json', JSON.stringify(manifestData)) + const zipData = await zip.generateAsync({ type: 'blob' }) + return zipData +} + export default { parseXmindFile, transformXmind, - transformOldXmind + transformOldXmind, + transformToXmind } diff --git a/simple-mind-map/src/plugins/Export.js b/simple-mind-map/src/plugins/Export.js index 5186ffe8..1f719d8f 100644 --- a/simple-mind-map/src/plugins/Export.js +++ b/simple-mind-map/src/plugins/Export.js @@ -180,6 +180,17 @@ class Export { this.mindMap.doExportPDF.pdf(name, img) } + // 导出为xmind + async xmind(name) { + if (!this.mindMap.doExportXMind) { + throw new Error('请注册ExportXMind插件') + } + const data = this.mindMap.getData() + const blob = await this.mindMap.doExportXMind.xmind(data, name) + const res = await readBlob(blob) + return res + } + // 导出为svg // plusCssText:附加的css样式,如果svg中存在dom节点,想要设置一些针对节点的样式可以通过这个参数传入 async svg(name, plusCssText) { diff --git a/simple-mind-map/src/plugins/ExportXMind.js b/simple-mind-map/src/plugins/ExportXMind.js new file mode 100644 index 00000000..5f54e220 --- /dev/null +++ b/simple-mind-map/src/plugins/ExportXMind.js @@ -0,0 +1,19 @@ +import xmind from '../parse/xmind' + +// 导出XMind类,需要通过Export插件使用 +class ExportXMind { + // 构造函数 + constructor(opt) { + this.mindMap = opt.mindMap + } + + // 导出xmind + async xmind(data, name) { + const zipData = await xmind.transformToXmind(data, name) + return zipData + } +} + +ExportXMind.instanceName = 'doExportXMind' + +export default ExportXMind diff --git a/simple-mind-map/src/plugins/NodeImgAdjust.js b/simple-mind-map/src/plugins/NodeImgAdjust.js new file mode 100644 index 00000000..5a6ab838 --- /dev/null +++ b/simple-mind-map/src/plugins/NodeImgAdjust.js @@ -0,0 +1,223 @@ +// 节点图片大小调整插件 +import { resizeImgSizeByOriginRatio } from '../utils/index' +import btnsSvg from '../svg/btns' + +class NodeImgAdjust { + // 构造函数 + constructor({ mindMap }) { + this.mindMap = mindMap + this.resizeBtnSize = 26 // 调整按钮的大小 + this.handleEl = null // 自定义元素,用来渲染临时图片、调整按钮 + this.isShowHandleEl = false // 自定义元素是否在显示中 + this.node = null // 当前节点实例 + this.img = null // 当前节点的图片节点 + this.rect = null // 当前图片节点的尺寸信息 + this.isMousedown = false // 当前是否是按住调整按钮状态 + this.currentImgWidth = 0 // 当前拖拽实时图片的大小 + this.currentImgHeight = 0 + this.isAdjusted = false // 是否是拖拽结束后的渲染期间 + this.bindEvent() + } + + // 监听事件 + bindEvent() { + this.onNodeImgMouseleave = this.onNodeImgMouseleave.bind(this) + this.onNodeImgMousemove = this.onNodeImgMousemove.bind(this) + this.onMousemove = this.onMousemove.bind(this) + this.onMouseup = this.onMouseup.bind(this) + this.onRenderEnd = this.onRenderEnd.bind(this) + this.mindMap.on('node_img_mouseleave', this.onNodeImgMouseleave) + this.mindMap.on('node_img_mousemove', this.onNodeImgMousemove) + this.mindMap.on('mousemove', this.onMousemove) + this.mindMap.on('mouseup', this.onMouseup) + this.mindMap.on('node_mouseup', this.onMouseup) + this.mindMap.on('node_tree_render_end', this.onRenderEnd) + } + + // 解绑事件 + unBindEvent() { + this.mindMap.off('node_img_mouseleave', this.onNodeImgMouseleave) + this.mindMap.off('node_img_mousemove', this.onNodeImgMousemove) + this.mindMap.off('mousemove', this.onMousemove) + this.mindMap.off('mouseup', this.onMouseup) + this.mindMap.off('node_mouseup', this.onMouseup) + this.mindMap.off('node_tree_render_end', this.onRenderEnd) + } + + // 节点图片鼠标移动事件 + onNodeImgMousemove(node, img) { + // 如果当前正在拖动调整中那么直接返回 + if (this.isMousedown || this.isAdjusted) return + // 如果在当前节点内移动,以及自定义元素已经是显示状态,那么直接返回 + if (this.node === node && this.isShowHandleEl) return + // 更新当前节点信息 + this.node = node + this.img = img + this.rect = this.img.rbox() + // 显示自定义元素 + this.showHandleEl() + } + + // 节点图片鼠标移出事件 + onNodeImgMouseleave() { + if (this.isMousedown) return + this.hideHandleEl() + } + + // 隐藏节点实际的图片 + hideNodeImage() { + if (!this.img) return + this.img.hide() + } + + // 显示节点实际的图片 + showNodeImage() { + if (!this.img) return + this.img.show() + } + + // 显示自定义元素 + showHandleEl() { + if (!this.handleEl) { + this.createResizeBtnEl() + } + this.setHandleElRect() + document.body.appendChild(this.handleEl) + this.isShowHandleEl = true + } + + // 隐藏自定义元素 + hideHandleEl() { + if (!this.isShowHandleEl) return + this.isShowHandleEl = false + document.body.removeChild(this.handleEl) + this.handleEl.style.backgroundImage = `` + this.handleEl.style.width = 0 + this.handleEl.style.height = 0 + this.handleEl.style.left = 0 + this.handleEl.style.top = 0 + } + + // 设置自定义元素尺寸位置信息 + setHandleElRect() { + let { width, height, x, y } = this.rect + this.handleEl.style.left = `${x}px` + this.handleEl.style.top = `${y}px` + this.currentImgWidth = width + this.currentImgHeight = height + this.updateHandleElSize() + } + + // 更新自定义元素宽高 + updateHandleElSize() { + this.handleEl.style.width = `${this.currentImgWidth}px` + this.handleEl.style.height = `${this.currentImgHeight}px` + } + + // 创建调整按钮元素 + createResizeBtnEl() { + // 容器元素 + this.handleEl = document.createElement('div') + this.handleEl.style.cssText = ` + pointer-events: none; + position: fixed; + background-size: cover; + ` + // 调整按钮元素 + const btnEl = document.createElement('div') + btnEl.innerHTML = btnsSvg.imgAdjust + btnEl.style.cssText = ` + position: absolute; + right: 0; + bottom: 0; + pointer-events: auto; + background-color: rgba(0, 0, 0, 0.3); + width: ${this.resizeBtnSize}px; + height: ${this.resizeBtnSize}px; + display: flex; + justify-content: center; + align-items: center; + cursor: nwse-resize; + ` + this.handleEl.appendChild(btnEl) + // 给按钮元素绑定事件 + btnEl.addEventListener('mouseenter', () => { + // 移入按钮,会触发节点图片的移出事件,所以需要再次显示按钮 + this.showHandleEl() + }) + btnEl.addEventListener('mouseleave', () => { + // 移除按钮,需要隐藏按钮 + if (this.isMousedown) return + this.hideHandleEl() + }) + btnEl.addEventListener('mousedown', e => { + this.onMousedown(e) + }) + } + + // 鼠标按钮按下事件 + onMousedown() { + this.isMousedown = true + // 隐藏节点实际图片 + this.hideNodeImage() + // 将节点图片渲染到自定义元素上 + this.handleEl.style.backgroundImage = `url(${this.node.nodeData.data.image})` + } + + // 鼠标移动 + onMousemove(e) { + if (!this.isMousedown) return + e.preventDefault() + // 计算当前拖拽位置对应的图片的实时大小 + let { width: imageOriginWidth, height: imageOriginHeight } = + this.node.nodeData.data.imageSize + let newWidth = e.clientX - this.rect.x + let newHeight = e.clientY - this.rect.y + if (newWidth <= 0 || newHeight <= 0) return + let [actWidth, actHeight] = resizeImgSizeByOriginRatio( + imageOriginWidth, + imageOriginHeight, + newWidth, + newHeight + ) + this.currentImgWidth = actWidth + this.currentImgHeight = actHeight + this.updateHandleElSize() + } + + // 鼠标松开 + onMouseup() { + if (!this.isMousedown) return + // 显示节点实际图片 + this.showNodeImage() + // 隐藏自定义元素 + this.hideHandleEl() + // 更新节点图片为新的大小 + let { image, imageTitle } = this.node.nodeData.data + let { scaleX, scaleY } = this.mindMap.draw.transform() + this.mindMap.execCommand('SET_NODE_IMAGE', this.node, { + url: image, + title: imageTitle, + width: this.currentImgWidth / scaleX, + height: this.currentImgHeight / scaleY, + custom: true // 代表自定义了图片大小 + }) + this.isAdjusted = true + this.isMousedown = false + } + + // 渲染完成事件 + onRenderEnd() { + if (!this.isAdjusted) return + this.isAdjusted = false + } + + // 插件被移除前做的事情 + beforePluginRemove() { + this.unBindEvent() + } +} + +NodeImgAdjust.instanceName = 'nodeImgAdjust' + +export default NodeImgAdjust diff --git a/simple-mind-map/src/plugins/RichText.js b/simple-mind-map/src/plugins/RichText.js index 47cc9ecd..d501c282 100644 --- a/simple-mind-map/src/plugins/RichText.js +++ b/simple-mind-map/src/plugins/RichText.js @@ -39,6 +39,7 @@ class RichText { this.range = null this.lastRange = null this.node = null + this.isInserting = false this.styleEl = null this.cacheEditingText = '' this.lostStyle = false @@ -145,11 +146,12 @@ class RichText { } // 显示文本编辑控件 - showEditText(node, rect) { + showEditText(node, rect, isInserting) { if (this.showTextEdit) { return } this.node = node + this.isInserting = isInserting if (!rect) rect = node._textData.node.node.getBoundingClientRect() this.mindMap.emit('before_show_text_edit') this.mindMap.renderer.textEdit.registerTmpShortcut() @@ -200,7 +202,8 @@ class RichText { this.initQuillEditor() document.querySelector('.ql-editor').style.minHeight = originHeight + 'px' this.showTextEdit = true - this.focus() + // 如果是刚创建的节点,那么默认全选,否则普通激活不全选 + this.focus(isInserting ? 0 : null) if (!node.nodeData.data.richText) { // 如果是非富文本的情况,需要手动应用文本样式 this.setTextStyleIfNotRichText(node) @@ -250,6 +253,7 @@ class RichText { this.showTextEdit = false this.mindMap.emit('rich_text_selection_change', false) this.node = null + this.isInserting = false } // 初始化Quill富文本编辑器 @@ -271,6 +275,8 @@ class RichText { theme: 'snow' }) this.quill.on('selection-change', range => { + // 刚创建的节点全选不需要显示操作条 + if (this.isInserting) return this.lastRange = this.range this.range = null if (range) { @@ -338,9 +344,9 @@ class RichText { } // 聚焦 - focus() { + focus(start) { let len = this.quill.getLength() - this.quill.setSelection(len, len) + this.quill.setSelection(typeof start === 'number' ? start : len, len) } // 格式化当前选中的文本 diff --git a/simple-mind-map/src/plugins/TouchEvent.js b/simple-mind-map/src/plugins/TouchEvent.js index 135d6ece..5afc1f04 100644 --- a/simple-mind-map/src/plugins/TouchEvent.js +++ b/simple-mind-map/src/plugins/TouchEvent.js @@ -50,16 +50,20 @@ class TouchEvent { } else if (len === 2) { let touch1 = e.touches[0] let touch2 = e.touches[1] - let distance = Math.sqrt( - Math.pow(touch1.clientX - touch2.clientX, 2) + - Math.pow(touch1.clientY - touch2.clientY, 2) - ) + let ox = touch1.clientX - touch2.clientX + let oy = touch1.clientY - touch2.clientY + let distance = Math.sqrt(Math.pow(ox, 2) + Math.pow(oy, 2)) + // 以两指中心点进行缩放 + let { x: touch1ClientX, y: touch1ClientY } = this.mindMap.toPos(touch1.clientX, touch1.clientY) + let { x: touch2ClientX, y: touch2ClientY } = this.mindMap.toPos(touch2.clientX, touch2.clientY) + let cx = (touch1ClientX + touch2ClientX) / 2 + let cy = (touch1ClientY + touch2ClientY) / 2 if (distance > this.doubleTouchmoveDistance) { // 放大 - this.mindMap.view.enlarge() + this.mindMap.view.enlarge(cx, cy) } else { // 缩小 - this.mindMap.view.narrow() + this.mindMap.view.narrow(cx, cy) } this.doubleTouchmoveDistance = distance } @@ -82,7 +86,8 @@ class TouchEvent { this.clickNum = 0 this.dispatchMouseEvent('dblclick', ev.target, ev) } else { - this.dispatchMouseEvent('click', ev.target, ev) + // 点击事件应该不用模拟 + // this.dispatchMouseEvent('click', ev.target, ev) } } this.touchesNum = 0 diff --git a/simple-mind-map/src/svg/btns.js b/simple-mind-map/src/svg/btns.js index e456fe22..9b132737 100644 --- a/simple-mind-map/src/svg/btns.js +++ b/simple-mind-map/src/svg/btns.js @@ -4,7 +4,11 @@ const open = `` +// 图片调整按钮 +const imgAdjust = `` + export default { open, - close + close, + imgAdjust } diff --git a/simple-mind-map/src/utils/index.js b/simple-mind-map/src/utils/index.js index d8e95df2..f84f7fd2 100644 --- a/simple-mind-map/src/utils/index.js +++ b/simple-mind-map/src/utils/index.js @@ -50,6 +50,26 @@ export const bfsWalk = (root, callback) => { } } +// 按原比例缩放图片 +export const resizeImgSizeByOriginRatio = ( + width, + height, + newWidth, + newHeight +) => { + let arr = [] + let nRatio = width / height + let mRatio = newWidth / newHeight + if (nRatio > mRatio) { + // 固定高度 + arr = [nRatio * newHeight, newHeight] + } else { + // 固定宽度 + arr = [newWidth, newWidth / nRatio] + } + return arr +} + // 缩放图片尺寸 export const resizeImgSize = (width, height, maxWidth, maxHeight) => { let nRatio = width / height @@ -137,7 +157,12 @@ export const copyRenderTree = (tree, root, removeActiveState = false) => { } // 复制节点树数据 -export const copyNodeTree = (tree, root, removeActiveState = false, keepId = false) => { +export const copyNodeTree = ( + tree, + root, + removeActiveState = false, + keepId = false +) => { tree.data = simpleDeepClone(root.nodeData ? root.nodeData.data : root.data) // 去除节点id,因为节点id不能重复 if (tree.data.id && !keepId) delete tree.data.id @@ -188,6 +213,18 @@ export const imgToDataUrl = src => { }) } +// 解析dataUrl +export const parseDataUrl = data => { + if (!/^data:/.test(data)) return data + let [typeStr, base64] = data.split(',') + let res = /^data:[^/]+\/([^;]+);/.exec(typeStr) + let type = res[1] + return { + type, + base64 + } +} + // 下载文件 export const downloadFile = (file, fileName) => { let a = document.createElement('a') @@ -236,8 +273,8 @@ export const degToRad = deg => { return deg * (Math.PI / 180) } -// 驼峰转连字符 -export const camelCaseToHyphen = (str) => { +// 驼峰转连字符 +export const camelCaseToHyphen = str => { return str.replace(/([a-z])([A-Z])/g, (...args) => { return args[1] + '-' + args[2].toLowerCase() }) @@ -258,11 +295,8 @@ export const measureText = (text, { italic, bold, fontSize, fontFamily }) => { } measureTextContext.save() measureTextContext.font = font - const { - width, - actualBoundingBoxAscent, - actualBoundingBoxDescent - } = measureTextContext.measureText(text) + const { width, actualBoundingBoxAscent, actualBoundingBoxDescent } = + measureTextContext.measureText(text) measureTextContext.restore() const height = actualBoundingBoxAscent + actualBoundingBoxDescent return { width, height } @@ -270,7 +304,9 @@ export const measureText = (text, { italic, bold, fontSize, fontFamily }) => { // 拼接font字符串 export const joinFontStr = ({ italic, bold, fontSize, fontFamily }) => { - return `${italic ? 'italic ' : ''} ${bold ? 'bold ' : ''} ${fontSize}px ${fontFamily} ` + return `${italic ? 'italic ' : ''} ${ + bold ? 'bold ' : '' + } ${fontSize}px ${fontFamily} ` } // 在下一个事件循环里执行任务 @@ -336,7 +372,7 @@ export const checkNodeOuter = (mindMap, node) => { // 提取html字符串里的纯文本 let getTextFromHtmlEl = null -export const getTextFromHtml = (html) => { +export const getTextFromHtml = html => { if (!getTextFromHtmlEl) { getTextFromHtmlEl = document.createElement('div') } @@ -345,15 +381,46 @@ export const getTextFromHtml = (html) => { } // 将blob转成data:url -export const readBlob = (blob) => { +export const readBlob = blob => { return new Promise((resolve, reject) => { let reader = new FileReader() - reader.onload = (evt) => { + reader.onload = evt => { resolve(evt.target.result) } - reader.onerror = (err) => { + reader.onerror = err => { reject(err) } reader.readAsDataURL(blob) }) -} \ No newline at end of file +} + +// 将dom节点转换成html字符串 +let nodeToHTMLWrapEl = null +export const nodeToHTML = node => { + if (!nodeToHTMLWrapEl) { + nodeToHTMLWrapEl = document.createElement('div') + } + nodeToHTMLWrapEl.innerHTML = '' + nodeToHTMLWrapEl.appendChild(node) + return nodeToHTMLWrapEl.innerHTML +} + +// 获取图片大小 +export const getImageSize = src => { + return new Promise(resolve => { + let img = new Image() + img.src = src + img.onload = () => { + resolve({ + width: img.width, + height: img.height + }) + } + img.onerror = () => { + resolve({ + width: 0, + height: 0 + }) + } + }) +} diff --git a/web/public/index.html b/web/public/index.html index 294e768a..a5e6d6ba 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -5,7 +5,7 @@ - 一个简单的web思维导图实现 + 思绪思维导图