Сопоставление полей в списках с прокруткой
Два длинных списка рядом — столбцы загруженного файла и поля базы данных — и линии между строками, которые пользователь сопоставил. Списки прокручиваются, поэтому сопоставленная строка может оказаться вне видимой области; её линия при этом остаётся прикреплённой к краю списка и не висит над страницей.
Что здесь используется:
- Порты на строках — каждая строка является портом своей панели.
- Прокручиваемые контейнеры —
clipToScrollParentsзакрепляет или скрывает конец линии, строка которой ушла за край. - Выделение и доступность — выделение линии и её удаление клавишей Delete.
Чистый JavaScript
html
<div id="diagram">
<div class="panel" id="source"><h3>CSV columns</h3></div>
<div class="panel" id="target"><h3>Database fields</h3></div>
</div>css
body {
margin: 0;
font:
14px system-ui,
sans-serif;
background: #f8fafc;
}
#diagram {
position: relative;
display: flex;
justify-content: space-between;
margin: 16px;
padding: 0 40px;
}
.panel {
width: 240px;
height: 260px;
overflow-y: auto;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 8px;
}
.panel h3 {
position: sticky;
top: 0;
margin: 0;
padding: 8px 12px;
background: #e2e8f0;
font-size: 13px;
}
.row {
padding: 8px 12px;
border-top: 1px solid #f1f5f9;
cursor: pointer;
}
.row:hover {
background: #f8fafc;
}
.row.picked {
background: #e0e7ff;
}
.row small {
color: #64748b;
}ts
import { createVisualLinker, type ConnectionDescriptor } from '@macrulez/visual-linker-core'
const sourceFields = [
'first_name',
'last_name',
'email_address',
'phone',
'company',
'job_title',
'city',
'country',
'zip',
'notes',
]
const targetFields = [
'givenName',
'familyName',
'email',
'mobile',
'organization',
'position',
'town',
'countryCode',
'postalCode',
'comment',
]
let mappings: ConnectionDescriptor[] = [
{
id: 'first_name>givenName',
from: { blockId: 'source', portId: 'first_name' },
to: { blockId: 'target', portId: 'givenName' },
},
{
id: 'email_address>email',
from: { blockId: 'source', portId: 'email_address' },
to: { blockId: 'target', portId: 'email' },
},
{
id: 'notes>comment',
from: { blockId: 'source', portId: 'notes' },
to: { blockId: 'target', portId: 'comment' },
},
]
const fill = (panel: HTMLElement, fields: string[]) => {
panel.insertAdjacentHTML(
'beforeend',
fields.map((f) => `<div class="row" data-field="${f}">${f}</div>`).join(''),
)
}
const diagram = document.querySelector<HTMLElement>('#diagram')!
const source = document.querySelector<HTMLElement>('#source')!
const target = document.querySelector<HTMLElement>('#target')!
fill(source, sourceFields)
fill(target, targetFields)
const linker = createVisualLinker(diagram, {
lines: { curve: 'bezier', width: 2, hover: { width: 3 } },
markers: { start: { shape: 'circle' }, end: { shape: 'circle' } },
interaction: { hover: true, selectable: true, clipToScrollParents: 'pin' },
})
const ports = (fields: string[], side: 'left' | 'right') =>
fields.map((f) => ({ id: f, target: `[data-field="${f}"]`, side }))
linker.setBlocks([
{ id: 'source', el: source, ports: ports(sourceFields, 'right') },
{ id: 'target', el: target, ports: ports(targetFields, 'left') },
])
linker.setConnections(mappings)
linker.on('connection:delete-request', ({ connections }) => {
const ids = new Set(connections.map((c) => c.id))
mappings = mappings.filter((c) => !ids.has(c.id))
linker.setConnections(mappings)
})
let picked: HTMLElement | null = null
diagram.addEventListener('click', (event) => {
const row = (event.target as HTMLElement).closest<HTMLElement>('.row')
if (!row) return
if (row.parentElement === source) {
picked?.classList.remove('picked')
picked = picked === row ? null : row
picked?.classList.add('picked')
return
}
if (!picked) return
const id = `${picked.dataset.field}>${row.dataset.field}`
if (!mappings.some((m) => m.id === id)) {
mappings = [
...mappings,
{
id,
from: { blockId: 'source', portId: picked.dataset.field! },
to: { blockId: 'target', portId: row.dataset.field! },
},
]
linker.setConnections(mappings)
}
picked.classList.remove('picked')
picked = null
})Vue
Стили те же, что выше, в блоке <style scoped>.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VisualLinker, vVlBlock, vVlPort } from '@macrulez/visual-linker-vue'
import type { ConnectionDescriptor, VisualLinkerConfig } from '@macrulez/visual-linker-vue'
const sourceFields = [
'first_name',
'last_name',
'email_address',
'phone',
'company',
'job_title',
'city',
'country',
'zip',
'notes',
]
const targetFields = [
'givenName',
'familyName',
'email',
'mobile',
'organization',
'position',
'town',
'countryCode',
'postalCode',
'comment',
]
const mappings = ref<ConnectionDescriptor[]>([
{
id: 'first_name>givenName',
from: { blockId: 'source', portId: 'first_name' },
to: { blockId: 'target', portId: 'givenName' },
},
{
id: 'email_address>email',
from: { blockId: 'source', portId: 'email_address' },
to: { blockId: 'target', portId: 'email' },
},
{
id: 'notes>comment',
from: { blockId: 'source', portId: 'notes' },
to: { blockId: 'target', portId: 'comment' },
},
])
const config: VisualLinkerConfig = {
lines: { curve: 'bezier', width: 2, hover: { width: 3 } },
markers: { start: { shape: 'circle' }, end: { shape: 'circle' } },
interaction: { hover: true, selectable: true, clipToScrollParents: 'pin' },
}
const picked = ref<string | null>(null)
function pickSource(field: string) {
picked.value = picked.value === field ? null : field
}
function mapTo(field: string) {
if (!picked.value) return
const id = `${picked.value}>${field}`
if (!mappings.value.some((m) => m.id === id)) {
mappings.value.push({
id,
from: { blockId: 'source', portId: picked.value },
to: { blockId: 'target', portId: field },
})
}
picked.value = null
}
function remove(doomed: ConnectionDescriptor[]) {
const ids = new Set(doomed.map((c) => c.id))
mappings.value = mappings.value.filter((m) => !ids.has(m.id))
}
</script>
<template>
<VisualLinker
class="diagram"
:connections="mappings"
:config="config"
@connection-delete-request="remove"
>
<div v-vl-block="'source'" class="panel">
<h3>CSV columns</h3>
<div
v-for="field in sourceFields"
:key="field"
v-vl-port="{ id: field, side: 'right' }"
class="row"
:class="{ picked: picked === field }"
@click="pickSource(field)"
>
{{ field }}
</div>
</div>
<div v-vl-block="'target'" class="panel">
<h3>Database fields</h3>
<div
v-for="field in targetFields"
:key="field"
v-vl-port="{ id: field, side: 'left' }"
class="row"
@click="mapTo(field)"
>
{{ field }}
</div>
</div>
</VisualLinker>
</template>Nuxt
Компонент работает без изменений: на сервере выполнять нечего, кроме разметки, а движок рисует на клиенте после монтирования. Строку import для VisualLinker, vVlBlock и vVlPort можно убрать — модуль регистрирует все три.
Как это работает
- Панели — блоки, строки — порты. Обе панели зарегистрированы как блоки, а каждая строка — порт своей панели:
target— это строка,side—'right'для строк источника и'left'для строк приёмника. Сопоставление — это соединение от строки к строке, которое называет порты черезportId. - Строка ушла за край.
clipToScrollParents: 'pin'подтягивает конец линии к краю видимой части списка и убирает маркер этого конца, так что линия выглядит уходящей за экран. Движок измеряет от самой строки, поэтому знает, какой прокручиваемый предок её скрывает. - Точки на строках. Каждая точка лежит на краю своей строки. Если задать
anchorBlockIdпанели, точки окажутся на границе панели, за полосой прокрутки, а строки, ушедшие за край, будут прижиматься так же. См. Прокручиваемые контейнеры. - Создание сопоставления. Клик по строке источника запоминает её, клик по строке приёмника добавляет соединение. Выделенная линия (
selectable: true) удаляется клавишей Delete черезconnection:delete-request. - Линии следуют за прокруткой. Движок слушает прокрутку панелей, поэтому линии двигаются вместе со строками и ручное обновление не нужно.
Варианты
- Скрывать вместо закрепления.
clipToScrollParents: 'hide'убирает линию целиком, пока любая из её строк вне видимой области, — длинные списки выглядят спокойнее. - Автосопоставление по имени. Заполните
mappingsпарами имён, равных после приведения регистра и подчёркиваний; пользователю остаётся исправить только неверное. - Оформление закреплённых линий. Событие
layoutотмечаетfromClippedиtoClippedу каждого соединения, чтобы нарисовать свою стрелку у края. См. Прокручиваемые контейнеры.