feat: add interactive edge waypoints with smooth path editing
- Drag waypoints to reshape edges; double-click a waypoint to remove it - + handles at segment midpoints to insert new waypoints - Bezier style: catmull-rom smooth curves through waypoints - Smooth style: rounded-corner polyline with soft 45° snap (snaps within 15px) - First + handle biased to source axis for perpendicular node exit - snap45both: ray-intersection solver ensures both adjacent segments snap to 45° simultaneously - Clear path button in EdgeModal when waypoints exist - Waypoints serialised/deserialised with canvas state
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getBezierPath,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
useStore,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} from '@xyflow/react'
|
||||
import type { EdgeData, EdgeType } from '@/types'
|
||||
import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
|
||||
|
||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||
|
||||
@@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${waypoint.x}px, ${waypoint.y}px)`,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
border: '2px solid #0d1117',
|
||||
cursor: 'grab',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title="Drag to move · Double-click to remove"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
background: '#0d1117',
|
||||
border: `1.5px solid ${color}`,
|
||||
color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
cursor: 'crosshair',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 9,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
title="Click to add waypoint"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 b = pts[i + 1]
|
||||
let mx = (a.x + b.x) / 2
|
||||
const my = (a.y + b.y) / 2
|
||||
|
||||
// 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 }
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main edge component ──────────────────────────────────────────────────────
|
||||
|
||||
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
@@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
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 [edgePath, labelX] = data?.path_style === 'smooth'
|
||||
const [autoPath, labelX] = pathStyle === 'smooth'
|
||||
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
||||
: getBezierPath(pathArgs)
|
||||
|
||||
const edgePath = hasWaypoints
|
||||
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||
: autoPath
|
||||
|
||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
||||
const midY = (sourceY + targetY) / 2
|
||||
|
||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||
const edgeColors = theme.colors.edgeColors
|
||||
|
||||
@@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
}
|
||||
|
||||
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) } : {}),
|
||||
@@ -50,16 +233,20 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||
}
|
||||
|
||||
// Normalize animated value — supports legacy boolean (true → 'snake')
|
||||
const animMode: 'none' | 'snake' | 'flow' =
|
||||
data?.animated === true || data?.animated === 'snake' ? 'snake' :
|
||||
data?.animated === 'flow' ? 'flow' : '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 (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={style} />
|
||||
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} />
|
||||
|
||||
{animMode === 'snake' && (
|
||||
<path
|
||||
d={edgePath}
|
||||
@@ -92,12 +279,12 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
</path>
|
||||
)}
|
||||
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<EdgeLabelRenderer>
|
||||
{data?.label && (
|
||||
<div
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${(sourceY + targetY) / 2}px)`,
|
||||
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
||||
background: theme.colors.edgeLabelBackground,
|
||||
color: theme.colors.edgeLabelColor,
|
||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||
@@ -105,8 +292,47 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
>
|
||||
{data.label as string}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Existing waypoint drag handles */}
|
||||
{selected && waypoints.map((wp, idx) => {
|
||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||
const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1]
|
||||
return (
|
||||
<WaypointHandle
|
||||
key={`wp-${idx}`}
|
||||
edgeId={id}
|
||||
index={idx}
|
||||
waypoint={wp}
|
||||
waypoints={waypoints}
|
||||
color={strokeColor}
|
||||
pathStyle={pathStyle}
|
||||
prevPoint={prevPoint}
|
||||
nextPoint={nextPoint}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* + handles at segment midpoints to add new waypoints */}
|
||||
{selected && midpoints.map((mp) => {
|
||||
const prevPoint = mp.insertIndex === 0
|
||||
? { x: sourceX, y: sourceY }
|
||||
: waypoints[mp.insertIndex - 1]
|
||||
return (
|
||||
<AddWaypointHandle
|
||||
key={`add-${mp.insertIndex}`}
|
||||
edgeId={id}
|
||||
insertIndex={mp.insertIndex}
|
||||
x={mp.x}
|
||||
y={mp.y}
|
||||
waypoints={waypoints}
|
||||
color={strokeColor}
|
||||
pathStyle={pathStyle}
|
||||
prevPoint={prevPoint}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user