From 6f0face378d0e5c1d49bbe24b9f87552f299a08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A1=97=E8=A7=92=E5=B0=8F=E6=9E=97?= <1013335014@qq.com> Date: Fri, 28 Mar 2025 17:52:01 +0800 Subject: [PATCH] =?UTF-8?q?Feat=EF=BC=9A=E5=A4=96=E6=A1=86=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=96=87=E6=9C=AC=E7=BC=96=E8=BE=91=EF=BC=8C=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/constants/defaultOptions.js | 1 + simple-mind-map/src/plugins/OuterFrame.js | 226 ++++++++--------- simple-mind-map/src/plugins/RichText.js | 2 +- .../src/plugins/outerFrame/outerFrameText.js | 230 ++++++++++++++++++ .../src/plugins/outerFrame/outerFrameUtils.js | 122 ++++++++++ 5 files changed, 451 insertions(+), 130 deletions(-) create mode 100644 simple-mind-map/src/plugins/outerFrame/outerFrameText.js create mode 100644 simple-mind-map/src/plugins/outerFrame/outerFrameUtils.js diff --git a/simple-mind-map/src/constants/defaultOptions.js b/simple-mind-map/src/constants/defaultOptions.js index 86e7a0f5..0fd1ad11 100644 --- a/simple-mind-map/src/constants/defaultOptions.js +++ b/simple-mind-map/src/constants/defaultOptions.js @@ -496,6 +496,7 @@ export const defaultOpt = { // 【OuterFrame】插件 outerFramePaddingX: 10, outerFramePaddingY: 10, + defaultOuterFrameText: '外框', // 【Painter】插件 // 是否只格式刷节点手动设置的样式,不考虑节点通过主题的应用的样式 diff --git a/simple-mind-map/src/plugins/OuterFrame.js b/simple-mind-map/src/plugins/OuterFrame.js index 2bcffcd4..8fd8e5b3 100644 --- a/simple-mind-map/src/plugins/OuterFrame.js +++ b/simple-mind-map/src/plugins/OuterFrame.js @@ -1,149 +1,63 @@ import { formatDataToArray, walk, - getTopAncestorsFomNodeList, getNodeListBoundingRect, createUid } from '../utils' - -// 解析要添加外框的节点实例列表 -const parseAddNodeList = list => { - // 找出顶层节点 - list = getTopAncestorsFomNodeList(list) - const cache = {} - const uidToParent = {} - // 找出列表中节点在兄弟节点中的索引,并和父节点关联起来 - list.forEach(node => { - const parent = node.parent - if (parent) { - const pUid = parent.uid - uidToParent[pUid] = parent - const index = node.getIndexInBrothers() - const data = { - node, - index - } - if (cache[pUid]) { - if ( - !cache[pUid].find(item => { - return item.index === data.index - }) - ) { - cache[pUid].push(data) - } - } else { - cache[pUid] = [data] - } - } - }) - const res = [] - Object.keys(cache).forEach(uid => { - const indexList = cache[uid] - const parentNode = uidToParent[uid] - if (indexList.length > 1) { - // 多个节点 - const rangeList = indexList - .map(item => { - return item.index - }) - .sort((a, b) => { - return a - b - }) - const minIndex = rangeList[0] - const maxIndex = rangeList[rangeList.length - 1] - let curStart = -1 - let curEnd = -1 - for (let i = minIndex; i <= maxIndex; i++) { - // 连续索引 - if (rangeList.includes(i)) { - if (curStart === -1) { - curStart = i - } - curEnd = i - } else { - // 连续断开 - if (curStart !== -1 && curEnd !== -1) { - res.push({ - node: parentNode, - range: [curStart, curEnd] - }) - } - curStart = -1 - curEnd = -1 - } - } - // 不要忘了最后一段索引 - if (curStart !== -1 && curEnd !== -1) { - res.push({ - node: parentNode, - range: [curStart, curEnd] - }) - } - } else { - // 单个节点 - res.push({ - node: parentNode, - range: [indexList[0].index, indexList[0].index] - }) - } - }) - return res -} - -// 解析获取节点的子节点生成的外框列表 -const getNodeOuterFrameList = node => { - const children = node.children - if (!children || children.length <= 0) return - const res = [] - const map = {} - children.forEach((item, index) => { - const outerFrameData = item.getData('outerFrame') - if (!outerFrameData) return - const groupId = outerFrameData.groupId - if (groupId) { - if (!map[groupId]) { - map[groupId] = [] - } - map[groupId].push({ - node: item, - index - }) - } else { - res.push({ - nodeList: [item], - range: [index, index] - }) - } - }) - Object.keys(map).forEach(id => { - const list = map[id] - res.push({ - nodeList: list.map(item => { - return item.node - }), - range: [list[0].index, list[list.length - 1].index] - }) - }) - return res -} +import { + parseAddNodeList, + getNodeOuterFrameList +} from './outerFrame/outerFrameUtils' +import outerFrameTextMethods from './outerFrame/outerFrameText' // 默认外框样式 const defaultStyle = { + // 外框圆角大小 radius: 5, + // 外框边框宽度 strokeWidth: 2, + // 外框边框颜色 strokeColor: '#0984e3', + // 外框边框虚线样式 strokeDasharray: '5,5', - fill: 'rgba(9,132,227,0.05)' + // 外框填充颜色 + fill: 'rgba(9,132,227,0.05)', + // 外框文字字号 + fontSize: 14, + // 外框文字字体 + fontFamily: '微软雅黑, Microsoft YaHei', + // 外框文字颜色 + color: '#fff', + // 外框文字行高 + lineHeight: 1.2, + // 外框文字背景 + textFill: '#0984e3', + // 外框文字圆角 + textFillRadius: 5, + // 外框文字矩内边距,左上右下 + textFillPadding: [5, 5, 5, 5], + // 外框文字水平显示位置,相对于外框 + textAlign: 'left' // left、center、right } +const OUTER_FRAME_TEXT_EDIT_WRAP = 'outer-frame-text-edit-warp' + // 外框插件 class OuterFrame { constructor(opt = {}) { this.mindMap = opt.mindMap this.draw = null this.createDrawContainer() + this.textNodeList = [] this.outerFrameElList = [] this.activeOuterFrame = null + // 文字相关方法 + this.textEditNode = null + this.showTextEdit = false + Object.keys(outerFrameTextMethods).forEach(item => { + this[item] = outerFrameTextMethods[item].bind(this) + }) + this.mindMap.addEditNodeClass(OUTER_FRAME_TEXT_EDIT_WRAP) this.bindEvent() } @@ -164,6 +78,11 @@ class OuterFrame { this.clearActiveOuterFrame = this.clearActiveOuterFrame.bind(this) this.mindMap.on('draw_click', this.clearActiveOuterFrame) this.mindMap.on('node_click', this.clearActiveOuterFrame) + // 缩放事件 + this.mindMap.on('scale', this.onScale) + // 实例销毁事件 + this.onBeforeDestroy = this.onBeforeDestroy.bind(this) + this.mindMap.on('beforeDestroy', this.onBeforeDestroy) this.addOuterFrame = this.addOuterFrame.bind(this) this.mindMap.command.add('ADD_OUTER_FRAME', this.addOuterFrame) @@ -181,6 +100,8 @@ class OuterFrame { this.mindMap.off('data_change', this.renderOuterFrames) this.mindMap.off('draw_click', this.clearActiveOuterFrame) this.mindMap.off('node_click', this.clearActiveOuterFrame) + this.mindMap.off('scale', this.onScale) + this.mindMap.off('beforeDestroy', this.onBeforeDestroy) this.mindMap.command.remove('ADD_OUTER_FRAME', this.addOuterFrame) this.mindMap.keyCommand.removeShortcut( 'Del|Backspace', @@ -188,6 +109,12 @@ class OuterFrame { ) } + // 实例销毁时清除关联线文字编辑框 + onBeforeDestroy() { + this.hideEditTextBox() + this.removeTextEditEl() + } + // 给节点添加外框数据 /* config: { @@ -279,8 +206,14 @@ class OuterFrame { }) } + // 获取某个节点指定范围的带外框的第一个子节点 + getNodeRangeFirstNode(node, range) { + return node.children[range[0]] + } + // 渲染外框 renderOuterFrames() { + this.clearTextNodes() this.clearOuterFrameElList() let tree = this.mindMap.renderer.root if (!tree) return @@ -317,11 +250,15 @@ class OuterFrame { t.scaleY, (width + outerFramePaddingX * 2) / t.scaleX, (height + outerFramePaddingY * 2) / t.scaleY, - nodeList[0].getData('outerFrame') // 使用第一个节点的外框样式 + this.getStyle(nodeList[0]) // 使用第一个节点的外框样式 ) + // 渲染文字,如果有的话 + const textNode = this.createText(el, cur, range) + this.textNodeList.push(textNode) + this.renderText(this.getText(nodeList[0]), el, textNode, cur, range) el.on('click', e => { e.stopPropagation() - this.setActiveOuterFrame(el, cur, range) + this.setActiveOuterFrame(el, cur, range, textNode) }) }) } @@ -333,33 +270,54 @@ class OuterFrame { } // 激活外框 - setActiveOuterFrame(el, node, range) { + setActiveOuterFrame(el, node, range, textNode) { this.mindMap.execCommand('CLEAR_ACTIVE_NODE') this.clearActiveOuterFrame() this.activeOuterFrame = { el, node, - range + range, + textNode } el.stroke({ dasharray: 'none' }) + // 如果没有输入过文字,那么显示默认文字 + if (!this.getText(this.getNodeRangeFirstNode(node, range))) { + this.renderText( + this.mindMap.opt.defaultOuterFrameText, + el, + textNode, + node, + range + ) + } this.mindMap.emit('outer_frame_active', el, node, range) } // 清除当前激活的外框 clearActiveOuterFrame() { if (!this.activeOuterFrame) return - const { el } = this.activeOuterFrame + const { el, textNode, node, range } = this.activeOuterFrame el.stroke({ dasharray: el.cacheStyle.dasharray || defaultStyle.strokeDasharray }) + // 隐藏文本编辑框 + this.hideEditTextBox() + // 如果没有输入过文字,那么隐藏 + if (!this.getText(this.getNodeRangeFirstNode(node, range))) { + textNode.clear() + } this.activeOuterFrame = null } + // 获取指定外框的样式 + getStyle(node) { + return { ...defaultStyle, ...(node.getData('outerFrame') || {}) } + } + // 创建外框元素 createOuterFrameEl(x, y, width, height, styleConfig = {}) { - styleConfig = { ...defaultStyle, ...styleConfig } const el = this.draw .rect() .size(width, height) @@ -381,6 +339,13 @@ class OuterFrame { return el } + // 清除文本元素 + clearTextNodes() { + this.textNodeList.forEach(item => { + item.remove() + }) + } + // 清除外框元素 clearOuterFrameElList() { this.outerFrameElList.forEach(item => { @@ -392,15 +357,18 @@ class OuterFrame { // 插件被移除前做的事情 beforePluginRemove() { + this.mindMap.deleteEditNodeClass(OUTER_FRAME_TEXT_EDIT_WRAP) this.unBindEvent() } // 插件被卸载前做的事情 beforePluginDestroy() { + this.mindMap.deleteEditNodeClass(OUTER_FRAME_TEXT_EDIT_WRAP) this.unBindEvent() } } OuterFrame.instanceName = 'outerFrame' +OuterFrame.defaultStyle = defaultStyle export default OuterFrame diff --git a/simple-mind-map/src/plugins/RichText.js b/simple-mind-map/src/plugins/RichText.js index 5da0d5d9..9465886b 100644 --- a/simple-mind-map/src/plugins/RichText.js +++ b/simple-mind-map/src/plugins/RichText.js @@ -12,7 +12,7 @@ import { htmlEscape, compareVersion } from '../utils' -import { CONSTANTS, richTextSupportStyleList } from '../constants/constant' +import { richTextSupportStyleList } from '../constants/constant' import MindMapNode from '../core/render/node/MindMapNode' import { Scope } from 'parchment' diff --git a/simple-mind-map/src/plugins/outerFrame/outerFrameText.js b/simple-mind-map/src/plugins/outerFrame/outerFrameText.js new file mode 100644 index 00000000..bfb2439f --- /dev/null +++ b/simple-mind-map/src/plugins/outerFrame/outerFrameText.js @@ -0,0 +1,230 @@ +import { Text, Rect, G } from '@svgdotjs/svg.js' +import { + getStrWithBrFromHtml, + focusInput, + selectAllInput +} from '../../utils/index' + +const OUTER_FRAME_TEXT_EDIT_WRAP = 'outer-frame-text-edit-warp' + +// 创建文字节点 +function createText(el, cur, range) { + const g = this.draw.group() + const setActive = () => { + if (!this.activeOuterFrame || this.activeOuterFrame.el !== el) { + this.setActiveOuterFrame(el, cur, range, g) + } + } + g.click(e => { + e.stopPropagation() + setActive() + }) + g.on('dblclick', e => { + e.stopPropagation() + setActive() + this.showEditTextBox(g) + }) + return g +} + +// 显示文本编辑框 +function showEditTextBox(g) { + this.mindMap.emit('before_show_text_edit') + // 注册回车快捷键 + this.mindMap.keyCommand.addShortcut('Enter', () => { + this.hideEditTextBox() + }) + // 输入框元素没有创建过,则先创建 + if (!this.textEditNode) { + this.textEditNode = document.createElement('div') + this.textEditNode.className = OUTER_FRAME_TEXT_EDIT_WRAP + this.textEditNode.style.cssText = ` + position: fixed; + box-sizing: border-box; + background-color: #fff; + box-shadow: 0 0 20px rgba(0,0,0,.5); + outline: none; + word-break: break-all; + ` + this.textEditNode.setAttribute('contenteditable', true) + this.textEditNode.addEventListener('keyup', e => { + e.stopPropagation() + }) + this.textEditNode.addEventListener('click', e => { + e.stopPropagation() + }) + const targetNode = this.mindMap.opt.customInnerElsAppendTo || document.body + targetNode.appendChild(this.textEditNode) + } + const { node, range } = this.activeOuterFrame + const style = this.getStyle(this.getNodeRangeFirstNode(node, range)) + const [pl, pt, pr, pb] = style.textFillPadding + let { defaultOuterFrameText, nodeTextEditZIndex } = this.mindMap.opt + let scale = this.mindMap.view.scale + let text = this.getText(this.getNodeRangeFirstNode(node, range)) + let textLines = (text || defaultOuterFrameText).split(/\n/gim) + this.textEditNode.style.padding = `${pl}px ${pt}px ${pr}px ${pb}px` + this.textEditNode.style.fontFamily = style.fontFamily + this.textEditNode.style.fontSize = style.fontSize * scale + 'px' + this.textEditNode.style.lineHeight = + textLines.length > 1 ? style.lineHeight : 'normal' + this.textEditNode.style.zIndex = nodeTextEditZIndex + this.textEditNode.innerHTML = textLines.join('
') + this.textEditNode.style.display = 'block' + this.updateTextEditBoxPos(g) + this.setIsShowTextEdit(true) + // 如果是默认文本要全选输入框 + if (text === '' || text === defaultOuterFrameText) { + selectAllInput(this.textEditNode) + } else { + // 否则聚焦即可 + focusInput(this.textEditNode) + } +} + +// 设置文本编辑框是否处于显示状态 +function setIsShowTextEdit(val) { + this.showTextEdit = val + if (val) { + this.mindMap.keyCommand.stopCheckInSvg() + } else { + this.mindMap.keyCommand.recoveryCheckInSvg() + } +} + +// 删除文本编辑框元素 +function removeTextEditEl() { + if (!this.textEditNode) return + const targetNode = this.mindMap.opt.customInnerElsAppendTo || document.body + targetNode.removeChild(this.textEditNode) +} + +// 处理画布缩放 +function onScale() { + this.hideEditTextBox() +} + +// 更新文本编辑框位置 +function updateTextEditBoxPos(g) { + let rect = g.node.getBoundingClientRect() + if (this.textEditNode) { + this.textEditNode.style.minWidth = `${rect.width}px` + this.textEditNode.style.minHeight = `${rect.height}px` + this.textEditNode.style.left = `${rect.left}px` + this.textEditNode.style.top = `${rect.top}px` + } +} + +// 隐藏文本编辑框 +function hideEditTextBox() { + if (!this.showTextEdit) { + return + } + let { el, textNode, node, range } = this.activeOuterFrame + let str = getStrWithBrFromHtml(this.textEditNode.innerHTML) + // 如果是默认文本,那么不保存 + let isDefaultText = str === this.mindMap.opt.defaultOuterFrameText + str = isDefaultText ? '' : str + this.updateActiveOuterFrame({ + text: str + }) + this.textEditNode.style.display = 'none' + this.textEditNode.innerHTML = '' + this.setIsShowTextEdit(false) + this.renderText(str, el, textNode, node, range) + this.mindMap.emit('hide_text_edit') +} + +// 渲染文字 +function renderText(str, rect, textNode, node, range) { + if (!str) return + // 先清空文字节点原内容 + textNode.clear() + // 创建背景矩形 + const shape = new Rect() + textNode.add(shape) + // 获取样式配置 + const style = this.getStyle(this.getNodeRangeFirstNode(node, range)) + const [pl, pt, pr, pb] = style.textFillPadding + // 创建文本节点 + let textArr = str.replace(/\n$/g, '').split(/\n/gim) + const g = new G() + textArr.forEach((item, index) => { + // 避免尾部的空行不占宽度,导致文本编辑框定位异常的问题 + if (item === '') { + item = '' + } + let text = new Text().text(item) + text.y(style.fontSize * style.lineHeight * index) + this.styleText(text, style) + g.add(text) + }) + textNode.add(g) + // 计算高度 + const { width: textWidth, height: textHeight } = textNode.bbox() + const totalWidth = textWidth + pl + pr + const totalHeight = textHeight + pt + pb + shape.size(totalWidth, totalHeight).x(0).dy(0) + this.styleTextShape(shape, style) + // 设置节点位置 + let tx = 0 + switch (style.textAlign) { + case 'left': + tx = rect.x() + break + case 'center': + tx = rect.x() + rect.width() / 2 - totalWidth / 2 + break + case 'right': + tx = rect.x() + rect.width() - totalWidth + break + default: + break + } + const ty = rect.y() - totalHeight + shape.x(tx) + shape.y(ty) + g.x(tx + pl) + g.y(ty + pt) +} + +// 给文本背景设置样式 +function styleTextShape(shape, style) { + shape + .fill({ + color: style.textFill + }) + .radius(style.textFillRadius) +} + +// 给文本设置样式 +function styleText(textNode, style) { + textNode + .fill({ + color: style.color + }) + .css({ + 'font-family': style.fontFamily, + 'font-size': style.fontSize + 'px' + }) +} + +// 获取外框文字 +function getText(node) { + const data = node.getData('outerFrame') + return data && data.text ? data.text : '' +} + +export default { + getText, + createText, + styleTextShape, + styleText, + onScale, + showEditTextBox, + setIsShowTextEdit, + removeTextEditEl, + hideEditTextBox, + updateTextEditBoxPos, + renderText +} diff --git a/simple-mind-map/src/plugins/outerFrame/outerFrameUtils.js b/simple-mind-map/src/plugins/outerFrame/outerFrameUtils.js new file mode 100644 index 00000000..c69e1f75 --- /dev/null +++ b/simple-mind-map/src/plugins/outerFrame/outerFrameUtils.js @@ -0,0 +1,122 @@ +import { getTopAncestorsFomNodeList } from '../../utils' + +// 解析要添加外框的节点实例列表 +export const parseAddNodeList = list => { + // 找出顶层节点 + list = getTopAncestorsFomNodeList(list) + const cache = {} + const uidToParent = {} + // 找出列表中节点在兄弟节点中的索引,并和父节点关联起来 + list.forEach(node => { + const parent = node.parent + if (parent) { + const pUid = parent.uid + uidToParent[pUid] = parent + const index = node.getIndexInBrothers() + const data = { + node, + index + } + if (cache[pUid]) { + if ( + !cache[pUid].find(item => { + return item.index === data.index + }) + ) { + cache[pUid].push(data) + } + } else { + cache[pUid] = [data] + } + } + }) + const res = [] + Object.keys(cache).forEach(uid => { + const indexList = cache[uid] + const parentNode = uidToParent[uid] + if (indexList.length > 1) { + // 多个节点 + const rangeList = indexList + .map(item => { + return item.index + }) + .sort((a, b) => { + return a - b + }) + const minIndex = rangeList[0] + const maxIndex = rangeList[rangeList.length - 1] + let curStart = -1 + let curEnd = -1 + for (let i = minIndex; i <= maxIndex; i++) { + // 连续索引 + if (rangeList.includes(i)) { + if (curStart === -1) { + curStart = i + } + curEnd = i + } else { + // 连续断开 + if (curStart !== -1 && curEnd !== -1) { + res.push({ + node: parentNode, + range: [curStart, curEnd] + }) + } + curStart = -1 + curEnd = -1 + } + } + // 不要忘了最后一段索引 + if (curStart !== -1 && curEnd !== -1) { + res.push({ + node: parentNode, + range: [curStart, curEnd] + }) + } + } else { + // 单个节点 + res.push({ + node: parentNode, + range: [indexList[0].index, indexList[0].index] + }) + } + }) + return res +} + +// 解析获取节点的子节点生成的外框列表 +export const getNodeOuterFrameList = node => { + const children = node.children + if (!children || children.length <= 0) return + const res = [] + const map = {} + children.forEach((item, index) => { + const outerFrameData = item.getData('outerFrame') + if (!outerFrameData) return + const groupId = outerFrameData.groupId + if (groupId) { + if (!map[groupId]) { + map[groupId] = [] + } + map[groupId].push({ + node: item, + index + }) + } else { + res.push({ + nodeList: [item], + range: [index, index] + }) + } + }) + Object.keys(map).forEach(id => { + const list = map[id] + res.push({ + nodeList: list.map(item => { + return item.node + }), + range: [list[0].index, list[list.length - 1].index] + }) + }) + return res +}