Добавление, удаление и сохранение раскладки
Диаграмма, которая меняется, пока с ней работают: узлы добавляются и удаляются, а то, куда пользователь перетащил каждый из них, запоминается между визитами. Соединения здесь просто цепляют узлы по порядку, поэтому следуют за тем, чем становится список.
Что здесь используется:
- API движка —
setBlocks,setConnectionsи событиеblock:dragend. - Перетаскивание — смещение, которое блок сообщает по окончании перетаскивания.
- Компонент — событие
block-dragendи автоматическое обнаружение блоков.
Чистый 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
Узлы — это ref, выводимый через v-for; достаточно добавить или удалить узел, ведь <VisualLinker> обнаруживает блоки по мере их появления и исчезновения. Стили те же, что выше, в блоке <style scoped>.
<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
Компонент работает без изменений, импорты необязательны: модуль регистрирует <VisualLinker> и v-vl-block, а ref, computed, watch и onMounted импортируются автоматически.
Сохранённая раскладка читается в onMounted, потому что на сервере localStorage нет. Сервер и первый рендер на клиенте оба показывают позиции по умолчанию, поэтому они совпадают, а сохранённые применяются сразу после монтирования.
Как это работает
- Базовая позиция — ваша. Место каждого узла задают
leftиtop. Перетаскивание сдвигает блок через CSStranslateповерх этого, аblock:dragendсообщает смещение от базовой позиции, а не абсолютную. - Что сохраняется. Новая позиция — это база плюс смещение. Она хранится по id, а при следующем визите становится базой, поэтому движок снова начинает со смещения ноль.
- Добавление и удаление. В Vue достаточно изменить список в
v-for. В чистом JavaScript создайте или удалите элемент сами и вызовитеsetBlocksс полным текущим списком, затемsetConnectionsс соединениями, которые ещё имеют смысл. - Соединения следуют за списком. Здесь они вычисляются из списка, по одному между каждой парой соседей, поэтому удаление последнего узла убирает и его соединение.
- Сброс. Очистка сохранённой раскладки и перезагрузка возвращают каждый узел на место по умолчанию.
Варианты
- Сохранение на сервере. Отправляйте позицию из
block:dragendв свой API с задержкой (debounce) вместо записи вlocalStorage. - Экспорт и импорт. Сериализуйте узлы вместе с позициями и соединениями через
JSON.stringifyи восстанавливайте так же. - Отмена. Храните стек прежних списков узлов и позиций; передача старого списка обратно перерисует его.