Skip to content

Редактор потоков ​

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

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

Чистый JavaScript ​

html
<div id="diagram"></div>
<p>
  Нажмите Connect на узле, затем кликните другой узел. Выделите линию и нажмите Delete, чтобы
  удалить её.
</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 ​

Список соединений — это ref, поэтому добавление в него и фильтрация перерисовывают диаграмму. Стили те же, что выше, в блоке <style scoped>.

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 ​

Вынесите опции в nuxt.config.ts: они станут общей конфигурацией всех диаграмм приложения.

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 },
  },
})

Компоненту больше не нужны ни config, ни импорты — <VisualLinker>, v-vl-block и ref доступны и так:

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>

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

  • Данные принадлежат приложению. Библиотека сама никогда не удаляет соединение. connection:delete-request (@connection-delete-request в Vue) сообщает, какие линии пользователь попросил удалить клавишей Delete или Backspace; обработчик убирает их из своего списка и передаёт список обратно.
  • Выделение. interaction: { selectable: true } делает линии кликабельными и доступными с клавиатуры, а selected: { color: '#6366f1' } задаёт вид выделенной линии.
  • Маршрутизация. routing: { avoidObstacles: true } обводит линию smoothstep вокруг других узлов, а jumps: true заставляет её перепрыгивать линии, которые она пересекает.
  • Анимация. animated пускает узор вдоль каждой линии, от её начала к концу.
  • Стороны выбираются сами. Соединения называют только блоки, без портов, поэтому каждый конец выходит с той стороны узла, что смотрит на другой.
  • Создание соединения. Нажатие Connect запоминает узел как источник, а следующий клик по другому узлу добавляет соединение. Клик доходит до узла, потому что рукояткой перетаскивания служит только .node-body.
  • Кнопки вне рукоятки. Перетаскиваемый блок забирает любое нажатие на своей площади, чтобы начать перетаскивание, поэтому кнопка внутри него не получила бы клик. dragHandle: '.node-body' ограничивает перетаскивание телом узла, а кнопка Connect стоит за его пределами.
  • Не ставьте элементы управления на концы линий. Невидимая область нажатия выделяемой линии немного заходит на узел, где линия заканчивается, и забирает там клики. Элемент на краю узла может оказаться закрытым, в середине — нет.

Варианты ​

  • Соединение перетаскиванием от пина. Собственного жеста «протяни, чтобы соединить» в библиотеке нет. Соберите его из событий указателя на своих пинах и вызовите setConnections с новым списком, но держите пины вдали от концов линий, как выше.
  • Отмена. Храните соединения как неизменяемый список и перед каждым изменением кладите предыдущий список в стек. Передача старого списка обратно перерисует его.
  • Сохранение графа. Сериализуйте соединения вместе с позициями узлов через JSON.stringify; см. Добавление, удаление и сохранение раскладки.