557 lines
16 KiB
TypeScript
557 lines
16 KiB
TypeScript
import { generateUUID, getArrowPoints, getObjById, getOffsetLT, setAttributes } from './utils/index'
|
|
import LinkDragMoveHelper from './utils/LinkDragMoveHelper'
|
|
import { createSvgGroup, createSvgText, editSvgText, svgNS } from './utils/svg'
|
|
import type { CustomSvg, Topic } from './types/dom'
|
|
import type { MindElixirInstance, Uid } from './index'
|
|
|
|
const highlightColor = '#4dc4ff'
|
|
|
|
export interface Arrow {
|
|
id: string
|
|
/**
|
|
* label of arrow
|
|
*/
|
|
label: string
|
|
/**
|
|
* id of start node
|
|
*/
|
|
from: Uid
|
|
/**
|
|
* id of end node
|
|
*/
|
|
to: Uid
|
|
/**
|
|
* offset of control point from start point
|
|
*/
|
|
delta1: {
|
|
x: number
|
|
y: number
|
|
}
|
|
/**
|
|
* offset of control point from end point
|
|
*/
|
|
delta2: {
|
|
x: number
|
|
y: number
|
|
}
|
|
/**
|
|
* whether the arrow is bidirectional
|
|
*/
|
|
bidirectional?: boolean
|
|
/**
|
|
* style properties for the arrow
|
|
*/
|
|
style?: {
|
|
/**
|
|
* stroke color of the arrow
|
|
*/
|
|
stroke?: string
|
|
/**
|
|
* stroke width of the arrow
|
|
*/
|
|
strokeWidth?: string | number
|
|
/**
|
|
* stroke dash array for dashed lines
|
|
*/
|
|
strokeDasharray?: string
|
|
/**
|
|
* stroke line cap style
|
|
*/
|
|
strokeLinecap?: 'butt' | 'round' | 'square'
|
|
/**
|
|
* opacity of the arrow
|
|
*/
|
|
opacity?: string | number
|
|
/**
|
|
* color of the arrow label
|
|
*/
|
|
labelColor?: string
|
|
}
|
|
}
|
|
export type DivData = {
|
|
cx: number // center x
|
|
cy: number // center y
|
|
w: number // div width
|
|
h: number // div height
|
|
ctrlX: number // control point x
|
|
ctrlY: number // control point y
|
|
}
|
|
export type ArrowOptions = {
|
|
bidirectional?: boolean
|
|
style?: {
|
|
stroke?: string
|
|
strokeWidth?: string | number
|
|
strokeDasharray?: string
|
|
strokeLinecap?: 'butt' | 'round' | 'square'
|
|
opacity?: string | number
|
|
labelColor?: string
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate bezier curve midpoint position
|
|
*/
|
|
function calcBezierMidPoint(p1x: number, p1y: number, p2x: number, p2y: number, p3x: number, p3y: number, p4x: number, p4y: number) {
|
|
return {
|
|
x: p1x / 8 + (p2x * 3) / 8 + (p3x * 3) / 8 + p4x / 8,
|
|
y: p1y / 8 + (p2y * 3) / 8 + (p3y * 3) / 8 + p4y / 8,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update arrow label position
|
|
*/
|
|
function updateArrowLabel(label: SVGTextElement, x: number, y: number) {
|
|
setAttributes(label, {
|
|
x: x + '',
|
|
y: y + '',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Update control line position
|
|
*/
|
|
function updateControlLine(line: SVGElement, x1: number, y1: number, x2: number, y2: number) {
|
|
setAttributes(line, {
|
|
x1: x1 + '',
|
|
y1: y1 + '',
|
|
x2: x2 + '',
|
|
y2: y2 + '',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Update arrow path and related elements
|
|
*/
|
|
function updateArrowPath(
|
|
arrow: CustomSvg,
|
|
p1x: number,
|
|
p1y: number,
|
|
p2x: number,
|
|
p2y: number,
|
|
p3x: number,
|
|
p3y: number,
|
|
p4x: number,
|
|
p4y: number,
|
|
linkItem: Arrow
|
|
) {
|
|
const mainPath = `M ${p1x} ${p1y} C ${p2x} ${p2y} ${p3x} ${p3y} ${p4x} ${p4y}`
|
|
|
|
// Update main path
|
|
arrow.line.setAttribute('d', mainPath)
|
|
|
|
// Apply styles to the main line if they exist
|
|
if (linkItem.style) {
|
|
const style = linkItem.style
|
|
if (style.stroke) arrow.line.setAttribute('stroke', style.stroke)
|
|
if (style.strokeWidth) arrow.line.setAttribute('stroke-width', String(style.strokeWidth))
|
|
if (style.strokeDasharray) arrow.line.setAttribute('stroke-dasharray', style.strokeDasharray)
|
|
if (style.strokeLinecap) arrow.line.setAttribute('stroke-linecap', style.strokeLinecap)
|
|
if (style.opacity !== undefined) arrow.line.setAttribute('opacity', String(style.opacity))
|
|
}
|
|
|
|
// Update hotzone for main path (find the first hotzone path which corresponds to the main line)
|
|
const hotzones = arrow.querySelectorAll('path[stroke="transparent"]')
|
|
if (hotzones.length > 0) {
|
|
hotzones[0].setAttribute('d', mainPath)
|
|
}
|
|
|
|
// Update arrow head
|
|
const arrowPoint = getArrowPoints(p3x, p3y, p4x, p4y)
|
|
if (arrowPoint) {
|
|
const arrowPath1 = `M ${arrowPoint.x1} ${arrowPoint.y1} L ${p4x} ${p4y} L ${arrowPoint.x2} ${arrowPoint.y2}`
|
|
arrow.arrow1.setAttribute('d', arrowPath1)
|
|
|
|
// Update hotzone for arrow1
|
|
if (hotzones.length > 1) {
|
|
hotzones[1].setAttribute('d', arrowPath1)
|
|
}
|
|
|
|
// Apply styles to arrow head
|
|
if (linkItem.style) {
|
|
const style = linkItem.style
|
|
if (style.stroke) arrow.arrow1.setAttribute('stroke', style.stroke)
|
|
if (style.strokeWidth) arrow.arrow1.setAttribute('stroke-width', String(style.strokeWidth))
|
|
if (style.strokeLinecap) arrow.arrow1.setAttribute('stroke-linecap', style.strokeLinecap)
|
|
if (style.opacity !== undefined) arrow.arrow1.setAttribute('opacity', String(style.opacity))
|
|
}
|
|
}
|
|
|
|
// Update start arrow if bidirectional
|
|
if (linkItem.bidirectional) {
|
|
const arrowPointStart = getArrowPoints(p2x, p2y, p1x, p1y)
|
|
if (arrowPointStart) {
|
|
const arrowPath2 = `M ${arrowPointStart.x1} ${arrowPointStart.y1} L ${p1x} ${p1y} L ${arrowPointStart.x2} ${arrowPointStart.y2}`
|
|
arrow.arrow2.setAttribute('d', arrowPath2)
|
|
|
|
// Update hotzone for arrow2
|
|
if (hotzones.length > 2) {
|
|
hotzones[2].setAttribute('d', arrowPath2)
|
|
}
|
|
|
|
// Apply styles to start arrow head
|
|
if (linkItem.style) {
|
|
const style = linkItem.style
|
|
if (style.stroke) arrow.arrow2.setAttribute('stroke', style.stroke)
|
|
if (style.strokeWidth) arrow.arrow2.setAttribute('stroke-width', String(style.strokeWidth))
|
|
if (style.strokeLinecap) arrow.arrow2.setAttribute('stroke-linecap', style.strokeLinecap)
|
|
if (style.opacity !== undefined) arrow.arrow2.setAttribute('opacity', String(style.opacity))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update label position and color
|
|
const { x: halfx, y: halfy } = calcBezierMidPoint(p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y)
|
|
updateArrowLabel(arrow.label, halfx, halfy)
|
|
|
|
// Apply label color if specified
|
|
if (linkItem.style?.labelColor) {
|
|
arrow.label.setAttribute('fill', linkItem.style.labelColor)
|
|
}
|
|
|
|
// Update highlight layer
|
|
updateArrowHighlight(arrow)
|
|
}
|
|
|
|
/**
|
|
* calc control point, center point and div size
|
|
*/
|
|
function calcCtrlP(mei: MindElixirInstance, tpc: Topic, delta: { x: number; y: number }) {
|
|
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
|
|
const w = tpc.offsetWidth
|
|
const h = tpc.offsetHeight
|
|
const cx = x + w / 2
|
|
const cy = y + h / 2
|
|
const ctrlX = cx + delta.x
|
|
const ctrlY = cy + delta.y
|
|
return {
|
|
w,
|
|
h,
|
|
cx,
|
|
cy,
|
|
ctrlX,
|
|
ctrlY,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* calc start and end point using control point and div status
|
|
*/
|
|
function calcP(data: DivData) {
|
|
let x, y
|
|
const k = (data.cy - data.ctrlY) / (data.ctrlX - data.cx)
|
|
if (k > data.h / data.w || k < -data.h / data.w) {
|
|
if (data.cy - data.ctrlY < 0) {
|
|
x = data.cx - data.h / 2 / k
|
|
y = data.cy + data.h / 2
|
|
} else {
|
|
x = data.cx + data.h / 2 / k
|
|
y = data.cy - data.h / 2
|
|
}
|
|
} else {
|
|
if (data.cx - data.ctrlX < 0) {
|
|
x = data.cx + data.w / 2
|
|
y = data.cy - (data.w * k) / 2
|
|
} else {
|
|
x = data.cx - data.w / 2
|
|
y = data.cy + (data.w * k) / 2
|
|
}
|
|
}
|
|
return {
|
|
x,
|
|
y,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FYI
|
|
* p1: start point
|
|
* p2: control point of start point
|
|
* p3: control point of end point
|
|
* p4: end point
|
|
*/
|
|
const drawArrow = function (mei: MindElixirInstance, from: Topic, to: Topic, obj: Arrow, isInitPaint?: boolean) {
|
|
if (!from || !to) {
|
|
return // not expand
|
|
}
|
|
|
|
const fromData = calcCtrlP(mei, from, obj.delta1)
|
|
const toData = calcCtrlP(mei, to, obj.delta2)
|
|
|
|
const { x: p1x, y: p1y } = calcP(fromData)
|
|
const { ctrlX: p2x, ctrlY: p2y } = fromData
|
|
const { ctrlX: p3x, ctrlY: p3y } = toData
|
|
const { x: p4x, y: p4y } = calcP(toData)
|
|
|
|
const arrowT = getArrowPoints(p3x, p3y, p4x, p4y)
|
|
if (!arrowT) return
|
|
|
|
const toArrow = `M ${arrowT.x1} ${arrowT.y1} L ${p4x} ${p4y} L ${arrowT.x2} ${arrowT.y2}`
|
|
let fromArrow = ''
|
|
if (obj.bidirectional) {
|
|
const arrowF = getArrowPoints(p2x, p2y, p1x, p1y)
|
|
if (!arrowF) return
|
|
fromArrow = `M ${arrowF.x1} ${arrowF.y1} L ${p1x} ${p1y} L ${arrowF.x2} ${arrowF.y2}`
|
|
}
|
|
const newSvgGroup = createSvgGroup(`M ${p1x} ${p1y} C ${p2x} ${p2y} ${p3x} ${p3y} ${p4x} ${p4y}`, toArrow, fromArrow, obj.style)
|
|
|
|
// Use extracted common function to calculate midpoint
|
|
const { x: halfx, y: halfy } = calcBezierMidPoint(p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y)
|
|
const labelColor = obj.style?.labelColor
|
|
const label = createSvgText(obj.label, halfx, halfy, {
|
|
anchor: 'middle',
|
|
color: labelColor,
|
|
dataType: 'custom-link',
|
|
})
|
|
newSvgGroup.appendChild(label)
|
|
newSvgGroup.label = label
|
|
|
|
newSvgGroup.arrowObj = obj
|
|
newSvgGroup.dataset.linkid = obj.id
|
|
mei.linkSvgGroup.appendChild(newSvgGroup)
|
|
if (!isInitPaint) {
|
|
mei.arrows.push(obj)
|
|
mei.currentArrow = newSvgGroup
|
|
showLinkController(mei, obj, fromData, toData)
|
|
}
|
|
}
|
|
|
|
export const createArrow = function (this: MindElixirInstance, from: Topic, to: Topic, options: ArrowOptions = {}) {
|
|
const arrowObj = {
|
|
id: generateUUID(),
|
|
label: 'Custom Link',
|
|
from: from.nodeObj.id,
|
|
to: to.nodeObj.id,
|
|
delta1: {
|
|
x: from.offsetWidth / 2 + 100,
|
|
y: 0,
|
|
},
|
|
delta2: {
|
|
x: to.offsetWidth / 2 + 100,
|
|
y: 0,
|
|
},
|
|
...options,
|
|
}
|
|
drawArrow(this, from, to, arrowObj)
|
|
|
|
this.bus.fire('operation', {
|
|
name: 'createArrow',
|
|
obj: arrowObj,
|
|
})
|
|
}
|
|
|
|
export const createArrowFrom = function (this: MindElixirInstance, arrow: Omit<Arrow, 'id'>) {
|
|
hideLinkController(this)
|
|
const arrowObj = { ...arrow, id: generateUUID() }
|
|
drawArrow(this, this.findEle(arrowObj.from), this.findEle(arrowObj.to), arrowObj)
|
|
|
|
this.bus.fire('operation', {
|
|
name: 'createArrow',
|
|
obj: arrowObj,
|
|
})
|
|
}
|
|
|
|
export const removeArrow = function (this: MindElixirInstance, linkSvg?: CustomSvg) {
|
|
let link
|
|
if (linkSvg) {
|
|
link = linkSvg
|
|
} else {
|
|
link = this.currentArrow
|
|
}
|
|
if (!link) return
|
|
hideLinkController(this)
|
|
const id = link.arrowObj!.id
|
|
this.arrows = this.arrows.filter(arrow => arrow.id !== id)
|
|
link.remove()
|
|
this.bus.fire('operation', {
|
|
name: 'removeArrow',
|
|
obj: {
|
|
id,
|
|
},
|
|
})
|
|
}
|
|
|
|
export const selectArrow = function (this: MindElixirInstance, link: CustomSvg) {
|
|
this.currentArrow = link
|
|
const obj = link.arrowObj
|
|
|
|
const from = this.findEle(obj.from)
|
|
const to = this.findEle(obj.to)
|
|
|
|
const fromData = calcCtrlP(this, from, obj.delta1)
|
|
const toData = calcCtrlP(this, to, obj.delta2)
|
|
|
|
showLinkController(this, obj, fromData, toData)
|
|
}
|
|
|
|
export const unselectArrow = function (this: MindElixirInstance) {
|
|
hideLinkController(this)
|
|
this.currentArrow = null
|
|
}
|
|
|
|
/**
|
|
* Create a highlight path element with common attributes
|
|
*/
|
|
const createHighlightPath = function (d: string, highlightColor: string): SVGPathElement {
|
|
const path = document.createElementNS(svgNS, 'path')
|
|
setAttributes(path, {
|
|
d,
|
|
stroke: highlightColor,
|
|
fill: 'none',
|
|
'stroke-width': '6',
|
|
'stroke-linecap': 'round',
|
|
'stroke-linejoin': 'round',
|
|
})
|
|
return path
|
|
}
|
|
|
|
const addArrowHighlight = function (arrow: CustomSvg, highlightColor: string) {
|
|
const highlightGroup = document.createElementNS(svgNS, 'g')
|
|
highlightGroup.setAttribute('class', 'arrow-highlight')
|
|
highlightGroup.setAttribute('opacity', '0.45')
|
|
|
|
const highlightLine = createHighlightPath(arrow.line.getAttribute('d')!, highlightColor)
|
|
highlightGroup.appendChild(highlightLine)
|
|
|
|
const highlightArrow1 = createHighlightPath(arrow.arrow1.getAttribute('d')!, highlightColor)
|
|
highlightGroup.appendChild(highlightArrow1)
|
|
|
|
if (arrow.arrow2.getAttribute('d')) {
|
|
const highlightArrow2 = createHighlightPath(arrow.arrow2.getAttribute('d')!, highlightColor)
|
|
highlightGroup.appendChild(highlightArrow2)
|
|
}
|
|
|
|
arrow.insertBefore(highlightGroup, arrow.firstChild)
|
|
}
|
|
|
|
const removeArrowHighlight = function (arrow: CustomSvg) {
|
|
const highlightGroup = arrow.querySelector('.arrow-highlight')
|
|
if (highlightGroup) {
|
|
highlightGroup.remove()
|
|
}
|
|
}
|
|
|
|
const updateArrowHighlight = function (arrow: CustomSvg) {
|
|
const highlightGroup = arrow.querySelector('.arrow-highlight')
|
|
if (!highlightGroup) return
|
|
|
|
const highlightPaths = highlightGroup.querySelectorAll('path')
|
|
if (highlightPaths.length >= 1) {
|
|
highlightPaths[0].setAttribute('d', arrow.line.getAttribute('d')!)
|
|
}
|
|
if (highlightPaths.length >= 2) {
|
|
highlightPaths[1].setAttribute('d', arrow.arrow1.getAttribute('d')!)
|
|
}
|
|
if (highlightPaths.length >= 3 && arrow.arrow2.getAttribute('d')) {
|
|
highlightPaths[2].setAttribute('d', arrow.arrow2.getAttribute('d')!)
|
|
}
|
|
}
|
|
|
|
const hideLinkController = function (mei: MindElixirInstance) {
|
|
mei.helper1?.destroy!()
|
|
mei.helper2?.destroy!()
|
|
mei.linkController.style.display = 'none'
|
|
mei.P2.style.display = 'none'
|
|
mei.P3.style.display = 'none'
|
|
if (mei.currentArrow) {
|
|
removeArrowHighlight(mei.currentArrow)
|
|
}
|
|
}
|
|
|
|
const showLinkController = function (mei: MindElixirInstance, linkItem: Arrow, fromData: DivData, toData: DivData) {
|
|
const { linkController, P2, P3, line1, line2, nodes, map, currentArrow, bus } = mei
|
|
if (!currentArrow) return
|
|
linkController.style.display = 'initial'
|
|
P2.style.display = 'initial'
|
|
P3.style.display = 'initial'
|
|
nodes.appendChild(linkController)
|
|
nodes.appendChild(P2)
|
|
nodes.appendChild(P3)
|
|
|
|
addArrowHighlight(currentArrow, highlightColor)
|
|
|
|
// init points
|
|
let { x: p1x, y: p1y } = calcP(fromData)
|
|
let { ctrlX: p2x, ctrlY: p2y } = fromData
|
|
let { ctrlX: p3x, ctrlY: p3y } = toData
|
|
let { x: p4x, y: p4y } = calcP(toData)
|
|
|
|
P2.style.cssText = `top:${p2y}px;left:${p2x}px;`
|
|
P3.style.cssText = `top:${p3y}px;left:${p3x}px;`
|
|
updateControlLine(line1, p1x, p1y, p2x, p2y)
|
|
updateControlLine(line2, p3x, p3y, p4x, p4y)
|
|
|
|
mei.helper1 = LinkDragMoveHelper.create(P2)
|
|
mei.helper2 = LinkDragMoveHelper.create(P3)
|
|
|
|
mei.helper1.init(map, (deltaX, deltaY) => {
|
|
// recalc key points
|
|
p2x = p2x + deltaX / mei.scaleVal // scale should keep the latest value
|
|
p2y = p2y + deltaY / mei.scaleVal
|
|
const p1 = calcP({ ...fromData, ctrlX: p2x, ctrlY: p2y })
|
|
p1x = p1.x
|
|
p1y = p1.y
|
|
|
|
// update dom position
|
|
P2.style.top = p2y + 'px'
|
|
P2.style.left = p2x + 'px'
|
|
|
|
// Use extracted common function to update arrow
|
|
updateArrowPath(currentArrow, p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y, linkItem)
|
|
updateControlLine(line1, p1x, p1y, p2x, p2y)
|
|
|
|
linkItem.delta1.x = p2x - fromData.cx
|
|
linkItem.delta1.y = p2y - fromData.cy
|
|
|
|
bus.fire('updateArrowDelta', linkItem)
|
|
})
|
|
|
|
mei.helper2.init(map, (deltaX, deltaY) => {
|
|
p3x = p3x + deltaX / mei.scaleVal
|
|
p3y = p3y + deltaY / mei.scaleVal
|
|
const p4 = calcP({ ...toData, ctrlX: p3x, ctrlY: p3y })
|
|
p4x = p4.x
|
|
p4y = p4.y
|
|
|
|
P3.style.top = p3y + 'px'
|
|
P3.style.left = p3x + 'px'
|
|
|
|
// Use extracted common function to update arrow
|
|
updateArrowPath(currentArrow, p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y, linkItem)
|
|
updateControlLine(line2, p3x, p3y, p4x, p4y)
|
|
|
|
linkItem.delta2.x = p3x - toData.cx
|
|
linkItem.delta2.y = p3y - toData.cy
|
|
|
|
bus.fire('updateArrowDelta', linkItem)
|
|
})
|
|
}
|
|
|
|
export function renderArrow(this: MindElixirInstance) {
|
|
this.linkSvgGroup.innerHTML = ''
|
|
for (let i = 0; i < this.arrows.length; i++) {
|
|
const link = this.arrows[i]
|
|
try {
|
|
drawArrow(this, this.findEle(link.from), this.findEle(link.to), link, true)
|
|
} catch (e) {
|
|
console.warn('Node may not be expanded')
|
|
}
|
|
}
|
|
this.nodes.appendChild(this.linkSvgGroup)
|
|
}
|
|
|
|
export function editArrowLabel(this: MindElixirInstance, el: CustomSvg) {
|
|
hideLinkController(this)
|
|
console.time('editSummary')
|
|
if (!el) return
|
|
const textEl = el.label
|
|
editSvgText(this, textEl, el.arrowObj)
|
|
console.timeEnd('editSummary')
|
|
}
|
|
|
|
export function tidyArrow(this: MindElixirInstance) {
|
|
this.arrows = this.arrows.filter(arrow => {
|
|
return getObjById(arrow.from, this.nodeData) && getObjById(arrow.to, this.nodeData)
|
|
})
|
|
}
|