跪拜 Guibai
← Back to the summary

Build a Vue3 Flowchart Editor from Scratch: Node Dragging, Bezier Curves, and Box Selection

image.png

Flowchart editors are a very common type of interactive component in low-code, workflow, and data orchestration scenarios. Although there are mature solutions on the market such as node-red, ngx-flowchart, X6, and LogicFlow, if you want to truly understand the implementation principles behind core interactions like "node dragging, connecting lines, and box selection," building one yourself is still the most rewarding approach.

This article uses an open-source Vue3 flowchart project I created as an example to break down its overall architecture and core interaction implementation, including node dragging, Bezier curve connections, connection editing, rectangular box selection, automatic canvas expansion and edge auto-scrolling, and data persistence.

The project is open-sourced at github.com/Fate-ui/flowChart. The source code mentioned in the article can be referenced in the repository.

1. Tech Stack and Overall Effect

The project uses a relatively modern frontend tech stack:

The overall interface is divided into three parts: a left node panel (event / intermediate / action three types of nodes), a scrollable canvas in the middle, and a data display panel on the right.

2. Architecture Design: Organizing Complex Interactions with Service Classes

The biggest difficulty with flowcharts is the sheer amount of interaction logic: dragging, connecting, box selection, scrolling... If everything is crammed into the component's setup, the code quickly spirals out of control.

This project's approach is to abstract each type of interaction into an independent Service class, placed in the Services directory:

Services/
├── DragElementNodeService.ts   // Node dragging
├── DrawLineService.ts          // Drawing and editing connections
├── RectangleSelect.ts          // Rectangular box selection
├── ScrollParent.ts             // Edge auto-scrolling
└── EditElementNodeService.ts   // Node editing

Each Service obtains the necessary Ref references and canvas data during construction, and subscribes to global mouse events through the event bus:

export class DrawLineService {
  constructor(flowSvgRef, flowDomOffset, flowData, flowRef) {
    this.flowSvgRef = flowSvgRef
    this.flowData = flowData
    // Subscribe to global mousemove / mouseup
    useOn(flowEmitter, 'mouseMove', this.mouseMove)
    useOn(flowEmitter, 'mouseUp', this.mouseUp)
  }
}

This way, the component is only responsible for "dispatching raw events," and all business logic is handled by the Services, resulting in very clear separation of responsibilities.

Event Bus + Automatic Unsubscription: useOn

It's worth noting that instead of having each Service directly call addEventListener, a useOn composable function is encapsulated. It works with mitt's on/off to automatically unbind when the component scope is destroyed, preventing memory leaks:

export function useOn(target, event, listener) {
  let cleanup = () => {}
  const stopWatch = watch(() => unref(target), (val) => {
    cleanup()
    val.on(event, listener)
    cleanup = () => val.off(event, listener)
  }, { immediate: true, flush: 'post' })

  // Automatically cancel listening after component destruction
  tryOnScopeDispose(() => { stopWatch(); cleanup() })
}

Furthermore, global mousemove and mouseup events are listened to only once in the outermost component, and then broadcast to all Services via flowEmitter.emit('mouseMove', e). The advantage of this is that during dragging, even if the mouse moves outside the node, the events are still captured, preventing the interaction from "breaking."

3. Core Data Structure

The entire flowchart consists of three parts, with very intuitive type definitions:

export interface IFlow {
  nodes: INode[]          // All nodes
  connections: IConnect[] // All connections
  canvasSize: { width: number; height: number } // Canvas dimensions
}

export interface INode<Params = any> {
  id: string    // uuid
  type: string  // Node type, e.g., eventNode1 / actionNode1
  params: Params // Form data for this node
  additional: {
    layoutX: number // X coordinate on the canvas
    layoutY: number // Y coordinate on the canvas
    showDrop?: boolean // Whether to collapse
  }
}

