Flow Editor
A node editor: draggable steps, lines that route around other nodes and hop over each other, a click-to-connect interaction, and Delete on a selected line. The app owns the list of connections; the library draws it and reports what the user did.
What it uses:
- Drag & drop with a handle, a grid and bounds.
- Routing and crossings —
avoidObstaclesandjumps. - Animated flow — dots moving along each line.
- Selection & accessibility — selecting a line and the
connection:delete-requestevent.
Plain JavaScript
html
<div id="diagram"></div>
<p>
Press Connect on a node, then click another node. Select a line and press Delete to remove it.
</p>css
body {
margin: 0;
font:
14px system-ui,
sans-serif;
background: #f8fafc;
}
#diagram {
position: relative;
height: 420px;
margin: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.node {
position: absolute;
width: 150px;
background: #fff;
border: 1px solid #94a3b8;
border-radius: 8px;
user-select: none;
}
.node.source {
border-color: #6366f1;
box-shadow: 0 0 0 3px #c7d2fe;
}
.node-body {
padding: 10px 14px;
cursor: grab;
}
.node-body small {
display: block;
color: #64748b;
}
.node-connect {
display: block;
width: 100%;
padding: 6px;
border: 0;
border-top: 1px solid #e2e8f0;
background: none;
color: #6366f1;
cursor: pointer;
}
#hint {
margin: 0 16px;
color: #64748b;
}ts
import { createVisualLinker, type ConnectionDescriptor } from '@macrulez/visual-linker-core'
const nodes = [
{ id: 'trigger', title: 'Trigger', note: 'on webhook', x: 20, y: 150 },
{ id: 'fetch', title: 'Fetch', note: 'GET /orders', x: 260, y: 40 },
{ id: 'filter', title: 'Filter', note: 'status = paid', x: 260, y: 260 },
{ id: 'save', title: 'Save', note: 'to database', x: 520, y: 150 },
]
let connections: ConnectionDescriptor[] = [
{ id: 'trigger>fetch', from: { blockId: 'trigger' }, to: { blockId: 'fetch' } },
{ id: 'trigger>filter', from: { blockId: 'trigger' }, to: { blockId: 'filter' } },
{ id: 'fetch>save', from: { blockId: 'fetch' }, to: { blockId: 'save' } },
]
const diagram = document.querySelector<HTMLElement>('#diagram')!
diagram.innerHTML = nodes
.map(
(n) => `
<div class="node" id="n-${n.id}" data-node="${n.id}" style="left:${n.x}px;top:${n.y}px">
<div class="node-body"><strong>${n.title}</strong><small>${n.note}</small></div>
<button class="node-connect">Connect</button>
</div>`,
)
.join('')
const linker = createVisualLinker(diagram, {
lines: {
curve: 'smoothstep',
routing: { avoidObstacles: true },
jumps: true,
animated: { shape: 'dots', speed: 40 },
selected: { color: '#6366f1' },
},
markers: { end: { shape: 'arrow' } },
blocks: { draggable: true, drag: { grid: 20, bounds: 'container' } },
interaction: { selectable: true },
})
linker.setBlocks(
nodes.map((n) => ({
id: n.id,
el: diagram.querySelector<HTMLElement>(`#n-${n.id}`)!,
dragHandle: '.node-body',
})),
)
linker.setConnections(connections)
linker.on('connection:delete-request', ({ connections: doomed }) => {
const ids = new Set(doomed.map((c) => c.id))
connections = connections.filter((c) => !ids.has(c.id))
linker.setConnections(connections)
})
let source: string | null = null
const setSource = (id: string | null) => {
source = id
diagram
.querySelectorAll('.node')
.forEach((el) => el.classList.toggle('source', (el as HTMLElement).dataset.node === id))
}
diagram.addEventListener('click', (event) => {
const target = event.target as HTMLElement
const node = target.closest<HTMLElement>('.node')?.dataset.node
if (!node) return
if (target.closest('.node-connect')) return setSource(source === node ? null : node)
if (!source || source === node) return
const id = `${source}>${node}`
if (!connections.some((c) => c.id === id)) {
connections = [...connections, { id, from: { blockId: source }, to: { blockId: node } }]
linker.setConnections(connections)
}
setSource(null)
})
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') setSource(null)
})Vue
The list of connections is a ref, so adding to it or filtering it redraws the diagram. The styles are the same as above, in a <style scoped> block.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VisualLinker, vVlBlock } from '@macrulez/visual-linker-vue'
import type { ConnectionDescriptor, VisualLinkerConfig } from '@macrulez/visual-linker-vue'
const nodes = [
{ id: 'trigger', title: 'Trigger', note: 'on webhook', x: 20, y: 150 },
{ id: 'fetch', title: 'Fetch', note: 'GET /orders', x: 260, y: 40 },
{ id: 'filter', title: 'Filter', note: 'status = paid', x: 260, y: 260 },
{ id: 'save', title: 'Save', note: 'to database', x: 520, y: 150 },
]
const connections = ref<ConnectionDescriptor[]>([
{ id: 'trigger>fetch', from: { blockId: 'trigger' }, to: { blockId: 'fetch' } },
{ id: 'trigger>filter', from: { blockId: 'trigger' }, to: { blockId: 'filter' } },
{ id: 'fetch>save', from: { blockId: 'fetch' }, to: { blockId: 'save' } },
])
const config: VisualLinkerConfig = {
lines: {
curve: 'smoothstep',
routing: { avoidObstacles: true },
jumps: true,
animated: { shape: 'dots', speed: 40 },
selected: { color: '#6366f1' },
},
markers: { end: { shape: 'arrow' } },
blocks: { draggable: true, drag: { grid: 20, bounds: 'container' } },
interaction: { selectable: true },
}
const source = ref<string | null>(null)
function pick(id: string) {
if (!source.value || source.value === id) return
const connectionId = `${source.value}>${id}`
if (!connections.value.some((c) => c.id === connectionId)) {
connections.value.push({
id: connectionId,
from: { blockId: source.value },
to: { blockId: id },
})
}
source.value = null
}
function remove(doomed: ConnectionDescriptor[]) {
const ids = new Set(doomed.map((c) => c.id))
connections.value = connections.value.filter((c) => !ids.has(c.id))
}
</script>
<template>
<VisualLinker
class="diagram"
:connections="connections"
:config="config"
@connection-delete-request="remove"
@keydown.esc="source = null"
>
<div
v-for="node in nodes"
:key="node.id"
v-vl-block="{ id: node.id, dragHandle: '.node-body' }"
class="node"
:class="{ source: source === node.id }"
:style="{ left: `${node.x}px`, top: `${node.y}px` }"
@click="pick(node.id)"
>
<div class="node-body">
<strong>{{ node.title }}</strong>
<small>{{ node.note }}</small>
</div>
<button class="node-connect" @click.stop="source = source === node.id ? null : node.id">
Connect
</button>
</div>
</VisualLinker>
<p class="hint">
Press Connect on a node, then click another node. Select a line and press Delete to remove it.
</p>
</template>Nuxt
Move the options to nuxt.config.ts, where they become the shared configuration of every diagram in the app:
ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@macrulez/visual-linker-nuxt'],
visualLinker: {
lines: {
curve: 'smoothstep',
routing: { avoidObstacles: true },
jumps: true,
animated: { shape: 'dots', speed: 40 },
selected: { color: '#6366f1' },
},
markers: { end: { shape: 'arrow' } },
blocks: { draggable: true, drag: { grid: 20, bounds: 'container' } },
interaction: { selectable: true },
},
})The component no longer needs a config, nor any import — <VisualLinker>, v-vl-block and ref are all available without one:
vue
<script setup lang="ts">
import type { ConnectionDescriptor } from '@macrulez/visual-linker-vue'
const nodes = [
{ id: 'trigger', title: 'Trigger', note: 'on webhook', x: 20, y: 150 },
{ id: 'fetch', title: 'Fetch', note: 'GET /orders', x: 260, y: 40 },
{ id: 'filter', title: 'Filter', note: 'status = paid', x: 260, y: 260 },
{ id: 'save', title: 'Save', note: 'to database', x: 520, y: 150 },
]
const connections = ref<ConnectionDescriptor[]>([
{ id: 'trigger>fetch', from: { blockId: 'trigger' }, to: { blockId: 'fetch' } },
{ id: 'trigger>filter', from: { blockId: 'trigger' }, to: { blockId: 'filter' } },
{ id: 'fetch>save', from: { blockId: 'fetch' }, to: { blockId: 'save' } },
])
const source = ref<string | null>(null)
function pick(id: string) {
if (!source.value || source.value === id) return
const connectionId = `${source.value}>${id}`
if (!connections.value.some((c) => c.id === connectionId)) {
connections.value.push({
id: connectionId,
from: { blockId: source.value },
to: { blockId: id },
})
}
source.value = null
}
function remove(doomed: ConnectionDescriptor[]) {
const ids = new Set(doomed.map((c) => c.id))
connections.value = connections.value.filter((c) => !ids.has(c.id))
}
</script>
<template>
<VisualLinker
class="diagram"
:connections="connections"
@connection-delete-request="remove"
@keydown.esc="source = null"
>
<div
v-for="node in nodes"
:key="node.id"
v-vl-block="{ id: node.id, dragHandle: '.node-body' }"
class="node"
:class="{ source: source === node.id }"
:style="{ left: `${node.x}px`, top: `${node.y}px` }"
@click="pick(node.id)"
>
<div class="node-body">
<strong>{{ node.title }}</strong>
<small>{{ node.note }}</small>
</div>
<button class="node-connect" @click.stop="source = source === node.id ? null : node.id">
Connect
</button>
</div>
</VisualLinker>
<p class="hint">
Press Connect on a node, then click another node. Select a line and press Delete to remove it.
</p>
</template>How it works
- The app owns the data. The library never removes a connection by itself.
connection:delete-request(@connection-delete-requestin Vue) reports which lines the user asked to delete with the Delete or Backspace key; the handler filters them out of its own list and passes the list back. - Selecting.
interaction: { selectable: true }makes lines clickable and keyboard-focusable, andselected: { color: '#6366f1' }sets how a selected line looks. - Routing.
routing: { avoidObstacles: true }bends asmoothstepline around the other nodes, andjumps: truemakes it hop over the lines it crosses. - Animation.
animatedruns a pattern along every line, from the start of a line to its end. - Sides are automatic. The connections name only blocks, with no ports, so each end leaves from whichever side of the node faces the other.
- Connecting. Pressing Connect on a node remembers it as the source, and the next click on another node adds the connection. Clicks reach the node because only
.node-bodyis the drag handle. - Buttons outside the handle. A draggable block takes every press on its own area to start a drag, so a button inside it would never get the click.
dragHandle: '.node-body'restricts dragging to the body, and the Connect button sits outside it. - Keep controls away from the ends of lines. The invisible hit area of a selectable line extends a little over the node where it ends and takes clicks there. A control on a node's edge can be covered; one in the middle cannot.
Variations
- Connect by dragging from a pin. The library has no drag-to-connect gesture of its own. Build it from the pointer events on your own pins and call
setConnectionswith the new list, but keep the pins off the line ends, as above. - Undo. Keep the connections as an immutable list and push the previous list onto a stack before each change. Passing an older list back redraws it.
- Save the graph.
JSON.stringifythe connections together with the node positions; see Add, Remove and Save the Layout.