Add, Remove and Save the Layout
A diagram that changes while the user works with it: nodes are added and removed, and where the user dragged each one is remembered between visits. The connections here simply chain the nodes in order, so they follow whatever the list becomes.
What it uses:
- Engine API —
setBlocks,setConnectionsand theblock:dragendevent. - Drag & drop — the offset a block reports when a drag ends.
- Component — the
block-dragendevent and live discovery of blocks.
Plain JavaScript
<div class="toolbar">
<button id="add">Add node</button>
<button id="remove">Remove last</button>
<button id="reset">Reset layout</button>
</div>
<div id="diagram"></div>body {
margin: 0;
padding: 16px;
font:
14px system-ui,
sans-serif;
background: #f8fafc;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
button {
padding: 6px 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
#diagram {
position: relative;
height: 360px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.card {
position: absolute;
width: 130px;
padding: 12px 14px;
background: #fff;
border: 1px solid #94a3b8;
border-radius: 8px;
cursor: grab;
user-select: none;
}import { createVisualLinker } from '@macrulez/visual-linker-core'
type Position = { x: number; y: number }
type Node = { id: string; title: string }
const STORAGE_KEY = 'diagram-layout'
const nodes: Node[] = [
{ id: 'n1', title: 'Start' },
{ id: 'n2', title: 'Process' },
{ id: 'n3', title: 'Finish' },
]
const defaults = (index: number): Position => ({
x: 20 + (index % 4) * 200,
y: 40 + Math.floor(index / 4) * 120 + (index % 2) * 60,
})
const saved: Record<string, Position> = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
const positions = new Map<string, Position>()
const origin = new Map<string, Position>()
const diagram = document.querySelector<HTMLElement>('#diagram')!
const linker = createVisualLinker(diagram, {
lines: { curve: 'smoothstep' },
markers: { end: { shape: 'arrow' } },
blocks: { draggable: true, drag: { grid: 10, bounds: 'container' } },
})
const elements = new Map<string, HTMLElement>()
function place(id: string, index: number) {
const start = saved[id] ?? defaults(index)
origin.set(id, start)
positions.set(id, start)
return start
}
function render() {
diagram.querySelectorAll('.card').forEach((el) => {
if (!nodes.some((n) => `n-${n.id}` === el.id)) el.remove()
})
nodes.forEach((n, index) => {
if (elements.has(n.id)) return
const start = place(n.id, index)
const el = document.createElement('div')
el.className = 'card'
el.id = `n-${n.id}`
el.textContent = n.title
el.style.left = `${start.x}px`
el.style.top = `${start.y}px`
diagram.append(el)
elements.set(n.id, el)
})
for (const id of [...elements.keys()]) {
if (!nodes.some((n) => n.id === id)) elements.delete(id)
}
linker.setBlocks(nodes.map((n) => ({ id: n.id, el: elements.get(n.id)! })))
linker.setConnections(
nodes.slice(1).map((n, i) => ({
id: `${nodes[i].id}>${n.id}`,
from: { blockId: nodes[i].id },
to: { blockId: n.id },
})),
)
}
linker.on('block:dragend', ({ blockId, x, y }) => {
const start = origin.get(blockId)!
positions.set(blockId, { x: start.x + x, y: start.y + y })
localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(positions)))
})
document.querySelector('#add')!.addEventListener('click', () => {
const n = nodes.length + 1
nodes.push({ id: `n${n}`, title: `Step ${n}` })
render()
})
document.querySelector('#remove')!.addEventListener('click', () => {
const last = nodes.pop()
if (last) {
positions.delete(last.id)
elements.get(last.id)?.remove()
}
render()
})
document.querySelector('#reset')!.addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY)
location.reload()
})
render()Vue
Nodes are a ref rendered with v-for; adding or removing one is enough, since <VisualLinker> discovers blocks as they appear and disappear. The styles are the same as above, in a <style scoped> block.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { VisualLinker, vVlBlock } from '@macrulez/visual-linker-vue'
import type { VisualLinkerConfig } from '@macrulez/visual-linker-vue'
type Position = { x: number; y: number }
const STORAGE_KEY = 'diagram-layout'
const defaults = (index: number): Position => ({
x: 20 + (index % 4) * 200,
y: 40 + Math.floor(index / 4) * 120 + (index % 2) * 60,
})
const nodes = ref(
['Start', 'Process', 'Finish'].map((title, index) => ({
id: `n${index + 1}`,
title,
start: defaults(index),
})),
)
const positions = ref<Record<string, Position>>({})
const restored = ref(false)
onMounted(() => {
const saved: Record<string, Position> = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
for (const node of nodes.value) node.start = saved[node.id] ?? node.start
positions.value = saved
restored.value = true
})
const connections = computed(() =>
nodes.value.slice(1).map((node, i) => ({
id: `${nodes.value[i].id}>${node.id}`,
from: { blockId: nodes.value[i].id },
to: { blockId: node.id },
})),
)
const config: VisualLinkerConfig = {
lines: { curve: 'smoothstep' },
markers: { end: { shape: 'arrow' } },
blocks: { draggable: true, drag: { grid: 10, bounds: 'container' } },
}
function add() {
const index = nodes.value.length
nodes.value.push({ id: `n${index + 1}`, title: `Step ${index + 1}`, start: defaults(index) })
}
function removeLast() {
const last = nodes.value.pop()
if (last) delete positions.value[last.id]
}
function onDragEnd({ blockId, x, y }: { blockId: string; x: number; y: number }) {
const node = nodes.value.find((n) => n.id === blockId)!
positions.value[blockId] = { x: node.start.x + x, y: node.start.y + y }
}
function reset() {
localStorage.removeItem(STORAGE_KEY)
location.reload()
}
watch(
positions,
(value) => {
if (restored.value) localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
},
{ deep: true },
)
</script>
<template>
<div class="toolbar">
<button @click="add">Add node</button>
<button @click="removeLast">Remove last</button>
<button @click="reset">Reset layout</button>
</div>
<VisualLinker
class="diagram"
:connections="connections"
:config="config"
@block-dragend="onDragEnd"
>
<div
v-for="node in nodes"
:key="node.id"
v-vl-block="node.id"
class="card"
:style="{ left: `${node.start.x}px`, top: `${node.start.y}px` }"
>
{{ node.title }}
</div>
</VisualLinker>
</template>Nuxt
The component works unchanged, with the imports optional: the module registers <VisualLinker> and v-vl-block, and ref, computed, watch and onMounted are auto-imported.
The saved layout is read in onMounted, because localStorage does not exist on the server. The server and the first client render both show the default positions, so they match, and the saved ones are applied right after mounting.
How it works
- The base position is yours. Each node's place comes from
leftandtop. A drag moves the block with a CSStranslateon top of that, andblock:dragendreports the offset from the base position, not an absolute one. - What is saved. The new position is the base plus the offset. It is stored by id, and on the next visit it becomes the base, so the engine starts again from an offset of zero.
- Adding and removing. In Vue, changing the
v-forlist is enough. In plain JavaScript, create or remove the element yourself and callsetBlockswith the full current list, thensetConnectionswith the connections that still make sense. - Connections follow the list. Here they are computed from the list, one between each pair of neighbours, so removing the last node removes its connection too.
- Reset. Clearing the stored layout and reloading puts every node back at its default position.
Variations
- Save on a server. Send the position from
block:dragendto your API, debounced, instead of writing tolocalStorage. - Export and import.
JSON.stringifythe nodes together with their positions and the connections, and restore them the same way. - Undo. Keep a stack of the previous lists of nodes and positions; passing an older list back redraws it.