diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 3a6c24d..2a4fb8b 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -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 ( +
+ ) +} + // ── Main edge component ────────────────────────────────────────────────────── -export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) { +export function HomelableEdge({ id, source, target, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) { + 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
)} + {/* Endpoint dots — visual indicators for reconnection targets */} + {selected && ( + <> + + + + )} + {/* Existing waypoint drag handles */} {selected && waypoints.map((wp, idx) => { const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1] diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index 1286164..acbf241 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -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>) { - 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>) { style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }} /> - - + {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { + const sourceId = bottomHandleId(idx) + const targetId = `${sourceId}-t` + return ( + + + + + ) + })} {/* Cluster handles */} { 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) diff --git a/frontend/src/index.css b/frontend/src/index.css index ea075ec..419df7e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 9918d28..66603e7 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -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() diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 25898fc..8f48d99 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -44,6 +44,7 @@ interface CanvasState { updateNode: (id: string, data: Partial) => void deleteNode: (id: string) => void updateEdge: (id: string, data: Partial) => 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((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),