Skip to content

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, setConnections and the block:dragend event.
  • Drag & drop — the offset a block reports when a drag ends.
  • Component — the block-dragend event and live discovery of blocks.

Plain JavaScript ​

html
<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>
css
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;
}
ts
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.

vue
<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 left and top. A drag moves the block with a CSS translate on top of that, and block:dragend reports 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-for list is enough. In plain JavaScript, create or remove the element yourself and call setBlocks with the full current list, then setConnections with 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:dragend to your API, debounced, instead of writing to localStorage.
  • Export and import. JSON.stringify the 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.