feat(web/mazenet): interaction layer — pan, drag, resize, reparent
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { forwardRef, useMemo } from 'react';
|
||||||
import NetBox from './NetBox';
|
import NetBox from './NetBox';
|
||||||
import NodeCard from './NodeCard';
|
import NodeCard from './NodeCard';
|
||||||
import type { Net, MazeNode, Edge } from './types';
|
import type { Net, MazeNode, Edge } from './types';
|
||||||
import type { Selection } from './Inspector';
|
import type { Selection } from './Inspector';
|
||||||
|
import type { ResizeHandle } from './useMazeInteraction';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nets: Net[];
|
nets: Net[];
|
||||||
@@ -10,13 +11,23 @@ interface Props {
|
|||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
selection: Selection;
|
selection: Selection;
|
||||||
setSelection: (s: Selection) => void;
|
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_W = 140;
|
||||||
const NODE_HEAD_H = 22;
|
const NODE_HEAD_H = 22;
|
||||||
|
|
||||||
const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection, pan = { x: 0, y: 0 } }) => {
|
const Canvas = forwardRef<HTMLDivElement, Props>(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 netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
|
||||||
|
|
||||||
const absPos = (node: MazeNode) => {
|
const absPos = (node: MazeNode) => {
|
||||||
@@ -24,7 +35,6 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
|
|||||||
return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y };
|
return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y };
|
||||||
};
|
};
|
||||||
|
|
||||||
/* nets touched by any edge */
|
|
||||||
const activeNetIds = useMemo(() => {
|
const activeNetIds = useMemo(() => {
|
||||||
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
|
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -41,8 +51,13 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
className="maze-canvas-wrap"
|
className="maze-canvas-wrap"
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setSelection(null); }}
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setSelection(null);
|
||||||
|
onCanvasMouseDown(e);
|
||||||
|
}}
|
||||||
|
style={{ cursor: dragging ? 'grabbing' : 'grab' }}
|
||||||
>
|
>
|
||||||
<div className="maze-grid-bg">
|
<div className="maze-grid-bg">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -106,9 +121,11 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
|
|||||||
key={net.id}
|
key={net.id}
|
||||||
net={net}
|
net={net}
|
||||||
selected={net.id === selNetId}
|
selected={net.id === selNetId}
|
||||||
dropTarget={false}
|
dropTarget={dropTargetId === net.id}
|
||||||
inactive={inactive}
|
inactive={inactive}
|
||||||
onSelect={(id) => setSelection({ type: 'net', id })}
|
onSelect={(id) => setSelection({ type: 'net', id })}
|
||||||
|
onHeaderMouseDown={onNetMouseDown}
|
||||||
|
onResizeMouseDown={onNetResizeMouseDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -121,7 +138,9 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
|
|||||||
absX={p.x}
|
absX={p.x}
|
||||||
absY={p.y}
|
absY={p.y}
|
||||||
selected={n.id === selNodeId}
|
selected={n.id === selNodeId}
|
||||||
|
dragging={dragging && n.id === selNodeId}
|
||||||
onSelect={(id) => setSelection({ type: 'node', id })}
|
onSelect={(id) => setSelection({ type: 'node', id })}
|
||||||
|
onMouseDown={onNodeMouseDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -135,6 +154,6 @@ const Canvas: React.FC<Props> = ({ nets, nodes, edges, selection, setSelection,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default Canvas;
|
export default Canvas;
|
||||||
|
|||||||
@@ -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 { useSearchParams } from 'react-router-dom';
|
||||||
import { PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud } from 'lucide-react';
|
import { PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud } from 'lucide-react';
|
||||||
import './MazeNET.css';
|
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 { ServiceDef } from './data';
|
||||||
import type { Net, MazeNode, Edge, PendingChange } from './types';
|
import type { Net, MazeNode, Edge, PendingChange } from './types';
|
||||||
import { useMazeApi } from './useMazeApi';
|
import { useMazeApi } from './useMazeApi';
|
||||||
|
import { useMazeInteraction } from './useMazeInteraction';
|
||||||
|
|
||||||
const MazeNET: React.FC = () => {
|
const MazeNET: React.FC = () => {
|
||||||
const api = useMazeApi();
|
const api = useMazeApi();
|
||||||
@@ -19,12 +20,18 @@ const MazeNET: React.FC = () => {
|
|||||||
const [nets, setNets] = useState<Net[]>(DEMO_NETS);
|
const [nets, setNets] = useState<Net[]>(DEMO_NETS);
|
||||||
const [nodes, setNodes] = useState<MazeNode[]>(DEMO_NODES);
|
const [nodes, setNodes] = useState<MazeNode[]>(DEMO_NODES);
|
||||||
const [edges, setEdges] = useState<Edge[]>(DEMO_EDGES);
|
const [edges, setEdges] = useState<Edge[]>(DEMO_EDGES);
|
||||||
const [pending] = useState<PendingChange[]>([]);
|
const [pending, setPending] = useState<PendingChange[]>([]);
|
||||||
const [selection, setSelection] = useState<Selection>(null);
|
const [selection, setSelection] = useState<Selection>(null);
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(true);
|
const [inspectorOpen, setInspectorOpen] = useState(true);
|
||||||
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
||||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(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). */
|
/* Load service catalog from API (fall back to defaults if 401/offline). */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -58,8 +65,18 @@ const MazeNET: React.FC = () => {
|
|||||||
setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES);
|
setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES);
|
||||||
}
|
}
|
||||||
setSelection(null);
|
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 (
|
return (
|
||||||
<div className="maze-page">
|
<div className="maze-page">
|
||||||
<div className="maze-page-header">
|
<div className="maze-page-header">
|
||||||
@@ -102,11 +119,19 @@ const MazeNET: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Palette services={services} />
|
<Palette services={services} />
|
||||||
<Canvas
|
<Canvas
|
||||||
|
ref={canvasRef}
|
||||||
nets={nets}
|
nets={nets}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
setSelection={setSelection}
|
setSelection={setSelection}
|
||||||
|
pan={interaction.pan}
|
||||||
|
dropTargetId={interaction.dropTargetId}
|
||||||
|
dragging={interaction.dragging}
|
||||||
|
onCanvasMouseDown={interaction.onCanvasMouseDown}
|
||||||
|
onNodeMouseDown={interaction.onNodeMouseDown}
|
||||||
|
onNetMouseDown={interaction.onNetMouseDown}
|
||||||
|
onNetResizeMouseDown={interaction.onNetResizeMouseDown}
|
||||||
/>
|
/>
|
||||||
{inspectorOpen && (
|
{inspectorOpen && (
|
||||||
<Inspector
|
<Inspector
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Globe, GitMerge } from 'lucide-react';
|
import { Globe, GitMerge } from 'lucide-react';
|
||||||
import type { Net } from './types';
|
import type { Net } from './types';
|
||||||
|
import type { ResizeHandle } from './useMazeInteraction';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
net: Net;
|
net: Net;
|
||||||
@@ -8,10 +9,14 @@ interface Props {
|
|||||||
dropTarget: boolean;
|
dropTarget: boolean;
|
||||||
inactive: boolean;
|
inactive: boolean;
|
||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
|
onHeaderMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
|
onResizeMouseDown?: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NetBox: React.FC<Props> = ({ net, selected, dropTarget, inactive, onSelect, children }) => {
|
const NetBox: React.FC<Props> = ({
|
||||||
|
net, selected, dropTarget, inactive, onSelect, onHeaderMouseDown, onResizeMouseDown, children,
|
||||||
|
}) => {
|
||||||
const classes = [
|
const classes = [
|
||||||
'maze-net-box',
|
'maze-net-box',
|
||||||
net.kind === 'internet' ? 'internet' : '',
|
net.kind === 'internet' ? 'internet' : '',
|
||||||
@@ -23,42 +28,45 @@ const NetBox: React.FC<Props> = ({ net, selected, dropTarget, inactive, onSelect
|
|||||||
const Icon = net.kind === 'internet' ? Globe : GitMerge;
|
const Icon = net.kind === 'internet' ? Globe : GitMerge;
|
||||||
const resizable = net.kind !== 'internet';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classes}
|
className={classes}
|
||||||
style={{ left: net.x, top: net.y, width: net.w, height: net.h }}
|
style={{ left: net.x, top: net.y, width: net.w, height: net.h }}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={handleBoxDown}
|
||||||
if (e.target === e.currentTarget) { e.stopPropagation(); onSelect?.(net.id); }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div className="maze-net-box-head" onMouseDown={handleHeadDown}>
|
||||||
className="maze-net-box-head"
|
|
||||||
onMouseDown={(e) => { e.stopPropagation(); onSelect?.(net.id); }}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
<Icon size={10} />
|
<Icon size={10} />
|
||||||
<span>{net.label}</span>
|
<span>{net.label}</span>
|
||||||
{inactive && (
|
{inactive && (
|
||||||
<span
|
<span className="chip-mini"
|
||||||
className="chip-mini"
|
style={{ marginLeft: 4, borderColor: 'var(--border)', color: 'rgba(255,255,255,0.45)' }}>
|
||||||
style={{ marginLeft: 4, borderColor: 'var(--border)', color: 'rgba(255,255,255,0.45)' }}
|
|
||||||
>
|
|
||||||
INACTIVE
|
INACTIVE
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="cidr">{net.cidr}</span>
|
<span className="cidr">{net.cidr}</span>
|
||||||
</div>
|
</div>
|
||||||
{resizable && (
|
{resizable && onResizeMouseDown && (
|
||||||
<>
|
<>
|
||||||
<div className="net-resize net-resize-e" />
|
<div className="net-resize net-resize-e" onMouseDown={onResizeMouseDown(net.id, 'e')} />
|
||||||
<div className="net-resize net-resize-w" />
|
<div className="net-resize net-resize-w" onMouseDown={onResizeMouseDown(net.id, 'w')} />
|
||||||
<div className="net-resize net-resize-s" />
|
<div className="net-resize net-resize-s" onMouseDown={onResizeMouseDown(net.id, 's')} />
|
||||||
<div className="net-resize net-resize-n" />
|
<div className="net-resize net-resize-n" onMouseDown={onResizeMouseDown(net.id, 'n')} />
|
||||||
<div className="net-resize net-resize-se" />
|
<div className="net-resize net-resize-se" onMouseDown={onResizeMouseDown(net.id, 'se')} />
|
||||||
<div className="net-resize net-resize-sw" />
|
<div className="net-resize net-resize-sw" onMouseDown={onResizeMouseDown(net.id, 'sw')} />
|
||||||
<div className="net-resize net-resize-ne" />
|
<div className="net-resize net-resize-ne" onMouseDown={onResizeMouseDown(net.id, 'ne')} />
|
||||||
<div className="net-resize net-resize-nw" />
|
<div className="net-resize net-resize-nw" onMouseDown={onResizeMouseDown(net.id, 'nw')} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,23 +6,27 @@ interface Props {
|
|||||||
absX: number;
|
absX: number;
|
||||||
absY: number;
|
absY: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
dragging?: boolean;
|
||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
|
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, onSelect }) => {
|
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown }) => {
|
||||||
const classes = [
|
const classes = [
|
||||||
'maze-node',
|
'maze-node',
|
||||||
node.kind === 'observed' ? 'observed' : '',
|
node.kind === 'observed' ? 'observed' : '',
|
||||||
node.status === 'hot' ? 'hot' : '',
|
node.status === 'hot' ? 'hot' : '',
|
||||||
selected ? 'selected' : '',
|
selected ? 'selected' : '',
|
||||||
|
dragging ? 'dragging' : '',
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const handleDown = (e: React.MouseEvent) => {
|
||||||
|
onSelect?.(node.id);
|
||||||
|
onMouseDown?.(node.id)(e);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={classes} style={{ left: absX, top: absY }} onMouseDown={handleDown}>
|
||||||
className={classes}
|
|
||||||
style={{ left: absX, top: absY }}
|
|
||||||
onMouseDown={(e) => { e.stopPropagation(); onSelect?.(node.id); }}
|
|
||||||
>
|
|
||||||
<div className="mn-head">{node.name}</div>
|
<div className="mn-head">{node.name}</div>
|
||||||
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
|
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
|
||||||
{node.services.length > 0 && (
|
{node.services.length > 0 && (
|
||||||
|
|||||||
206
decnet_web/src/components/MazeNET/useMazeInteraction.ts
Normal file
206
decnet_web/src/components/MazeNET/useMazeInteraction.ts
Normal file
@@ -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<React.SetStateAction<Net[]>>;
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>;
|
||||||
|
applyChange: (pc: PendingChange) => void;
|
||||||
|
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }: Args) {
|
||||||
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||||
|
const [drag, setDrag] = useState<Drag>(null);
|
||||||
|
const [dropTargetId, setDropTargetId] = useState<string | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user