export interface IConnect {
  fromId: string // Source node id
  toId: string   // Target node id
  type: string   // Connection type (satisfied / unsatisfied / condition 1 2 3)
  id: string
  hidden?: boolean
}

Essentially, all interactions boil down to adding, deleting, or modifying these three arrays. Vue's reactive system then drives view updates—nodes are rendered with absolute positioning, and connections are rendered using SVG path elements.

4. Node Dragging: Offset, Multi-Select Linkage, and Canvas Expansion

Dragging seems simple, but there are several details to get right. DragElementNodeService doesn't record the mouse coordinates on mouseDown; instead, it records the offset of the mouse relative to the top-left corner of the node. This prevents the node from "jumping" to the mouse position when dragged:

onMouseDown = (data, e, dom) => {
  this.curNode = data
  const { x, y } = dom.getBoundingClientRect()
  this.moveOffset.x = e.x - x   // Record the offset
  this.moveOffset.y = e.y - y
  this.setIndex(dom)            // Bring the current node to the top layer
}

During mouseMove, the node's actual coordinates are calculated using "mouse coordinates - canvas offset - offset + scroll distance," and two things happen:

  1. Boundary Protection: If x < 0 or y < 0, the value is set to zero, preventing the node from being dragged out of the canvas's left/top boundary.
  2. Multi-Select Linkage: If the currently dragged node is part of a multi-selected group, the displacement delta is calculated, and all selected nodes move together. If any node would cross the boundary, the entire displacement is ignored.
let xDelta = x - this.curNode.additional.layoutX
let yDelta = y - this.curNode.additional.layoutY
if (selectedNodes.length > 1 && selectedNodes.includes(curNode)) {
  if (selectedNodes.some(n => n.additional.layoutX + xDelta < 0)) xDelta = 0
  if (selectedNodes.some(n => n.additional.layoutY + yDelta < 0)) yDelta = 0
  selectedNodes.forEach(n => { n.additional.layoutX += xDelta; n.additional.layoutY += yDelta })
}

Finally, when a node is dragged near the right/bottom edge of the canvas, the canvas size is automatically increased by 500px, providing an "infinite canvas" experience:

private extendCanvasSize = (pos) => {
  if (pos.x > width - elementNodeSize.width) flowData.canvasSize.width += 500
  if (pos.y > height) flowData.canvasSize.height += 500
}

5. Connections: Drawing and Snapping SVG Bezier Curves

Connections are the most exciting part of the project, handled by DrawLineService.

1. Starting Point Drop: On mousedown at a connection point, a dynamic SVG <path> element is created and appended to the canvas's <svg>, and the starting point coordinates are recorded.

2. Drawing on Move: During mousemove, the d attribute of the path is updated in real-time. The curve uses a cubic Bezier curve for a smooth, natural look:

export function getCurvePath(from, to) {
  return `M ${from.x} ${from.y} C ${from.x}, ${from.y + (to.y - from.y) / 2} ${to.x - 50}, ${to.y - (to.y - from.y) / 2} ${to.x} ${to.y}`
}

The two control points are positioned directly below the start point and to the left of the end point, respectively. This causes the connection line to extend in a "first downward, then horizontally towards the end point" manner, visually resembling professional flowchart tools.

3. Automatic Snapping: When the mouse enters a valid target connection point, the connection's end point is directly snapped to that node's entry coordinates. Releasing the mouse button successfully creates the connection. A set of connection rule validations is also built-in:

mouseEnterConnector = (type, node, e) => {
  // 1. Left connection point cannot be a starting point
  // 2. Target type must be different from source type
  // 3. Source and target cannot be the same node
  if (!this.isDrawing || this.startPointType == 'left'
      || type === this.startPointType || this.startNode.id === node.id) return
  // 4. At most one connection between two nodes
  const isConnected = this.flowData.connections.some(item =>
    [item.fromId, item.toId].every(el => [this.startNode.id, node.id].includes(el)))
  if (isConnected) return
  this.endNode = node
  this.setPath(/* Snap to target entry coordinates */)
}

