Редактор потоков
Редактор узлов: перетаскиваемые шаги, линии, которые обходят чужие узлы и перепрыгивают друг через друга, соединение по клику и удаление выделенной линии клавишей Delete. Список соединений принадлежит приложению; библиотека рисует его и сообщает, что сделал пользователь.
Что здесь используется:
- Перетаскивание с рукояткой, сеткой и границами.
- Маршрутизация и пересечения —
avoidObstaclesиjumps. - Анимированный поток — точки, бегущие вдоль каждой линии.
- Выделение и доступность — выделение линии и событие
connection:delete-request.
Чистый 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; см. Добавление, удаление и сохранение раскладки.