Feat:外框支持文本编辑,开发中

This commit is contained in:
街角小林
2025-03-28 17:52:01 +08:00
parent 37eab5f084
commit 6f0face378
5 changed files with 451 additions and 130 deletions

View File

@@ -496,6 +496,7 @@ export const defaultOpt = {
// 【OuterFrame】插件
outerFramePaddingX: 10,
outerFramePaddingY: 10,
defaultOuterFrameText: '外框',
// 【Painter】插件
// 是否只格式刷节点手动设置的样式,不考虑节点通过主题的应用的样式

View File

@@ -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

View File

@@ -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'

View File

@@ -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('<br>')
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
}

View File

@@ -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
}