4. Release Settlement: On mouseup, the temporary path is removed. If no target was matched, the operation is abandoned. If the starting point was a "condition node," an Element Plus dialog pops up for the user to select the connection type (Condition 1 / 2 / 3). Otherwise, a connection is generated directly with a default type ("satisfied" / "unsatisfied") and pushed into connections.

Connection editing is also clever: pressing down on a node's left entry point finds all connections ending at that node, temporarily hides the original connection, and clones a label that follows the mouse. This effectively "unplugs the end of the line and reconnects it." On release, the old connection is deleted, and a new one is generated.

6. Rectangular Box Selection

RectangleSelect handles box selection. mousedown records the starting point, mousemove continuously updates the selection box's left/top/width/height (using Math.min/Math.max to handle reverse dragging), and on mouseup, collision detection is performed to collect all nodes completely within the selection box:

if (layoutX > x3 && layoutX + nodeWidth < x4 &&
    layoutY > y3 && layoutY + nodeHeight < y4) {
  flowStore.selectedElementNodes.push(node)
}

After nodes are selected, connections whose starting points are within the selected nodes are also selected, allowing for bulk operations like deletion and movement.

7. Edge Auto-Scrolling

When dragging or box selection reaches the edge of the viewport, the canvas should scroll automatically—this is ScrollParent's responsibility. It also subscribes to the global mousemove event, checks if the mouse has entered a predefined edge zone, and if so, scrolls the parent container by a fixed delta:

mouseMove = (e) => {
  if (!flowStore.maybeNeedScrollParent) return
  const scrollDom = this.flowRef.value.parentElement
  if (e.x > this.edge.right) scrollDom.scrollLeft += this.delta
  else if (e.x < this.edge.left) scrollDom.scrollLeft -= this.delta
  if (e.y > this.edge.bottom) scrollDom.scrollTop += this.delta
  else if (e.y < this.edge.top) scrollDom.scrollTop -= this.delta
}

A global state maybeNeedScrollParent acts as a switch, only allowing scrolling when "dragging / connecting / box selecting" is in progress, preventing accidental triggers.

8. Data Persistence

Finally, persistence. The project uses a single watch to monitor the entire canvas data. Any change is synchronously written to localStorage, so refreshing the page automatically restores the state without losing work progress:

const oldFlowData = localStorage.getItem('flowData')
flowStore.flowData = oldFlowData ? JSON.parse(oldFlowData) : defaultFlowData

watch(flowStore.flowData, (value) => {
  localStorage.setItem('flowData', JSON.stringify(toRaw(value)))
})

Note that toRaw is used before writing, converting Vue's reactive Proxy back to a plain object for safer serialization.

9. Summary

After completing this project, I have some design thoughts at the engineering level, which I also share for those who want to build their own flowchart:

  1. Use Service classes to decompose complex interactions: Dragging, connecting, box selection, and scrolling each have their own responsibilities. The component only dispatches events, improving readability and maintainability.
  2. Use an event bus for unified mouse event dispatch: Listen globally only once, then broadcast to each module, solving the problem of event loss during dragging.
  3. Combine with tryOnScopeDispose for automatic listener unsubscription, preventing memory leaks at the source.
  4. Data-driven views: All interactions boil down to adding, deleting, or modifying the nodes and connections arrays. SVG Bezier curves are responsible for rendering the abstract connection relationships into smooth curves.

If you also want to deeply understand the implementation of a flowchart editor, I highly recommend following this approach and building one yourself. I have open-sourced this project on GitHub: Fate-ui/flowChart. If you're interested, feel free to give it a star, and I welcome discussions in the issue section.

This article has analyzed the core implementation of a Vue3 flowchart editor from a source code perspective. If there are any omissions, feel free to point them out in the comments.