Merge pull request #150 from Pouzor/feat/edge-endpoint-reconnect

feat(canvas): edge endpoint reconnect + proxmox container snap points
This commit is contained in:
Rémy
2026-05-15 11:10:47 +02:00
committed by GitHub
6 changed files with 227 additions and 12 deletions
+138 -2
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import {
BaseEdge,
EdgeLabelRenderer,
@@ -179,9 +179,113 @@ function segmentMidpoints(
})
}
// ── Endpoint dot (interactive reconnection handle pinned to handle) ──────────
interface EndpointDotProps {
edgeId: string
role: 'source' | 'target'
x: number
y: number
position?: string
color: string
source: string
target: string
sourceHandle: string | null | undefined
targetHandle: string | null | undefined
onDrag: (pos: { x: number; y: number } | null) => void
}
/**
* Interactive endpoint marker rendered above the node layer (via
* EdgeLabelRenderer). On pointerup it inspects the element under the cursor
* for a React Flow handle (`[data-handleid]`) and calls `reconnectEdge` with
* the new endpoint. Drop on empty space leaves the edge unchanged.
*
* Handles are nudged 3px inward (toward the node) because React Flow's edge
* endpoint coords sit at the outer edge of the handle box, not its center.
*/
function EndpointDot({ edgeId, role, x, y, position, color, source, target, sourceHandle, targetHandle, onDrag }: EndpointDotProps) {
const reconnectEdge = useCanvasStore((s) => s.reconnectEdge)
const { screenToFlowPosition } = useReactFlow()
const offset = 3
let dx = 0, dy = 0
if (position === 'bottom') dy = -offset
else if (position === 'top') dy = offset
else if (position === 'left') dx = offset
else if (position === 'right') dx = -offset
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
}, [])
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (e.buttons !== 1) return
onDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY }))
}, [onDrag, screenToFlowPosition])
const onPointerUp = useCallback((e: React.PointerEvent) => {
e.currentTarget.releasePointerCapture(e.pointerId)
// Find the topmost handle under cursor, skipping the dragged dot itself.
const stack = document.elementsFromPoint(e.clientX, e.clientY)
let handleEl: HTMLElement | null = null
for (const node of stack) {
const h = (node as HTMLElement).closest?.('[data-handleid]') as HTMLElement | null
if (h) { handleEl = h; break }
}
onDrag(null)
if (!handleEl) return // dropped on empty space → keep edge unchanged
const newHandleId = handleEl.getAttribute('data-handleid')
const newNodeId = handleEl.getAttribute('data-nodeid')
if (!newHandleId || !newNodeId) return
if (role === 'source') {
reconnectEdge(edgeId, { source: newNodeId, target, sourceHandle: newHandleId, targetHandle: targetHandle ?? null })
} else {
reconnectEdge(edgeId, { source, target: newNodeId, sourceHandle: sourceHandle ?? null, targetHandle: newHandleId })
}
}, [edgeId, role, source, target, sourceHandle, targetHandle, reconnectEdge, onDrag])
return (
<div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${x + dx}px, ${y + dy}px)`,
width: 15,
height: 15,
borderRadius: '50%',
background: color,
border: '2px solid #0d1117',
cursor: 'grab',
pointerEvents: 'all',
zIndex: 1000,
touchAction: 'none',
}}
title="Drag to reconnect"
/>
)
}
// ── Main edge component ──────────────────────────────────────────────────────
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
export function HomelableEdge({ id, source, target, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
const [drag, setDrag] = useState<{ role: 'source' | 'target'; x: number; y: number } | null>(null)
const sourceX = drag?.role === 'source' ? drag.x : rawSourceX
const sourceY = drag?.role === 'source' ? drag.y : rawSourceY
const targetX = drag?.role === 'target' ? drag.x : rawTargetX
const targetY = drag?.role === 'target' ? drag.y : rawTargetY
const onSourceDrag = useCallback((pos: { x: number; y: number } | null) => {
setDrag(pos ? { role: 'source', x: pos.x, y: pos.y } : null)
}, [])
const onTargetDrag = useCallback((pos: { x: number; y: number } | null) => {
setDrag(pos ? { role: 'target', x: pos.x, y: pos.y } : null)
}, [])
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
@@ -311,6 +415,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
</div>
)}
{/* Endpoint dots — visual indicators for reconnection targets */}
{selected && (
<>
<EndpointDot
edgeId={id}
role="source"
x={sourceX}
y={sourceY}
position={sourcePosition}
color={strokeColor}
source={source}
target={target}
sourceHandle={sourceHandleId}
targetHandle={targetHandleId}
onDrag={onSourceDrag}
/>
<EndpointDot
edgeId={id}
role="target"
x={targetX}
y={targetY}
position={targetPosition}
color={strokeColor}
source={source}
target={target}
sourceHandle={sourceHandleId}
targetHandle={targetHandleId}
onDrag={onTargetDrag}
/>
</>
)}
{/* Existing waypoint drag handles */}
{selected && waypoints.map((wp, idx) => {
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
@@ -1,5 +1,5 @@
import { createElement } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { createElement, useEffect } from 'react'
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
@@ -10,10 +10,13 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
import { BaseNode } from './BaseNode'
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { data, selected } = props
const { id, data, selected } = props
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
@@ -149,13 +152,26 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
const sourceId = bottomHandleId(idx)
const targetId = `${sourceId}-t`
return (
<span key={sourceId}>
<Handle
type="source"
position={Position.Bottom}
id={sourceId}
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle
type="target"
position={Position.Bottom}
id={targetId}
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
/>
</span>
)
})}
{/* Cluster handles */}
<Handle
@@ -102,6 +102,18 @@ describe('ProxmoxGroupNode', () => {
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
})
it('container mode renders bottom_handles snap points', () => {
const { container } = renderNode({ bottom_handles: 4 })
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
expect(sourceHandles.length).toBe(4)
})
it('container mode default has single bottom handle', () => {
const { container } = renderNode({})
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
expect(sourceHandles.length).toBe(1)
})
it('renders cluster handles in both modes', () => {
const { container: groupC } = renderNode({})
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
+7
View File
@@ -11,6 +11,13 @@
to { stroke-dashoffset: 0; }
}
/* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders
its own interactive endpoint dots in EdgeLabelRenderer (above the node
layer) so the node Handle DOM cannot steal the reconnection drag. */
.react-flow__edgeupdater {
display: none;
}
/* Homelable dark theme — always dark */
:root {
--background: #0d1117;
@@ -497,6 +497,31 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('reconnectEdge swaps source/target and normalizes handles', () => {
useCanvasStore.setState((s) => ({
edges: [...s.edges, { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom', targetHandle: 'top' }],
}))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().reconnectEdge('e1', {
source: 'n1',
target: 'n3',
sourceHandle: 'bottom-2-t',
targetHandle: 'top-t',
})
const edge = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(edge?.target).toBe('n3')
expect(edge?.source).toBe('n1')
expect(edge?.sourceHandle).toBe('bottom-2')
expect(edge?.targetHandle).toBe('top')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('reconnectEdge snapshots history for undo', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')], past: [] }))
useCanvasStore.getState().reconnectEdge('e1', { source: 'n1', target: 'n3', sourceHandle: null, targetHandle: null })
expect(useCanvasStore.getState().past.length).toBe(1)
})
it('deleteEdge removes the edge and marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
useCanvasStore.getState().markSaved()
+19
View File
@@ -44,6 +44,7 @@ interface CanvasState {
updateNode: (id: string, data: Partial<NodeData>) => void
deleteNode: (id: string) => void
updateEdge: (id: string, data: Partial<EdgeData>) => void
reconnectEdge: (id: string, connection: Connection) => void
deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
setNodeZIndex: (id: string, zIndex: number) => void
@@ -292,6 +293,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
hasUnsavedChanges: true,
})),
reconnectEdge: (id, connection) =>
set((state) => ({
edges: state.edges.map((e) =>
e.id === id
? {
...e,
source: connection.source ?? e.source,
target: connection.target ?? e.target,
sourceHandle: normalizeHandle(connection.sourceHandle),
targetHandle: normalizeHandle(connection.targetHandle),
}
: e
),
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
hasUnsavedChanges: true,
})),
deleteEdge: (id) =>
set((state) => ({
edges: state.edges.filter((e) => e.id !== id),