From 0401cccd1d142a53a859aa5d140c90b1b9e692d9 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 19:22:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(web/mazenet):=20interaction=20layer=20?= =?UTF-8?q?=E2=80=94=20pan,=20drag,=20resize,=20reparent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- decnet_web/src/components/MazeNET/Canvas.tsx | 33 ++- decnet_web/src/components/MazeNET/MazeNET.tsx | 29 ++- decnet_web/src/components/MazeNET/NetBox.tsx | 50 +++-- .../src/components/MazeNET/NodeCard.tsx | 16 +- .../components/MazeNET/useMazeInteraction.ts | 206 ++++++++++++++++++ 5 files changed, 298 insertions(+), 36 deletions(-) create mode 100644 decnet_web/src/components/MazeNET/useMazeInteraction.ts diff --git a/decnet_web/src/components/MazeNET/Canvas.tsx b/decnet_web/src/components/MazeNET/Canvas.tsx index 4fdb1159..025f0629 100644 --- a/decnet_web/src/components/MazeNET/Canvas.tsx +++ b/decnet_web/src/components/MazeNET/Canvas.tsx @@ -1,8 +1,9 @@ -import React, { useMemo } from 'react'; +import React, { forwardRef, useMemo } from 'react'; import NetBox from './NetBox'; import NodeCard from './NodeCard'; import type { Net, MazeNode, Edge } from './types'; import type { Selection } from './Inspector'; +import type { ResizeHandle } from './useMazeInteraction'; interface Props { nets: Net[]; @@ -10,13 +11,23 @@ interface Props { edges: Edge[]; selection: Selection; setSelection: (s: Selection) => void; - pan?: { x: number; y: number }; + pan: { x: number; y: number }; + dropTargetId: string | null; + dragging: boolean; + onCanvasMouseDown: (e: React.MouseEvent) => void; + onNodeMouseDown: (id: string) => (e: React.MouseEvent) => void; + onNetMouseDown: (id: string) => (e: React.MouseEvent) => void; + onNetResizeMouseDown: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void; } const NODE_W = 140; const NODE_HEAD_H = 22; -const Canvas: React.FC = ({ nets, nodes, edges, selection, setSelection, pan = { x: 0, y: 0 } }) => { +const Canvas = forwardRef(function Canvas( + { nets, nodes, edges, selection, setSelection, pan, dropTargetId, dragging, + onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown }, + ref, +) { const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]); const absPos = (node: MazeNode) => { @@ -24,7 +35,6 @@ const Canvas: React.FC = ({ nets, nodes, edges, selection, setSelection, return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y }; }; - /* nets touched by any edge */ const activeNetIds = useMemo(() => { const nodeNet = new Map(nodes.map((n) => [n.id, n.netId])); const ids = new Set(); @@ -41,8 +51,13 @@ const Canvas: React.FC = ({ nets, nodes, edges, selection, setSelection, return (
{ if (e.target === e.currentTarget) setSelection(null); }} + onMouseDown={(e) => { + if (e.target === e.currentTarget) setSelection(null); + onCanvasMouseDown(e); + }} + style={{ cursor: dragging ? 'grabbing' : 'grab' }} >
@@ -106,9 +121,11 @@ const Canvas: React.FC = ({ nets, nodes, edges, selection, setSelection, key={net.id} net={net} selected={net.id === selNetId} - dropTarget={false} + dropTarget={dropTargetId === net.id} inactive={inactive} onSelect={(id) => setSelection({ type: 'net', id })} + onHeaderMouseDown={onNetMouseDown} + onResizeMouseDown={onNetResizeMouseDown} /> ); })} @@ -121,7 +138,9 @@ const Canvas: React.FC = ({ nets, nodes, edges, selection, setSelection, absX={p.x} absY={p.y} selected={n.id === selNodeId} + dragging={dragging && n.id === selNodeId} onSelect={(id) => setSelection({ type: 'node', id })} + onMouseDown={onNodeMouseDown} /> ); })} @@ -135,6 +154,6 @@ const Canvas: React.FC = ({ nets, nodes, edges, selection, setSelection,
); -}; +}); export default Canvas; diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 4a3249d7..919ab48b 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud } from 'lucide-react'; import './MazeNET.css'; @@ -10,6 +10,7 @@ import { DEFAULT_SERVICES, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data'; import type { ServiceDef } from './data'; import type { Net, MazeNode, Edge, PendingChange } from './types'; import { useMazeApi } from './useMazeApi'; +import { useMazeInteraction } from './useMazeInteraction'; const MazeNET: React.FC = () => { const api = useMazeApi(); @@ -19,12 +20,18 @@ const MazeNET: React.FC = () => { const [nets, setNets] = useState(DEMO_NETS); const [nodes, setNodes] = useState(DEMO_NODES); const [edges, setEdges] = useState(DEMO_EDGES); - const [pending] = useState([]); + const [pending, setPending] = useState([]); const [selection, setSelection] = useState(null); const [inspectorOpen, setInspectorOpen] = useState(true); const [services, setServices] = useState(DEFAULT_SERVICES); const [loadErr, setLoadErr] = useState(null); + const canvasRef = useRef(null); + const applyChange = useCallback((pc: PendingChange) => { + setPending((p) => [...p, pc]); + }, []); + const interaction = useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }); + /* Load service catalog from API (fall back to defaults if 401/offline). */ useEffect(() => { let cancelled = false; @@ -58,8 +65,18 @@ const MazeNET: React.FC = () => { setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES); } setSelection(null); + setPending([]); + interaction.resetPan(); }; + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setSelection(null); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + return (
@@ -102,11 +119,19 @@ const MazeNET: React.FC = () => { > {inspectorOpen && ( void; + onHeaderMouseDown?: (id: string) => (e: React.MouseEvent) => void; + onResizeMouseDown?: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void; children?: React.ReactNode; } -const NetBox: React.FC = ({ net, selected, dropTarget, inactive, onSelect, children }) => { +const NetBox: React.FC = ({ + net, selected, dropTarget, inactive, onSelect, onHeaderMouseDown, onResizeMouseDown, children, +}) => { const classes = [ 'maze-net-box', net.kind === 'internet' ? 'internet' : '', @@ -23,42 +28,45 @@ const NetBox: React.FC = ({ net, selected, dropTarget, inactive, onSelect const Icon = net.kind === 'internet' ? Globe : GitMerge; const resizable = net.kind !== 'internet'; + const handleBoxDown = (e: React.MouseEvent) => { + if (e.target !== e.currentTarget) return; + onSelect?.(net.id); + }; + + const handleHeadDown = (e: React.MouseEvent) => { + onSelect?.(net.id); + onHeaderMouseDown?.(net.id)(e); + }; + return (
{ - if (e.target === e.currentTarget) { e.stopPropagation(); onSelect?.(net.id); } - }} + onMouseDown={handleBoxDown} > -
{ e.stopPropagation(); onSelect?.(net.id); }} - > +
{net.label} {inactive && ( - + INACTIVE )}
{net.cidr}
- {resizable && ( + {resizable && onResizeMouseDown && ( <> -
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
)} {children} diff --git a/decnet_web/src/components/MazeNET/NodeCard.tsx b/decnet_web/src/components/MazeNET/NodeCard.tsx index 07f9e1e3..58fbe0d7 100644 --- a/decnet_web/src/components/MazeNET/NodeCard.tsx +++ b/decnet_web/src/components/MazeNET/NodeCard.tsx @@ -6,23 +6,27 @@ interface Props { absX: number; absY: number; selected: boolean; + dragging?: boolean; onSelect?: (id: string) => void; + onMouseDown?: (id: string) => (e: React.MouseEvent) => void; } -const NodeCard: React.FC = ({ node, absX, absY, selected, onSelect }) => { +const NodeCard: React.FC = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown }) => { const classes = [ 'maze-node', node.kind === 'observed' ? 'observed' : '', node.status === 'hot' ? 'hot' : '', selected ? 'selected' : '', + dragging ? 'dragging' : '', ].filter(Boolean).join(' '); + const handleDown = (e: React.MouseEvent) => { + onSelect?.(node.id); + onMouseDown?.(node.id)(e); + }; + return ( -
{ e.stopPropagation(); onSelect?.(node.id); }} - > +
{node.name}
{node.archetype.toUpperCase()}
{node.services.length > 0 && ( diff --git a/decnet_web/src/components/MazeNET/useMazeInteraction.ts b/decnet_web/src/components/MazeNET/useMazeInteraction.ts new file mode 100644 index 00000000..50f6553d --- /dev/null +++ b/decnet_web/src/components/MazeNET/useMazeInteraction.ts @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Net, MazeNode, PendingChange } from './types'; + +export type ResizeHandle = 'e' | 'w' | 'n' | 's' | 'ne' | 'nw' | 'se' | 'sw'; + +type Drag = + | null + | { type: 'pan'; startX: number; startY: number; panX: number; panY: number } + | { type: 'node'; id: string; offX: number; offY: number } + | { type: 'net'; id: string; offX: number; offY: number } + | { type: 'resize'; id: string; handle: ResizeHandle; startX: number; startY: number; start: Net }; + +interface Args { + nets: Net[]; + nodes: MazeNode[]; + setNets: React.Dispatch>; + setNodes: React.Dispatch>; + applyChange: (pc: PendingChange) => void; + canvasRef: React.RefObject; +} + +export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }: Args) { + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [drag, setDrag] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); + + /* Refs to avoid re-binding global listeners on every state change. */ + const netsRef = useRef(nets); + const nodesRef = useRef(nodes); + const panRef = useRef(pan); + const dragRef = useRef(drag); + useEffect(() => { netsRef.current = nets; }, [nets]); + useEffect(() => { nodesRef.current = nodes; }, [nodes]); + useEffect(() => { panRef.current = pan; }, [pan]); + useEffect(() => { dragRef.current = drag; }, [drag]); + + const canvasOriginRef = useRef(() => { + const r = canvasRef.current?.getBoundingClientRect(); + return { x: r?.left ?? 0, y: r?.top ?? 0 }; + }); + + /* World-space coords from a client event (applies pan inverse). */ + const toWorld = useCallback((clientX: number, clientY: number) => { + const o = canvasOriginRef.current(); + const p = panRef.current; + return { x: clientX - o.x - p.x, y: clientY - o.y - p.y }; + }, []); + + /* ── Mousedown dispatchers ────────────────────────────── */ + + const onCanvasMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + if (e.target !== e.currentTarget) return; + setDrag({ type: 'pan', startX: e.clientX, startY: e.clientY, panX: panRef.current.x, panY: panRef.current.y }); + }, []); + + const onNodeMouseDown = useCallback((id: string) => (e: React.MouseEvent) => { + if (e.button !== 0) return; + e.stopPropagation(); + const node = nodesRef.current.find((n) => n.id === id); + if (!node) return; + const net = netsRef.current.find((nn) => nn.id === node.netId); + if (!net) return; + const w = toWorld(e.clientX, e.clientY); + setDrag({ type: 'node', id, offX: w.x - (net.x + node.x), offY: w.y - (net.y + node.y) }); + }, [toWorld]); + + const onNetMouseDown = useCallback((id: string) => (e: React.MouseEvent) => { + if (e.button !== 0) return; + e.stopPropagation(); + const net = netsRef.current.find((n) => n.id === id); + if (!net) return; + const w = toWorld(e.clientX, e.clientY); + setDrag({ type: 'net', id, offX: w.x - net.x, offY: w.y - net.y }); + }, [toWorld]); + + const onNetResizeMouseDown = useCallback((id: string, handle: ResizeHandle) => (e: React.MouseEvent) => { + if (e.button !== 0) return; + e.stopPropagation(); + const net = netsRef.current.find((n) => n.id === id); + if (!net) return; + setDrag({ type: 'resize', id, handle, startX: e.clientX, startY: e.clientY, start: { ...net } }); + }, []); + + /* ── Global mousemove / mouseup ───────────────────────── */ + + useEffect(() => { + const onMove = (e: MouseEvent) => { + const d = dragRef.current; + if (!d) return; + + if (d.type === 'pan') { + setPan({ x: d.panX + (e.clientX - d.startX), y: d.panY + (e.clientY - d.startY) }); + return; + } + + const w = (() => { + const o = canvasOriginRef.current(); + const p = panRef.current; + return { x: e.clientX - o.x - p.x, y: e.clientY - o.y - p.y }; + })(); + + if (d.type === 'net') { + setNets((prev) => prev.map((n) => n.id === d.id ? { ...n, x: Math.round(w.x - d.offX), y: Math.round(w.y - d.offY) } : n)); + return; + } + + if (d.type === 'node') { + const node = nodesRef.current.find((n) => n.id === d.id); + if (!node) return; + const isObserved = node.kind === 'observed'; + const targetNet = !isObserved ? netsRef.current.find((net) => { + if (net.id === node.netId) return false; + return w.x >= net.x && w.x <= net.x + net.w && w.y >= net.y && w.y <= net.y + net.h; + }) : undefined; + setDropTargetId(targetNet?.id ?? null); + + const parent = netsRef.current.find((n) => n.id === node.netId); + if (!parent) return; + const nx = Math.max(8, Math.round(w.x - d.offX - parent.x)); + const ny = Math.max(28, Math.round(w.y - d.offY - parent.y)); + setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, x: nx, y: ny } : n)); + return; + } + + if (d.type === 'resize') { + const dx = e.clientX - d.startX; + const dy = e.clientY - d.startY; + setNets((prev) => prev.map((n) => { + if (n.id !== d.id) return n; + let { x, y, w: width, h: height } = d.start; + const MIN_W = 220, MIN_H = 140; + if (d.handle.includes('e')) width = Math.max(MIN_W, d.start.w + dx); + if (d.handle.includes('s')) height = Math.max(MIN_H, d.start.h + dy); + if (d.handle.includes('w')) { + width = Math.max(MIN_W, d.start.w - dx); + x = d.start.x + (d.start.w - width); + } + if (d.handle.includes('n')) { + height = Math.max(MIN_H, d.start.h - dy); + y = d.start.y + (d.start.h - height); + } + return { ...n, x, y, w: width, h: height }; + })); + return; + } + }; + + const onUp = () => { + const d = dragRef.current; + if (!d) return; + + if (d.type === 'node') { + const node = nodesRef.current.find((n) => n.id === d.id); + const target = dropTargetId; + if (node && node.kind === 'decky' && target && target !== node.netId) { + const parentOld = netsRef.current.find((nn) => nn.id === node.netId); + const parentNew = netsRef.current.find((nn) => nn.id === target); + if (parentOld && parentNew) { + const absX = parentOld.x + node.x; + const absY = parentOld.y + node.y; + const relX = Math.max(8, absX - parentNew.x); + const relY = Math.max(28, absY - parentNew.y); + setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, netId: target, x: relX, y: relY } : n)); + applyChange({ op: 'detach_decky', payload: { nodeId: d.id, netId: node.netId } }); + applyChange({ op: 'attach_decky', payload: { + nodeId: d.id, netId: target, archetype: node.archetype, name: node.name, + x: relX, y: relY, services: node.services, + }}); + } + } else if (node && node.kind === 'decky') { + applyChange({ op: 'update_decky', payload: { nodeId: node.id, patch: { x: node.x, y: node.y } } }); + } + } else if (d.type === 'net') { + const net = netsRef.current.find((n) => n.id === d.id); + if (net) applyChange({ op: 'update_lan', payload: { id: net.id, patch: { x: net.x, y: net.y } } }); + } else if (d.type === 'resize') { + const net = netsRef.current.find((n) => n.id === d.id); + if (net) applyChange({ op: 'update_lan', payload: { id: net.id, patch: { x: net.x, y: net.y, w: net.w, h: net.h } } }); + } + + setDropTargetId(null); + setDrag(null); + }; + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [applyChange, setNets, setNodes, dropTargetId]); + + const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []); + + return { + pan, + dropTargetId, + dragging: drag !== null, + onCanvasMouseDown, + onNodeMouseDown, + onNetMouseDown, + onNetResizeMouseDown, + resetPan, + }; +}