import { useCallback, useState } from 'react' import { BaseEdge, EdgeLabelRenderer, getBezierPath, getSmoothStepPath, useReactFlow, useStore, type EdgeProps, type Edge, } from '@xyflow/react' import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] function getVlanColor(vlanId?: number): string { if (!vlanId) return '#00d4ff' return VLAN_COLORS[vlanId % VLAN_COLORS.length] } // ── Waypoint drag handle ───────────────────────────────────────────────────── interface WaypointHandleProps { edgeId: string index: number waypoint: Waypoint waypoints: Waypoint[] color: string pathStyle?: string prevPoint: Waypoint nextPoint: Waypoint } function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) { const { screenToFlowPosition } = useReactFlow() const updateEdge = useCanvasStore((s) => s.updateEdge) const handlePointerDown = useCallback((e: React.PointerEvent) => { e.stopPropagation() e.currentTarget.setPointerCapture(e.pointerId) }, []) const handlePointerMove = useCallback((e: React.PointerEvent) => { if (e.buttons !== 1) return let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY }) if (pathStyle === 'smooth') { // Find the intersection of 45°-rays from both adjacent points so that // ALL segments (prev→this and this→next) snap to 45° simultaneously. pos = snap45both(prevPoint, nextPoint, pos) } const next = [...waypoints] next[index] = pos updateEdge(edgeId, { waypoints: next }) }, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint]) const handlePointerUp = useCallback((e: React.PointerEvent) => { e.currentTarget.releasePointerCapture(e.pointerId) }, []) const handleDoubleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) }) }, [edgeId, waypoints, index, updateEdge]) return (
) } // ── Add waypoint handle (+ button at segment midpoints) ────────────────────── interface AddWaypointHandleProps { edgeId: string insertIndex: number x: number y: number waypoints: Waypoint[] color: string pathStyle?: string prevPoint: Waypoint } function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) { const updateEdge = useCanvasStore((s) => s.updateEdge) const handleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() let pos = { x, y } if (pathStyle === 'smooth') pos = snap45(prevPoint, pos) const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)] updateEdge(edgeId, { waypoints: next }) }, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint]) return (
+
) } // ── Segment midpoints ──────────────────────────────────────────────────────── /** * Compute + handle positions for each path segment. * For smooth style: bias the first + handle to the source handle axis and the * last + handle to the target handle axis, so clicking always gives a clean * perpendicular exit/entry (no diagonal guesswork near the nodes). */ function segmentMidpoints( sourceX: number, sourceY: number, waypoints: Waypoint[], targetX: number, targetY: number, pathStyle?: string, sourcePosition?: string, ): { x: number; y: number; insertIndex: number }[] { const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] const isSmooth = pathStyle === 'smooth' return pts.slice(0, -1).map((a, i) => { const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle) let mx = base.x const my = base.y // For smooth style with no existing waypoints, bias the single + handle onto // the source handle axis so clicking it creates a perpendicular exit. // Only applies to bottom/top handles (vertical exits) and only when the edge // has no waypoints yet — once waypoints exist, all + handles stay at the // real segment midpoint so they remain visually on the edge. if (isSmooth && i === 0 && pts.length === 2) { const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top' if (vertSrc) mx = a.x // same X as source → + sits directly below/above node } return { x: mx, y: my, insertIndex: i } }) } // ── 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, 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) const targetType = useStore((s) => s.nodeLookup.get(target)?.type) const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0 ? data.waypoints as Waypoint[] : [] const hasWaypoints = waypoints.length > 0 const pathStyle = data?.path_style as string | undefined const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } const [autoPath, labelX] = pathStyle === 'smooth' ? getSmoothStepPath({ ...pathArgs, borderRadius: 8 }) : getBezierPath(pathArgs) const edgePath = hasWaypoints ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) : autoPath const labelPosition = hasWaypoints ? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) : { x: labelX, y: (sourceY + targetY) / 2 } const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors const BASE_STYLES: Record = { ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 }, wifi: { stroke: edgeColors.wifi, strokeWidth: 1.5, strokeDasharray: '6 3' }, iot: { stroke: edgeColors.iot, strokeWidth: 1.5, strokeDasharray: '2 4' }, vlan: { strokeWidth: 2.5 }, virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' }, cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' }, fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` }, electrical: { stroke: edgeColors.electrical, strokeWidth: 2 }, } const customColor = data?.custom_color as string | undefined const strokeColor: string = selected ? theme.colors.edgeSelectedColor : customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet)) const style: React.CSSProperties = { ...BASE_STYLES[edgeType], ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), ...(customColor ? { stroke: customColor } : {}), ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } const animMode: 'none' | 'snake' | 'flow' | 'basic' = data?.animated === true || data?.animated === 'snake' ? 'snake' : data?.animated === 'flow' ? 'flow' : data?.animated === 'basic' ? 'basic' : 'none' const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string) const midpoints = selected ? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition) : [] return ( <> {animMode === 'basic' && ( )} {animMode === 'snake' && (