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) { 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) }) }