refactor: streamline flow board interactions
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s

This commit is contained in:
AzuTear
2026-06-14 15:11:05 +02:00
parent 0f8939306d
commit 6d4e8e7927
5 changed files with 235 additions and 145 deletions
@@ -33,7 +33,11 @@ defineEmits<{
{ entering }
]"
:style="{ left: left + '%', top: top + '%' }"
@click="$emit('select', agent.id)"
tabindex="0"
role="button"
:aria-label="`${agent.name} öffnen`"
@keydown.enter.prevent="$emit('select', agent.id)"
@keydown.space.prevent="$emit('select', agent.id)"
>
<div class="ncard">
<!-- Header: Avatar + Name + Role + Status-Dot -->
@@ -16,6 +16,7 @@ import type { AgentNodeData } from '../../../composables/useFlowLayout'
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
import { icons } from '../../../composables/icons'
import AgentNode from './AgentNode.vue'
import { useFlowCanvasInteractions } from './useFlowCanvasInteractions'
const props = defineProps<{
agents: AgentNodeData[]
@@ -172,93 +173,18 @@ watch(
)
/* ── Drag & Drop ──────────────────────────────── */
const DRAG_THRESHOLD = 5
interface DragState {
id: string
startX: number
startY: number
ox: number
oy: number
moved: boolean
raf: number | null
}
let drag: DragState | null = null
function onPointerDown(e: PointerEvent) {
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
if (!node) return
e.preventDefault()
const nr = node.getBoundingClientRect()
drag = {
id: node.dataset.id || '',
startX: e.clientX,
startY: e.clientY,
ox: e.clientX - (nr.left + nr.width / 2),
oy: e.clientY - (nr.top + nr.height / 2),
moved: false,
raf: null,
}
node.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!drag) return
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
if (!drag.moved && dist < DRAG_THRESHOLD) return
if (!drag.moved) {
drag.moved = true
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) node.classList.add('dragging')
}
const flow = flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
// Direct DOM manipulation for responsiveness
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) {
node.style.left = x + '%'
node.style.top = y + '%'
}
// Update positions state
const newPos = { ...props.positions }
newPos[drag.id] = { x, y }
emit('updatePositions', newPos)
// Debounced edge re-render
if (!drag.raf) {
drag.raf = requestAnimationFrame(() => {
renderEdges()
if (drag) drag.raf = null
})
}
}
function onPointerUp() {
if (!drag) return
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) node.classList.remove('dragging')
if (!drag.moved) {
// Was a click — emit select
emit('select', drag.id)
}
drag = null
}
const {
onClickCapture,
onPointerDown,
onPointerMove,
onPointerUp,
} = useFlowCanvasInteractions({
flowRef,
renderEdges,
updatePositions: positions => emit('updatePositions', positions),
selectAgent: id => emit('select', id),
getPositions: () => props.positions,
})
/* ── Keyboard handler for Enter key on buttons ── */
function handleReset() {
@@ -271,6 +197,7 @@ function handleReset() {
<div
ref="flowRef"
class="flow"
@click.capture="onClickCapture"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@@ -0,0 +1,125 @@
import { ref } from 'vue'
const DRAG_THRESHOLD = 5
const CLICK_SUPPRESSION_MS = 250
export interface FlowPosition {
x: number
y: number
}
interface DragState {
id: string
startX: number
startY: number
ox: number
oy: number
moved: boolean
raf: number | null
}
interface UseFlowCanvasInteractionsOptions {
flowRef: { value: HTMLElement | null }
renderEdges: () => void
updatePositions: (positions: Record<string, FlowPosition>) => void
selectAgent: (id: string) => void
getPositions: () => Record<string, FlowPosition>
}
function findNode(target: EventTarget | null) {
return (target as HTMLElement | null)?.closest('.node') as HTMLElement | null
}
export function useFlowCanvasInteractions(options: UseFlowCanvasInteractionsOptions) {
const drag = ref<DragState | null>(null)
const suppressClickUntil = ref(0)
function onPointerDown(e: PointerEvent) {
const node = findNode(e.target)
if (!node) return
e.preventDefault()
const nr = node.getBoundingClientRect()
drag.value = {
id: node.dataset.id || '',
startX: e.clientX,
startY: e.clientY,
ox: e.clientX - (nr.left + nr.width / 2),
oy: e.clientY - (nr.top + nr.height / 2),
moved: false,
raf: null,
}
node.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!drag.value) return
const currentDrag = drag.value
const dist = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
if (!currentDrag.moved && dist < DRAG_THRESHOLD) return
if (!currentDrag.moved) {
currentDrag.moved = true
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) node.classList.add('dragging')
}
const flow = options.flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const x = Math.max(8, Math.min(92, ((e.clientX - currentDrag.ox - fr.left) / fr.width) * 100))
const y = Math.max(10, Math.min(92, ((e.clientY - currentDrag.oy - fr.top) / fr.height) * 100))
const node = flow.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) {
node.style.left = x + '%'
node.style.top = y + '%'
}
options.updatePositions({
...options.getPositions(),
[currentDrag.id]: { x, y },
})
if (!currentDrag.raf) {
currentDrag.raf = requestAnimationFrame(() => {
options.renderEdges()
if (drag.value) drag.value.raf = null
})
}
}
function onPointerUp() {
if (!drag.value) return
const currentDrag = drag.value
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) node.classList.remove('dragging')
if (!currentDrag.moved) {
options.selectAgent(currentDrag.id)
} else {
suppressClickUntil.value = performance.now() + CLICK_SUPPRESSION_MS
}
drag.value = null
}
function onClickCapture(e: MouseEvent) {
if (performance.now() >= suppressClickUntil.value) return
if (!findNode(e.target)) return
e.preventDefault()
e.stopPropagation()
}
return {
onClickCapture,
onPointerDown,
onPointerMove,
onPointerUp,
}
}