diff --git a/packages/react/src/components/EdgeRenderer.tsx b/packages/react/src/components/EdgeRenderer.tsx index 8a0ac9d..ba02405 100644 --- a/packages/react/src/components/EdgeRenderer.tsx +++ b/packages/react/src/components/EdgeRenderer.tsx @@ -4,6 +4,7 @@ import type { ResolvedNode, CanvasTheme, EdgeStyle, + ViewportState, } from 'system-canvas' import { computeEdgePath, @@ -11,6 +12,7 @@ import { resolveColor, measureTextWidth, } from 'system-canvas' +import { useWaypointDrag } from '../hooks/useWaypointDrag.js' interface EdgeRendererProps { edges: CanvasEdge[] @@ -28,6 +30,12 @@ interface EdgeRendererProps { dimmedNodeIds?: Set /** Parallel group info per edge id for auto-offsetting overlapping edges. */ parallelGroups?: Map + /** When true, waypoint handles are rendered for the selected edge. */ + editable?: boolean + /** Called when the user commits a waypoint drag or adds/removes a waypoint. */ + onWaypointCommit?: (edgeId: string, waypoints: { x: number; y: number }[]) => void + /** Viewport ref — needed for converting screen coords to canvas coords during waypoint drag. */ + viewportRef?: React.RefObject } /** @@ -46,7 +54,19 @@ export function EdgeRenderer({ editingId, dimmedNodeIds, parallelGroups, + editable, + onWaypointCommit, + viewportRef, }: EdgeRendererProps) { + // Stable fallback ref so the hook is always called unconditionally. + const fallbackViewportRef = React.useRef({ x: 0, y: 0, zoom: 1 }) + const effectiveViewportRef = (viewportRef ?? fallbackViewportRef) as React.RefObject + + const { overrides, onHandlePointerDown, onHandleDoubleClick, onGhostClick } = + useWaypointDrag({ + viewport: effectiveViewportRef, + onCommit: onWaypointCommit ?? (() => {}), + }) return ( <> {/* Arrowhead marker definition. Uses `context-stroke` so the polygon @@ -194,6 +214,89 @@ export function EdgeRenderer({ })()} )} + + {/* Waypoint drag handles — only in editable mode when this edge is selected */} + {editable && isSelected && (() => { + const baseWaypoints = edge.waypoints ?? [] + + // Apply live drag overrides + const liveWaypoints = baseWaypoints.map((wp, i) => + overrides.has(i) ? overrides.get(i)! : wp + ) + + // Ghost midpoints: between each pair of consecutive waypoints, + // plus before the first and after the last (using edge midpoint). + // When no waypoints exist, a single ghost at the edge midpoint is shown. + const ghostPoints: { x: number; y: number; insertAfterIndex: number }[] = [] + + if (liveWaypoints.length === 0) { + // Single ghost at auto-midpoint to let user start routing + ghostPoints.push({ x: midpoint.x, y: midpoint.y, insertAfterIndex: -1 }) + } else { + // Ghost before first waypoint (midpoint between edge start anchor and first wp) + // We approximate start anchor as the midpoint between the edge anchor and first wp. + // For simplicity, use the midpoint between edge midpoint and first waypoint. + ghostPoints.push({ + x: (midpoint.x + liveWaypoints[0].x) / 2, + y: (midpoint.y + liveWaypoints[0].y) / 2, + insertAfterIndex: -1, + }) + // Ghosts between consecutive waypoints + for (let i = 0; i < liveWaypoints.length - 1; i++) { + ghostPoints.push({ + x: (liveWaypoints[i].x + liveWaypoints[i + 1].x) / 2, + y: (liveWaypoints[i].y + liveWaypoints[i + 1].y) / 2, + insertAfterIndex: i, + }) + } + // Ghost after last waypoint + ghostPoints.push({ + x: (liveWaypoints[liveWaypoints.length - 1].x + midpoint.x) / 2, + y: (liveWaypoints[liveWaypoints.length - 1].y + midpoint.y) / 2, + insertAfterIndex: liveWaypoints.length - 1, + }) + } + + return ( + <> + {/* Ghost midpoint handles */} + {ghostPoints.map((gp, i) => ( + { + e.stopPropagation() + onGhostClick(edge.id, liveWaypoints, gp.insertAfterIndex, { x: gp.x, y: gp.y }) + }} + /> + ))} + + {/* Solid waypoint handles */} + {liveWaypoints.map((wp, i) => ( + onHandlePointerDown(edge.id, liveWaypoints, i, e)} + onDoubleClick={(e) => { + e.stopPropagation() + onHandleDoubleClick(edge.id, liveWaypoints, i) + }} + /> + ))} + + ) + })()} ) })} diff --git a/packages/react/src/components/SystemCanvas.tsx b/packages/react/src/components/SystemCanvas.tsx index dac81af..6f84e93 100644 --- a/packages/react/src/components/SystemCanvas.tsx +++ b/packages/react/src/components/SystemCanvas.tsx @@ -1408,6 +1408,13 @@ export const SystemCanvas = forwardRef( setEditingEdgeId(null) }, []) + const handleEdgeWaypointUpdate = useCallback( + (edgeId: string, patch: EdgeUpdate) => { + wrappedOnEdgeUpdate(edgeId, patch, currentCanvasRef) + }, + [wrappedOnEdgeUpdate, currentCanvasRef] + ) + // Cascade offset for rapid successive adds const lastAddRef = useRef<{ t: number; offset: number } | null>(null) @@ -1592,6 +1599,7 @@ export const SystemCanvas = forwardRef( onEditorCancel={handleEditorCancel} onEdgeEditorCommit={handleEdgeEditorCommit} onEdgeEditorCancel={handleEdgeEditorCancel} + onEdgeWaypointUpdate={editable ? handleEdgeWaypointUpdate : undefined} pendingEdge={editable ? pendingEdge : null} onConnectionHandlePointerDown={ editable ? onConnectionHandlePointerDown : undefined diff --git a/packages/react/src/components/Viewport.tsx b/packages/react/src/components/Viewport.tsx index b747aca..3a0c214 100644 --- a/packages/react/src/components/Viewport.tsx +++ b/packages/react/src/components/Viewport.tsx @@ -137,6 +137,7 @@ interface ViewportProps { onEditorCancel?: () => void onEdgeEditorCommit?: (patch: EdgeUpdate) => void onEdgeEditorCancel?: () => void + onEdgeWaypointUpdate?: (edgeId: string, patch: EdgeUpdate) => void // Edge creation (editable mode) pendingEdge?: PendingEdgeState | null @@ -217,6 +218,7 @@ export const Viewport = forwardRef( onEditorCancel, onEdgeEditorCommit, onEdgeEditorCancel, + onEdgeWaypointUpdate, pendingEdge, onConnectionHandlePointerDown, edgeCreateEnabled, @@ -617,6 +619,11 @@ export const Viewport = forwardRef( editingId={editingEdgeId} dimmedNodeIds={dimmedNodeIds} parallelGroups={parallelGroups} + editable={!!onNodePointerDown} + onWaypointCommit={onEdgeWaypointUpdate + ? (id, wps) => onEdgeWaypointUpdate(id, { waypoints: wps }) + : undefined} + viewportRef={viewport} /> {/* Non-group nodes on top (+ resize handles) */} diff --git a/packages/react/src/hooks/__tests__/useWaypointDrag.test.ts b/packages/react/src/hooks/__tests__/useWaypointDrag.test.ts new file mode 100644 index 0000000..3d63b20 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useWaypointDrag.test.ts @@ -0,0 +1,223 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { useWaypointDrag } from '../useWaypointDrag.js' +import type { ViewportState } from 'system-canvas' + +function makeViewportRef(): React.RefObject { + return { current: { x: 0, y: 0, zoom: 1 } } +} + +function makePointerEvent( + overrides: Partial<{ clientX: number; clientY: number; pointerId: number }> = {} +): React.PointerEvent { + const el = document.createElement('circle') + el.setPointerCapture = vi.fn() + el.closest = vi.fn(() => null) // no SVG parent + return { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + currentTarget: el, + stopPropagation: vi.fn(), + preventDefault: vi.fn(), + ...overrides, + } as unknown as React.PointerEvent +} + +describe('useWaypointDrag', () => { + it('returns the expected API surface', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + + expect(result.current.overrides).toBeInstanceOf(Map) + expect(typeof result.current.onHandlePointerDown).toBe('function') + expect(typeof result.current.onHandleDoubleClick).toBe('function') + expect(typeof result.current.onGhostClick).toBe('function') + }) + + // --------------------------------------------------------------------------- + // onHandleDoubleClick + // --------------------------------------------------------------------------- + + it('onHandleDoubleClick with 2 waypoints at index 0 → onCommit called with 1-element array', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + + const waypoints = [ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ] + + act(() => { + result.current.onHandleDoubleClick('edge-1', waypoints, 0) + }) + + expect(onCommit).toHaveBeenCalledOnce() + const [edgeId, wps] = onCommit.mock.calls[0] + expect(edgeId).toBe('edge-1') + expect(wps).toHaveLength(1) + expect(wps[0]).toEqual({ x: 30, y: 40 }) + }) + + it('onHandleDoubleClick at index 1 of 2 waypoints removes second waypoint', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + + const waypoints = [ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ] + + act(() => { + result.current.onHandleDoubleClick('edge-1', waypoints, 1) + }) + + expect(onCommit).toHaveBeenCalledOnce() + const [, wps] = onCommit.mock.calls[0] + expect(wps).toHaveLength(1) + expect(wps[0]).toEqual({ x: 10, y: 20 }) + }) + + it('onHandleDoubleClick with 1 waypoint results in empty array (reverts to auto-routing)', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + + act(() => { + result.current.onHandleDoubleClick('edge-1', [{ x: 50, y: 50 }], 0) + }) + + expect(onCommit).toHaveBeenCalledOnce() + const [, wps] = onCommit.mock.calls[0] + expect(wps).toHaveLength(0) + }) + + // --------------------------------------------------------------------------- + // onGhostClick + // --------------------------------------------------------------------------- + + it('onGhostClick with insertAfterIndex=-1 → onCommit called with 1-element array at ghost position', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + + const ghostPos = { x: 200, y: 150 } + + act(() => { + result.current.onGhostClick('edge-2', [], -1, ghostPos) + }) + + expect(onCommit).toHaveBeenCalledOnce() + const [edgeId, wps] = onCommit.mock.calls[0] + expect(edgeId).toBe('edge-2') + expect(wps).toHaveLength(1) + expect(wps[0]).toEqual({ x: 200, y: 150 }) + }) + + it('onGhostClick inserts at the correct position within existing waypoints', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + + const existingWaypoints = [ + { x: 10, y: 10 }, + { x: 30, y: 30 }, + ] + const ghostPos = { x: 20, y: 20 } + + // Insert between index 0 and 1 (insertAfterIndex = 0) + act(() => { + result.current.onGhostClick('edge-3', existingWaypoints, 0, ghostPos) + }) + + expect(onCommit).toHaveBeenCalledOnce() + const [, wps] = onCommit.mock.calls[0] + expect(wps).toHaveLength(3) + expect(wps[0]).toEqual({ x: 10, y: 10 }) + expect(wps[1]).toEqual({ x: 20, y: 20 }) + expect(wps[2]).toEqual({ x: 30, y: 30 }) + }) + + it('onGhostClick with insertAfterIndex equal to last index appends to the end', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + + const existingWaypoints = [{ x: 10, y: 10 }] + const ghostPos = { x: 50, y: 50 } + + act(() => { + result.current.onGhostClick('edge-4', existingWaypoints, 0, ghostPos) + }) + + const [, wps] = onCommit.mock.calls[0] + expect(wps).toHaveLength(2) + expect(wps[0]).toEqual({ x: 10, y: 10 }) + expect(wps[1]).toEqual({ x: 50, y: 50 }) + }) + + // --------------------------------------------------------------------------- + // onHandlePointerDown + // --------------------------------------------------------------------------- + + it('onHandlePointerDown sets up overrides on pointermove and commits on pointerup', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + + const waypoints = [{ x: 100, y: 100 }] + const event = makePointerEvent({ clientX: 100, clientY: 100, pointerId: 5 }) + + act(() => { + result.current.onHandlePointerDown('edge-5', waypoints, 0, event) + }) + + // Simulate pointermove + act(() => { + const moveEvent = new PointerEvent('pointermove', { + pointerId: 5, + clientX: 110, + clientY: 120, + bubbles: true, + }) + window.dispatchEvent(moveEvent) + }) + + // Override should be set + expect(result.current.overrides.size).toBeGreaterThan(0) + + // Simulate pointerup + act(() => { + const upEvent = new PointerEvent('pointerup', { + pointerId: 5, + clientX: 110, + clientY: 120, + bubbles: true, + }) + window.dispatchEvent(upEvent) + }) + + // onCommit should be called with updated position + expect(onCommit).toHaveBeenCalledOnce() + const [edgeId, wps] = onCommit.mock.calls[0] + expect(edgeId).toBe('edge-5') + expect(wps).toHaveLength(1) + // Position should be updated (dx=10/zoom=1, dy=20/zoom=1) + expect(wps[0].x).toBeCloseTo(110) + expect(wps[0].y).toBeCloseTo(120) + + // Overrides should be cleared after commit + expect(result.current.overrides.size).toBe(0) + }) + + it('overrides are empty initially', () => { + const onCommit = vi.fn() + const viewport = makeViewportRef() + const { result } = renderHook(() => useWaypointDrag({ viewport, onCommit })) + expect(result.current.overrides.size).toBe(0) + }) +}) diff --git a/packages/react/src/hooks/useWaypointDrag.ts b/packages/react/src/hooks/useWaypointDrag.ts new file mode 100644 index 0000000..1038269 --- /dev/null +++ b/packages/react/src/hooks/useWaypointDrag.ts @@ -0,0 +1,191 @@ +import { useCallback, useRef, useState } from 'react' +import type { ViewportState } from 'system-canvas' +import { screenToCanvas } from 'system-canvas' + +interface UseWaypointDragOptions { + viewport: React.RefObject + onCommit: (edgeId: string, waypoints: { x: number; y: number }[]) => void +} + +interface UseWaypointDragResult { + /** Live position overrides during drag — Map */ + overrides: Map + onHandlePointerDown: ( + edgeId: string, + waypoints: { x: number; y: number }[], + index: number, + event: React.PointerEvent + ) => void + onHandleDoubleClick: ( + edgeId: string, + waypoints: { x: number; y: number }[], + index: number + ) => void + onGhostClick: ( + edgeId: string, + waypoints: { x: number; y: number }[], + insertAfterIndex: number, + pos: { x: number; y: number } + ) => void +} + +interface DragState { + edgeId: string + waypoints: { x: number; y: number }[] + index: number + pointerId: number + startClientX: number + startClientY: number + startCanvasX: number + startCanvasY: number +} + +/** + * Pointer-capture drag hook for waypoint handles on edges. + * Follows the same pattern as useNodeDrag / useNodeResize. + */ +export function useWaypointDrag( + options: UseWaypointDragOptions +): UseWaypointDragResult { + const { viewport, onCommit } = options + + const [overrides, setOverrides] = useState>( + () => new Map() + ) + + const stateRef = useRef(null) + + const onPointerMoveRef = useRef<((e: PointerEvent) => void) | null>(null) + const onPointerUpRef = useRef<((e: PointerEvent) => void) | null>(null) + + const cleanup = useCallback(() => { + if (onPointerMoveRef.current) { + window.removeEventListener('pointermove', onPointerMoveRef.current) + onPointerMoveRef.current = null + } + if (onPointerUpRef.current) { + window.removeEventListener('pointerup', onPointerUpRef.current) + onPointerUpRef.current = null + } + }, []) + + const onHandlePointerDown = useCallback( + ( + edgeId: string, + waypoints: { x: number; y: number }[], + index: number, + event: React.PointerEvent + ) => { + event.stopPropagation() + event.preventDefault() + + const vp = viewport.current ?? { x: 0, y: 0, zoom: 1 } + const svgEl = (event.currentTarget as Element).closest('svg') + const rect = svgEl?.getBoundingClientRect() + const canvasPos = rect + ? screenToCanvas(event.clientX - rect.left, event.clientY - rect.top, vp) + : { x: waypoints[index].x, y: waypoints[index].y } + + stateRef.current = { + edgeId, + waypoints: [...waypoints], + index, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startCanvasX: canvasPos.x, + startCanvasY: canvasPos.y, + } + + try { + ;(event.currentTarget as Element).setPointerCapture(event.pointerId) + } catch { + // ignore + } + + const onPointerMove = (e: PointerEvent) => { + const st = stateRef.current + if (!st || e.pointerId !== st.pointerId) return + + const currentVp = viewport.current ?? { x: 0, y: 0, zoom: 1 } + const dxScreen = e.clientX - st.startClientX + const dyScreen = e.clientY - st.startClientY + const zoom = currentVp.zoom + const dx = dxScreen / zoom + const dy = dyScreen / zoom + + const newX = st.startCanvasX + dx + const newY = st.startCanvasY + dy + + setOverrides((prev) => { + const next = new Map(prev) + next.set(st.index, { x: newX, y: newY }) + return next + }) + } + + const onPointerUp = (e: PointerEvent) => { + const st = stateRef.current + if (!st || e.pointerId !== st.pointerId) return + + cleanup() + + const currentVp = viewport.current ?? { x: 0, y: 0, zoom: 1 } + const dxScreen = e.clientX - st.startClientX + const dyScreen = e.clientY - st.startClientY + const zoom = currentVp.zoom + const dx = dxScreen / zoom + const dy = dyScreen / zoom + + const newX = st.startCanvasX + dx + const newY = st.startCanvasY + dy + + const merged = st.waypoints.map((wp, i) => + i === st.index ? { x: newX, y: newY } : wp + ) + + stateRef.current = null + setOverrides(new Map()) + onCommit(st.edgeId, merged) + } + + onPointerMoveRef.current = onPointerMove + onPointerUpRef.current = onPointerUp + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + }, + [viewport, onCommit, cleanup] + ) + + const onHandleDoubleClick = useCallback( + ( + edgeId: string, + waypoints: { x: number; y: number }[], + index: number + ) => { + const filtered = waypoints.filter((_, i) => i !== index) + onCommit(edgeId, filtered) + }, + [onCommit] + ) + + const onGhostClick = useCallback( + ( + edgeId: string, + waypoints: { x: number; y: number }[], + insertAfterIndex: number, + pos: { x: number; y: number } + ) => { + const insertAt = insertAfterIndex + 1 + const next = [ + ...waypoints.slice(0, insertAt), + { x: pos.x, y: pos.y }, + ...waypoints.slice(insertAt), + ] + onCommit(edgeId, next) + }, + [onCommit] + ) + + return { overrides, onHandlePointerDown, onHandleDoubleClick, onGhostClick } +}