Merge pull request #150 from Pouzor/feat/edge-endpoint-reconnect
feat(canvas): edge endpoint reconnect + proxmox container snap points
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
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 ──────────────────────────────────────────────────────
|
// ── 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 activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||||
@@ -311,6 +415,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
</div>
|
</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 */}
|
{/* Existing waypoint drag handles */}
|
||||||
{selected && waypoints.map((wp, idx) => {
|
{selected && waypoints.map((wp, idx) => {
|
||||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement, useEffect } from 'react'
|
||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -10,10 +10,13 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
|
|
||||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
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 activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
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 }}
|
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="target" position={Position.Top} id="top-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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id="bottom"
|
id={sourceId}
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={targetId}
|
||||||
|
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Cluster handles */}
|
{/* Cluster handles */}
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ describe('ProxmoxGroupNode', () => {
|
|||||||
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
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', () => {
|
it('renders cluster handles in both modes', () => {
|
||||||
const { container: groupC } = renderNode({})
|
const { container: groupC } = renderNode({})
|
||||||
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
to { stroke-dashoffset: 0; }
|
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 */
|
/* Homelable dark theme — always dark */
|
||||||
:root {
|
:root {
|
||||||
--background: #0d1117;
|
--background: #0d1117;
|
||||||
|
|||||||
@@ -497,6 +497,31 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
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', () => {
|
it('deleteEdge removes the edge and marks unsaved', () => {
|
||||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
|
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
|
||||||
useCanvasStore.getState().markSaved()
|
useCanvasStore.getState().markSaved()
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface CanvasState {
|
|||||||
updateNode: (id: string, data: Partial<NodeData>) => void
|
updateNode: (id: string, data: Partial<NodeData>) => void
|
||||||
deleteNode: (id: string) => void
|
deleteNode: (id: string) => void
|
||||||
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
||||||
|
reconnectEdge: (id: string, connection: Connection) => void
|
||||||
deleteEdge: (id: string) => void
|
deleteEdge: (id: string) => void
|
||||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||||
setNodeZIndex: (id: string, zIndex: number) => void
|
setNodeZIndex: (id: string, zIndex: number) => void
|
||||||
@@ -292,6 +293,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
hasUnsavedChanges: true,
|
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) =>
|
deleteEdge: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
edges: state.edges.filter((e) => e.id !== id),
|
edges: state.edges.filter((e) => e.id !== id),
|
||||||
|
|||||||
Reference in New Issue
Block a user