Skip to content

Добавление, удаление и сохранение раскладки ​

Диаграмма, которая меняется, пока с ней работают: узлы добавляются и удаляются, а то, куда пользователь перетащил каждый из них, запоминается между визитами. Соединения здесь просто цепляют узлы по порядку, поэтому следуют за тем, чем становится список.

Что здесь используется:

  • API движка — setBlocks, setConnections и событие block:dragend.
  • Перетаскивание — смещение, которое блок сообщает по окончании перетаскивания.
  • Компонент — событие block-dragend и автоматическое обнаружение блоков.

Чистый 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 ​

Узлы — это ref, выводимый через v-for; достаточно добавить или удалить узел, ведь <VisualLinker> обнаруживает блоки по мере их появления и исчезновения. Стили те же, что выше, в блоке <style scoped>.

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 ​

Компонент работает без изменений, импорты необязательны: модуль регистрирует <VisualLinker> и v-vl-block, а ref, computed, watch и onMounted импортируются автоматически.

Сохранённая раскладка читается в onMounted, потому что на сервере localStorage нет. Сервер и первый рендер на клиенте оба показывают позиции по умолчанию, поэтому они совпадают, а сохранённые применяются сразу после монтирования.

Как это работает ​

  • Базовая позиция — ваша. Место каждого узла задают left и top. Перетаскивание сдвигает блок через CSS translate поверх этого, а block:dragend сообщает смещение от базовой позиции, а не абсолютную.
  • Что сохраняется. Новая позиция — это база плюс смещение. Она хранится по id, а при следующем визите становится базой, поэтому движок снова начинает со смещения ноль.
  • Добавление и удаление. В Vue достаточно изменить список в v-for. В чистом JavaScript создайте или удалите элемент сами и вызовите setBlocks с полным текущим списком, затем setConnections с соединениями, которые ещё имеют смысл.
  • Соединения следуют за списком. Здесь они вычисляются из списка, по одному между каждой парой соседей, поэтому удаление последнего узла убирает и его соединение.
  • Сброс. Очистка сохранённой раскладки и перезагрузка возвращают каждый узел на место по умолчанию.

Варианты ​

  • Сохранение на сервере. Отправляйте позицию из block:dragend в свой API с задержкой (debounce) вместо записи в localStorage.
  • Экспорт и импорт. Сериализуйте узлы вместе с позициями и соединениями через JSON.stringify и восстанавливайте так же.
  • Отмена. Храните стек прежних списков узлов и позиций; передача старого списка обратно перерисует его.