Files
mind-map/simple-mind-map/src/plugins/AssociativeLine.js

765 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { walk, bfsWalk, throttle } from '../utils'
import { v4 as uuid } from 'uuid'
import {
getAssociativeLineTargetIndex,
computeCubicBezierPathPoints,
cubicBezierPath,
getNodePoint,
computeNodePoints,
getNodeLinePath
} from './associativeLine/associativeLineUtils'
import associativeLineControlsMethods from './associativeLine/associativeLineControls'
import associativeLineTextMethods from './associativeLine/associativeLineText'
const styleProps = [
'associativeLineWidth',
'associativeLineColor',
'associativeLineActiveWidth',
'associativeLineActiveColor',
'associativeLineDasharray',
'associativeLineTextColor',
'associativeLineTextFontSize',
'associativeLineTextLineHeight',
'associativeLineTextFontFamily'
]
const ASSOCIATIVE_LINE_TEXT_EDIT_WRAP = 'associative-line-text-edit-warp'
// 关联线插件
class AssociativeLine {
constructor(opt = {}) {
this.mindMap = opt.mindMap
this.associativeLineDraw = this.mindMap.associativeLineDraw
// 本次不要重新渲染连线
this.isNotRenderAllLines = false
// 当前所有连接线
this.lineList = []
// 当前激活的连接线
this.activeLine = null
// 当前正在创建连接线
this.isCreatingLine = false // 是否正在创建连接线中
this.creatingStartNode = null // 起始节点
this.creatingLine = null // 创建过程中的连接线
this.overlapNode = null // 创建过程中的目标节点
// 是否有节点正在被拖拽
this.isNodeDragging = false
// 控制点
this.controlLine1 = null
this.controlLine2 = null
this.controlPoint1 = null
this.controlPoint2 = null
this.controlPointDiameter = 10
this.isControlPointMousedown = false
this.mousedownControlPointKey = ''
this.controlPointMousemoveState = {
pos: null,
startPoint: null,
endPoint: null,
targetIndex: ''
}
// 节流一下,不然很卡
this.checkOverlapNode = throttle(this.checkOverlapNode, 100, this)
// 控制点相关方法
Object.keys(associativeLineControlsMethods).forEach(item => {
this[item] = associativeLineControlsMethods[item].bind(this)
})
// 关联线文字相关方法
this.showTextEdit = false
Object.keys(associativeLineTextMethods).forEach(item => {
this[item] = associativeLineTextMethods[item].bind(this)
})
this.mindMap.addEditNodeClass(ASSOCIATIVE_LINE_TEXT_EDIT_WRAP)
this.bindEvent()
}
// 监听事件
bindEvent() {
this.renderAllLines = this.renderAllLines.bind(this)
this.onDrawClick = this.onDrawClick.bind(this)
this.onNodeClick = this.onNodeClick.bind(this)
this.removeLine = this.removeLine.bind(this)
this.addLine = this.addLine.bind(this)
this.onMousemove = this.onMousemove.bind(this)
this.onNodeDragging = this.onNodeDragging.bind(this)
this.onNodeDragend = this.onNodeDragend.bind(this)
this.onControlPointMouseup = this.onControlPointMouseup.bind(this)
this.onBeforeDestroy = this.onBeforeDestroy.bind(this)
// 节点树渲染完毕后渲染连接线
this.mindMap.on('node_tree_render_end', this.renderAllLines)
// 状态改变后重新渲染连接线
this.mindMap.on('data_change', this.renderAllLines)
// 监听画布和节点点击事件,用于清除当前激活的连接线
this.mindMap.on('draw_click', this.onDrawClick)
this.mindMap.on('node_click', this.onNodeClick)
this.mindMap.on('contextmenu', this.onDrawClick)
// 注册删除快捷键
this.mindMap.keyCommand.addShortcut('Del|Backspace', this.removeLine)
// 注册添加连接线的命令
this.mindMap.command.add('ADD_ASSOCIATIVE_LINE', this.addLine)
// 监听鼠标移动事件
this.mindMap.on('mousemove', this.onMousemove)
// 节点拖拽事件
this.mindMap.on('node_dragging', this.onNodeDragging)
this.mindMap.on('node_dragend', this.onNodeDragend)
// 拖拽控制点
this.mindMap.on('mouseup', this.onControlPointMouseup)
// 缩放事件
this.mindMap.on('scale', this.onScale)
// 实例销毁事件
this.mindMap.on('beforeDestroy', this.onBeforeDestroy)
}
// 解绑事件
unBindEvent() {
this.mindMap.off('node_tree_render_end', this.renderAllLines)
this.mindMap.off('data_change', this.renderAllLines)
this.mindMap.off('draw_click', this.onDrawClick)
this.mindMap.off('node_click', this.onNodeClick)
this.mindMap.off('contextmenu', this.onDrawClick)
this.mindMap.keyCommand.removeShortcut('Del|Backspace', this.removeLine)
this.mindMap.command.remove('ADD_ASSOCIATIVE_LINE', this.addLine)
this.mindMap.off('mousemove', this.onMousemove)
this.mindMap.off('node_dragging', this.onNodeDragging)
this.mindMap.off('node_dragend', this.onNodeDragend)
this.mindMap.off('mouseup', this.onControlPointMouseup)
this.mindMap.off('scale', this.onScale)
this.mindMap.off('beforeDestroy', this.onBeforeDestroy)
}
// 获取关联线的样式配置
// 优先级:关联线自定义样式、节点自定义样式、主题的节点层级样式、主题的最外层样式
getStyleConfig(node, toNode) {
let lineStyle = {}
if (toNode) {
const associativeLineStyle = node.getData('associativeLineStyle') || {}
lineStyle = associativeLineStyle[toNode.getData('uid')] || {}
}
const res = {}
styleProps.forEach(prop => {
if (typeof lineStyle[prop] !== 'undefined') {
res[prop] = lineStyle[prop]
} else {
res[prop] = node.getStyle(prop)
}
})
return res
}
// 实例销毁时清除关联线文字编辑框
onBeforeDestroy() {
this.hideEditTextBox()
this.removeTextEditEl()
}
// 画布点击事件
onDrawClick() {
// 取消创建关联线
if (this.isCreatingLine) {
this.cancelCreateLine()
}
// 取消激活关联线
if (!this.isControlPointMousedown) {
this.clearActiveLine()
this.renderAllLines()
}
}
// 节点点击事件
onNodeClick(node) {
if (this.isCreatingLine) {
this.completeCreateLine(node)
} else {
this.clearActiveLine()
this.renderAllLines()
}
}
// 创建箭头
createMarker(callback = () => {}) {
return this.associativeLineDraw.marker(20, 20, add => {
add.ref(12, 5)
add.size(10, 10)
add.attr('orient', 'auto-start-reverse')
callback(add.path('M0,0 L2,5 L0,10 L10,5 Z'))
})
}
// 判断关联线坐标是否变更,有变更则使用变化后的坐标,无则默认坐标
updateAllLinesPos(node, toNode, associativeLinePoint) {
associativeLinePoint = associativeLinePoint || {}
let [startPoint, endPoint] = computeNodePoints(node, toNode)
let nodeRange = 0
let nodeDir = ''
let toNodeRange = 0
let toNodeDir = ''
if (associativeLinePoint.startPoint) {
nodeRange = associativeLinePoint.startPoint.range || 0
nodeDir = associativeLinePoint.startPoint.dir || 'right'
startPoint = getNodePoint(node, nodeDir, nodeRange)
}
if (associativeLinePoint.endPoint) {
toNodeRange = associativeLinePoint.endPoint.range || 0
toNodeDir = associativeLinePoint.endPoint.dir || 'right'
endPoint = getNodePoint(toNode, toNodeDir, toNodeRange)
}
return [startPoint, endPoint]
}
// 渲染所有连线
renderAllLines() {
if (this.isNotRenderAllLines) {
this.isNotRenderAllLines = false
return
}
// 先移除
this.removeAllLines()
this.removeControls()
this.clearActiveLine()
let tree = this.mindMap.renderer.root
if (!tree) return
let idToNode = new Map()
let nodeToIds = new Map()
walk(
tree,
null,
cur => {
if (!cur) return
let data = cur.getData()
if (
data.associativeLineTargets &&
data.associativeLineTargets.length > 0
) {
nodeToIds.set(cur, data.associativeLineTargets)
}
if (data.uid) {
idToNode.set(data.uid, cur)
}
},
() => {},
true,
0
)
nodeToIds.forEach((ids, node) => {
ids.forEach((uid, index) => {
let toNode = idToNode.get(uid)
if (!node || !toNode) return
const associativeLinePoint = (node.getData('associativeLinePoint') ||
[])[index]
// 切换结构和布局,都会更新坐标
const [startPoint, endPoint] = this.updateAllLinesPos(
node,
toNode,
associativeLinePoint
)
this.drawLine(startPoint, endPoint, node, toNode)
})
})
}
// 绘制连接线
drawLine(startPoint, endPoint, node, toNode) {
let {
associativeLineWidth,
associativeLineColor,
associativeLineActiveWidth,
associativeLineDasharray
} = this.getStyleConfig(node, toNode)
// 箭头
let markerPath = null
const marker = this.createMarker(p => {
markerPath = p
})
markerPath
.stroke({ color: associativeLineColor })
.fill({ color: associativeLineColor })
// 路径
let { path: pathStr, controlPoints } = getNodeLinePath(
startPoint,
endPoint,
node,
toNode
)
// 虚线
let path = this.associativeLineDraw.path()
path
.stroke({
width: associativeLineWidth,
color: associativeLineColor,
dasharray: associativeLineDasharray || '6,4'
})
.fill({ color: 'none' })
path.plot(pathStr)
path.marker('end', marker)
// 不可见的点击线
let clickPath = this.associativeLineDraw.path()
clickPath
.stroke({ width: associativeLineActiveWidth, color: 'transparent' })
.fill({ color: 'none' })
clickPath.plot(pathStr)
// 文字
let text = this.createText({
path,
clickPath,
markerPath,
node,
toNode,
startPoint,
endPoint,
controlPoints
})
// 点击事件
clickPath.click(e => {
e.stopPropagation()
this.setActiveLine({
path,
clickPath,
markerPath,
text,
node,
toNode,
startPoint,
endPoint,
controlPoints
})
})
// 双击进入关联线文本编辑状态
clickPath.dblclick(() => {
if (!this.activeLine) return
this.showEditTextBox(text)
})
// 渲染关联线文字
this.renderText(this.getText(node, toNode), path, text, node, toNode)
this.lineList.push([path, clickPath, text, node, toNode])
}
// 更新当前激活连线的样式,一般在自定义了节点关联线的样式后调用
// 直接调用node.setStyle方法更新样式会直接触发关联线更新但是关联线的激活状态会丢失
// 所以可以调用node.setData方法更新数据然后再调用该方法更新样式这样关联线激活状态不会丢失
updateActiveLineStyle() {
if (!this.activeLine) return
this.isNotRenderAllLines = true
const [path, clickPath, text, node, toNode, markerPath] = this.activeLine
const {
associativeLineWidth,
associativeLineColor,
associativeLineDasharray,
associativeLineActiveWidth,
associativeLineActiveColor,
associativeLineTextColor,
associativeLineTextFontFamily,
associativeLineTextFontSize
} = this.getStyleConfig(node, toNode)
path
.stroke({
width: associativeLineWidth,
color: associativeLineColor,
dasharray: associativeLineDasharray || '6,4'
})
.fill({ color: 'none' })
clickPath
.stroke({
width: associativeLineActiveWidth,
color: associativeLineActiveColor
})
.fill({ color: 'none' })
markerPath
.stroke({ color: associativeLineColor })
.fill({ color: associativeLineColor })
text.find('text').forEach(textNode => {
textNode
.fill({
color: associativeLineTextColor
})
.css({
'font-family': associativeLineTextFontFamily,
'font-size': associativeLineTextFontSize + 'px'
})
})
if (this.controlLine1) {
this.controlLine1.stroke({ color: associativeLineActiveColor })
}
if (this.controlLine2) {
this.controlLine2.stroke({ color: associativeLineActiveColor })
}
if (this.controlPoint1) {
this.controlPoint1.stroke({ color: associativeLineActiveColor })
}
if (this.controlPoint2) {
this.controlPoint2.stroke({ color: associativeLineActiveColor })
}
}
// 激活某根关联线
setActiveLine({
path,
clickPath,
markerPath,
text,
node,
toNode,
startPoint,
endPoint,
controlPoints
}) {
let { associativeLineActiveColor } = this.getStyleConfig(node, toNode)
// 如果当前存在激活节点,那么取消激活节点
this.mindMap.execCommand('CLEAR_ACTIVE_NODE')
// 否则清除当前的关联线的激活状态,如果有的话
this.clearActiveLine()
// 保存当前激活的关联线信息
this.activeLine = [path, clickPath, text, node, toNode, markerPath]
// 让不可见的点击线显示
clickPath.stroke({ color: associativeLineActiveColor })
// 如果没有输入过关联线文字,那么显示默认文字
if (!this.getText(node, toNode)) {
this.renderText(
this.mindMap.opt.defaultAssociativeLineText,
path,
text,
node,
toNode
)
}
// 渲染控制点和连线
this.renderControls(
startPoint,
endPoint,
controlPoints[0],
controlPoints[1],
node,
toNode
)
this.mindMap.emit('associative_line_click', path, clickPath, node, toNode)
this.front()
}
// 移除所有连接线
removeAllLines() {
this.lineList.forEach(line => {
line[0].remove()
line[1].remove()
line[2].remove()
})
this.lineList = []
}
// 从当前激活节点开始创建连接线
createLineFromActiveNode() {
if (this.mindMap.renderer.activeNodeList.length <= 0) return
let node = this.mindMap.renderer.activeNodeList[0]
this.createLine(node)
}
// 创建连接线
createLine(fromNode) {
let {
associativeLineWidth,
associativeLineColor,
associativeLineDasharray
} = this.getStyleConfig(fromNode)
if (this.isCreatingLine || !fromNode) return
this.front()
this.isCreatingLine = true
this.creatingStartNode = fromNode
this.creatingLine = this.associativeLineDraw.path()
this.creatingLine
.stroke({
width: associativeLineWidth,
color: associativeLineColor,
dasharray: associativeLineDasharray || '6,4'
})
.fill({ color: 'none' })
// 箭头
let markerPath = null
const marker = this.createMarker(p => {
markerPath = p
})
markerPath
.stroke({ color: associativeLineColor })
.fill({ color: associativeLineColor })
this.creatingLine.marker('end', marker)
}
// 取消创建关联线
cancelCreateLine() {
this.isCreatingLine = false
this.creatingStartNode = null
this.creatingLine.remove()
this.creatingLine = null
this.overlapNode = null
this.back()
}
// 鼠标移动事件
onMousemove(e) {
this.onControlPointMousemove(e)
this.updateCreatingLine(e)
}
// 更新创建过程中的连接线
updateCreatingLine(e) {
if (!this.isCreatingLine) return
let { x, y } = this.getTransformedEventPos(e)
let startPoint = getNodePoint(this.creatingStartNode)
let offsetX = x > startPoint.x ? -10 : 10
let pathStr = cubicBezierPath(startPoint.x, startPoint.y, x + offsetX, y)
this.creatingLine.plot(pathStr)
this.checkOverlapNode(x, y)
}
// 获取转换后的鼠标事件对象的坐标
getTransformedEventPos(e) {
let { x, y } = this.mindMap.toPos(e.clientX, e.clientY)
let { scaleX, scaleY, translateX, translateY } =
this.mindMap.draw.transform()
return {
x: (x - translateX) / scaleX,
y: (y - translateY) / scaleY
}
}
// 计算节点偏移位置
getNodePos(node) {
const { scaleX, scaleY, translateX, translateY } =
this.mindMap.draw.transform()
const { left, top, width, height } = node
let translateLeft = left * scaleX + translateX
let translateTop = top * scaleY + translateY
return {
left,
top,
translateLeft,
translateTop,
width,
height
}
}
// 检测当前移动到的目标节点
checkOverlapNode(x, y) {
this.overlapNode = null
bfsWalk(this.mindMap.renderer.root, node => {
if (node.getData('isActive')) {
this.mindMap.execCommand('SET_NODE_ACTIVE', node, false)
}
if (node.uid === this.creatingStartNode.uid || this.overlapNode) {
return
}
let { left, top, width, height } = node
let right = left + width
let bottom = top + height
if (x >= left && x <= right && y >= top && y <= bottom) {
this.overlapNode = node
}
})
if (this.overlapNode && !this.overlapNode.getData('isActive')) {
this.mindMap.execCommand('SET_NODE_ACTIVE', this.overlapNode, true)
}
}
// 完成创建连接线
completeCreateLine(node) {
if (this.creatingStartNode.uid === node.uid) return
const { beforeAssociativeLineConnection } = this.mindMap.opt
let stop = false
if (typeof beforeAssociativeLineConnection === 'function') {
stop = beforeAssociativeLineConnection(node)
}
if (stop) return
this.addLine(this.creatingStartNode, node)
if (this.overlapNode && this.overlapNode.getData('isActive')) {
this.mindMap.execCommand('SET_NODE_ACTIVE', this.overlapNode, false)
}
this.cancelCreateLine()
}
// 添加连接线
addLine(fromNode, toNode) {
if (!fromNode || !toNode) return
// 目标节点如果没有id则生成一个id
let uid = toNode.getData('uid')
if (!uid) {
uid = uuid()
this.mindMap.execCommand('SET_NODE_DATA', toNode, {
uid
})
}
// 将目标节点id保存起来
let list = fromNode.getData('associativeLineTargets') || []
// 连线节点是否存在相同的id,存在则阻止添加关联线
const sameLine = list.some(item => item === uid)
if (sameLine) {
return
}
list.push(uid)
// 保存控制点
let [startPoint, endPoint] = computeNodePoints(fromNode, toNode)
let controlPoints = computeCubicBezierPathPoints(
startPoint.x,
startPoint.y,
endPoint.x,
endPoint.y
)
// 检查是否存在固定位置的配置
const { associativeLineInitPointsPosition } = this.mindMap.opt
if (associativeLineInitPointsPosition) {
const { from, to } = associativeLineInitPointsPosition
if (from) {
startPoint.dir = from
}
if (to) {
endPoint.dir = to
}
}
let offsetList =
fromNode.getData('associativeLineTargetControlOffsets') || []
// 保存的实际是控制点和端点的差值,否则当节点位置改变了,控制点还是原来的位置,连线就不对了
offsetList[list.length - 1] = [
{
x: controlPoints[0].x - startPoint.x,
y: controlPoints[0].y - startPoint.y
},
{
x: controlPoints[1].x - endPoint.x,
y: controlPoints[1].y - endPoint.y
}
]
let associativeLinePoint = fromNode.getData('associativeLinePoint') || []
// 记录关联的起始|结束坐标
associativeLinePoint[list.length - 1] = { startPoint, endPoint }
this.mindMap.execCommand('SET_NODE_DATA', fromNode, {
associativeLineTargets: list,
associativeLineTargetControlOffsets: offsetList,
associativeLinePoint
})
}
// 删除连接线
removeLine() {
if (!this.activeLine) return
let [, , , node, toNode] = this.activeLine
this.removeControls()
let {
associativeLineTargets,
associativeLinePoint,
associativeLineTargetControlOffsets,
associativeLineText,
associativeLineStyle
} = node.getData()
associativeLinePoint = associativeLinePoint || []
let targetIndex = getAssociativeLineTargetIndex(node, toNode)
// 更新关联线文本数据
let newAssociativeLineText = {}
if (associativeLineText) {
Object.keys(associativeLineText).forEach(item => {
if (item !== toNode.getData('uid')) {
newAssociativeLineText[item] = associativeLineText[item]
}
})
}
// 更新关联线样式数据
let newAssociativeLineStyle = {}
if (associativeLineStyle) {
Object.keys(associativeLineStyle).forEach(item => {
if (item !== toNode.getData('uid')) {
newAssociativeLineStyle[item] = associativeLineStyle[item]
}
})
}
this.mindMap.execCommand('SET_NODE_DATA', node, {
// 目标
associativeLineTargets: associativeLineTargets.filter((_, index) => {
return index !== targetIndex
}),
// 连接线坐标
associativeLinePoint: associativeLinePoint.filter((_, index) => {
return index !== targetIndex
}),
// 偏移量
associativeLineTargetControlOffsets: associativeLineTargetControlOffsets
? associativeLineTargetControlOffsets.filter((_, index) => {
return index !== targetIndex
})
: [],
// 文本
associativeLineText: newAssociativeLineText,
// 样式
associativeLineStyle: newAssociativeLineStyle
})
}
// 清除激活的线
clearActiveLine() {
if (this.activeLine) {
let [, clickPath, text, node, toNode] = this.activeLine
clickPath.stroke({
color: 'transparent'
})
// 隐藏关联线文本编辑框
this.hideEditTextBox()
// 如果当前关联线没有文字,则清空文字节点
if (!this.getText(node, toNode)) {
text.clear()
}
this.activeLine = null
this.removeControls()
this.back()
this.mindMap.emit('associative_line_deactivate')
}
}
// 处理节点正在拖拽事件
onNodeDragging() {
if (this.isNodeDragging) return
this.isNodeDragging = true
this.lineList.forEach(line => {
line[0].hide()
line[1].hide()
line[2].hide()
})
this.hideControls()
}
// 处理节点拖拽完成事件
onNodeDragend() {
if (!this.isNodeDragging) return
this.lineList.forEach(line => {
line[0].show()
line[1].show()
line[2].show()
})
this.showControls()
this.isNodeDragging = false
}
// 关联线顶层显示
front() {
if (this.mindMap.opt.associativeLineIsAlwaysAboveNode) return
this.associativeLineDraw.front()
}
// 关联线回到原有层级
back() {
if (this.mindMap.opt.associativeLineIsAlwaysAboveNode) return
this.associativeLineDraw.back() // 最底层
this.associativeLineDraw.forward() // 连线层上面
}
// 插件被移除前做的事情
beforePluginRemove() {
this.mindMap.deleteEditNodeClass(ASSOCIATIVE_LINE_TEXT_EDIT_WRAP)
this.unBindEvent()
}
// 插件被卸载前做的事情
beforePluginDestroy() {
this.mindMap.deleteEditNodeClass(ASSOCIATIVE_LINE_TEXT_EDIT_WRAP)
this.unBindEvent()
}
}
AssociativeLine.instanceName = 'associativeLine'
export default AssociativeLine