Build a Vue3 Flowchart Editor from Scratch: Node Dragging, Bezier Curves, and Box Selection
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:
- Vue3 +
<script setup>: Composition API, more flexible logic organization - Vite: Development and build tool
- TypeScript: Complete type constraints, with clear interface definitions even for the canvas data structure
- Pinia: Centralized management of canvas state (nodes, connections, selection state, etc.)
- Element Plus: Node forms, popups, and other UI
- UnoCSS: Atomic CSS
- VueUse: Composition utilities like
tryOnScopeDispose - mitt: Lightweight event bus, connecting various interaction modules
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:
- Boundary Protection: If
x < 0ory < 0, the value is set to zero, preventing the node from being dragged out of the canvas's left/top boundary. - 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:
- 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.
- 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.
- Combine with
tryOnScopeDisposefor automatic listener unsubscription, preventing memory leaks at the source. - Data-driven views: All interactions boil down to adding, deleting, or modifying the
nodesandconnectionsarrays. 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.