feat(web/mazenet): port-drag edges, context menus, delete actions
This commit is contained in:
@@ -14,18 +14,25 @@ interface Props {
|
|||||||
pan: { x: number; y: number };
|
pan: { x: number; y: number };
|
||||||
dropTargetId: string | null;
|
dropTargetId: string | null;
|
||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
|
edgeDraw: { fromX: number; fromY: number; toX: number; toY: number; hoverTarget: string | null } | null;
|
||||||
onCanvasMouseDown: (e: React.MouseEvent) => void;
|
onCanvasMouseDown: (e: React.MouseEvent) => void;
|
||||||
onNodeMouseDown: (id: string) => (e: React.MouseEvent) => void;
|
onNodeMouseDown: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onNetMouseDown: (id: string) => (e: React.MouseEvent) => void;
|
onNetMouseDown: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onNetResizeMouseDown: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
|
onNetResizeMouseDown: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
|
||||||
|
onPortMouseDown: (id: string) => (e: React.MouseEvent) => void;
|
||||||
|
onNodeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
|
onNetContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
|
onEdgeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
|
onCanvasContextMenu?: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NODE_W = 140;
|
const NODE_W = 140;
|
||||||
const NODE_HEAD_H = 22;
|
const NODE_HEAD_H = 22;
|
||||||
|
|
||||||
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
||||||
{ nets, nodes, edges, selection, setSelection, pan, dropTargetId, dragging,
|
{ nets, nodes, edges, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
|
||||||
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown },
|
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
||||||
|
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu },
|
||||||
ref,
|
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]);
|
||||||
@@ -57,6 +64,9 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
if (e.target === e.currentTarget) setSelection(null);
|
if (e.target === e.currentTarget) setSelection(null);
|
||||||
onCanvasMouseDown(e);
|
onCanvasMouseDown(e);
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (e.target === e.currentTarget && onCanvasContextMenu) onCanvasContextMenu(e);
|
||||||
|
}}
|
||||||
style={{ cursor: dragging ? 'grabbing' : 'grab' }}
|
style={{ cursor: dragging ? 'grabbing' : 'grab' }}
|
||||||
>
|
>
|
||||||
<div className="maze-grid-bg">
|
<div className="maze-grid-bg">
|
||||||
@@ -97,7 +107,8 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
const isSel = e.id === selEdgeId;
|
const isSel = e.id === selEdgeId;
|
||||||
return (
|
return (
|
||||||
<g key={e.id} style={{ pointerEvents: 'auto' }}
|
<g key={e.id} style={{ pointerEvents: 'auto' }}
|
||||||
onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}>
|
onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}
|
||||||
|
onContextMenu={onEdgeContextMenu?.(e.id)}>
|
||||||
<path d={d} className={`maze-edge ${klass} maze-edge-dash`} markerEnd={`url(#${marker})`}
|
<path d={d} className={`maze-edge ${klass} maze-edge-dash`} markerEnd={`url(#${marker})`}
|
||||||
style={{ strokeWidth: isSel ? 2.5 : 1.5 }} />
|
style={{ strokeWidth: isSel ? 2.5 : 1.5 }} />
|
||||||
<path d={d} stroke="transparent" strokeWidth="12" fill="none" style={{ cursor: 'pointer' }} />
|
<path d={d} stroke="transparent" strokeWidth="12" fill="none" style={{ cursor: 'pointer' }} />
|
||||||
@@ -111,6 +122,11 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{edgeDraw && (() => {
|
||||||
|
const cx = (edgeDraw.fromX + edgeDraw.toX) / 2;
|
||||||
|
const d = `M${edgeDraw.fromX},${edgeDraw.fromY} C${cx},${edgeDraw.fromY} ${cx},${edgeDraw.toY} ${edgeDraw.toX},${edgeDraw.toY}`;
|
||||||
|
return <path d={d} className={`ghost-edge ${edgeDraw.hoverTarget ? 'snap' : ''}`} />;
|
||||||
|
})()}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div className="maze-nodes">
|
<div className="maze-nodes">
|
||||||
@@ -126,6 +142,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
onSelect={(id) => setSelection({ type: 'net', id })}
|
onSelect={(id) => setSelection({ type: 'net', id })}
|
||||||
onHeaderMouseDown={onNetMouseDown}
|
onHeaderMouseDown={onNetMouseDown}
|
||||||
onResizeMouseDown={onNetResizeMouseDown}
|
onResizeMouseDown={onNetResizeMouseDown}
|
||||||
|
onContextMenu={onNetContextMenu?.(net.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -141,6 +158,8 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
dragging={dragging && n.id === selNodeId}
|
dragging={dragging && n.id === selNodeId}
|
||||||
onSelect={(id) => setSelection({ type: 'node', id })}
|
onSelect={(id) => setSelection({ type: 'node', id })}
|
||||||
onMouseDown={onNodeMouseDown}
|
onMouseDown={onNodeMouseDown}
|
||||||
|
onPortMouseDown={onPortMouseDown}
|
||||||
|
onContextMenu={onNodeContextMenu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
57
decnet_web/src/components/MazeNET/ContextMenu.tsx
Normal file
57
decnet_web/src/components/MazeNET/ContextMenu.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
separator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: MenuItem[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextMenu: React.FC<Props> = ({ x, y, items, onClose }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="ctx-menu" style={{ left: x, top: y }}>
|
||||||
|
{items.map((it, i) =>
|
||||||
|
it.separator ? (
|
||||||
|
<div key={i} className="ctx-divider" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`ctx-item ${it.danger ? 'danger' : ''}`}
|
||||||
|
disabled={it.disabled}
|
||||||
|
title={it.title}
|
||||||
|
onClick={() => { if (!it.disabled) { it.onClick?.(); onClose(); } }}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContextMenu;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
import type { Net, MazeNode, Edge, PendingChange } from './types';
|
import type { Net, MazeNode, Edge, PendingChange } from './types';
|
||||||
|
|
||||||
export type Selection =
|
export type Selection =
|
||||||
@@ -14,9 +15,12 @@ interface Props {
|
|||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
pending: PendingChange[];
|
pending: PendingChange[];
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
onDeleteNet?: (id: string) => void;
|
||||||
|
onDeleteNode?: (id: string) => void;
|
||||||
|
onDeleteEdge?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Inspector: React.FC<Props> = ({ selection, nets, nodes, edges, pending, onClose }) => {
|
const Inspector: React.FC<Props> = ({ selection, nets, nodes, edges, pending, onClose, onDeleteNet, onDeleteNode, onDeleteEdge }) => {
|
||||||
const net = selection?.type === 'net' ? nets.find((n) => n.id === selection.id) : undefined;
|
const net = selection?.type === 'net' ? nets.find((n) => n.id === selection.id) : undefined;
|
||||||
const node = selection?.type === 'node' ? nodes.find((n) => n.id === selection.id) : undefined;
|
const node = selection?.type === 'node' ? nodes.find((n) => n.id === selection.id) : undefined;
|
||||||
const edge = selection?.type === 'edge' ? edges.find((e) => e.id === selection.id) : undefined;
|
const edge = selection?.type === 'edge' ? edges.find((e) => e.id === selection.id) : undefined;
|
||||||
@@ -40,36 +44,63 @@ const Inspector: React.FC<Props> = ({ selection, nets, nodes, edges, pending, on
|
|||||||
{!selection && <div className="inspector-empty">SELECT AN ELEMENT</div>}
|
{!selection && <div className="inspector-empty">SELECT AN ELEMENT</div>}
|
||||||
|
|
||||||
{net && (
|
{net && (
|
||||||
<div className="kvs">
|
<>
|
||||||
<div className="k">KIND</div> <div className="v">{net.kind.toUpperCase()}</div>
|
<div className="kvs">
|
||||||
<div className="k">LABEL</div> <div className="v">{net.label}</div>
|
<div className="k">KIND</div> <div className="v">{net.kind.toUpperCase()}</div>
|
||||||
<div className="k">CIDR</div> <div className="v">{net.cidr}</div>
|
<div className="k">LABEL</div> <div className="v">{net.label}</div>
|
||||||
<div className="k">MEMBERS</div> <div className="v">
|
<div className="k">CIDR</div> <div className="v">{net.cidr}</div>
|
||||||
{nodes.filter((n) => n.netId === net.id).map((n) => n.name).join(', ') || '—'}
|
<div className="k">MEMBERS</div> <div className="v">
|
||||||
|
{nodes.filter((n) => n.netId === net.id).map((n) => n.name).join(', ') || '—'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{net.kind !== 'internet' && onDeleteNet && (
|
||||||
|
<button type="button" className="maze-btn ghost" onClick={() => onDeleteNet(net.id)}>
|
||||||
|
<Trash2 size={12} /> DELETE NET
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{node && (
|
{node && (
|
||||||
<div className="kvs">
|
<>
|
||||||
<div className="k">KIND</div> <div className="v">{node.kind === 'observed' ? 'OBSERVED' : 'DECKY'}</div>
|
<div className="kvs">
|
||||||
<div className="k">NAME</div> <div className="v">{node.name}</div>
|
<div className="k">KIND</div> <div className="v">{node.kind === 'observed' ? 'OBSERVED' : 'DECKY'}</div>
|
||||||
<div className="k">ARCHETYPE</div> <div className="v">{node.archetype}</div>
|
<div className="k">NAME</div> <div className="v">{node.name}</div>
|
||||||
<div className="k">NET</div> <div className="v">{nets.find((nn) => nn.id === node.netId)?.label ?? node.netId}</div>
|
<div className="k">ARCHETYPE</div> <div className="v">{node.archetype}</div>
|
||||||
<div className="k">SERVICES</div> <div className="v">{node.services.join(', ') || '—'}</div>
|
<div className="k">NET</div> <div className="v">{nets.find((nn) => nn.id === node.netId)?.label ?? node.netId}</div>
|
||||||
<div className="k">STATUS</div> <div className="v">{node.status.toUpperCase()}</div>
|
<div className="k">SERVICES</div> <div className="v">{node.services.join(', ') || '—'}</div>
|
||||||
</div>
|
<div className="k">STATUS</div> <div className="v">{node.status.toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteNode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="maze-btn ghost"
|
||||||
|
disabled={node.kind === 'observed'}
|
||||||
|
title={node.kind === 'observed' ? 'observed entity — not a deployed decky' : 'delete decky'}
|
||||||
|
onClick={() => node.kind === 'decky' && onDeleteNode(node.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} /> DELETE NODE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{edge && (
|
{edge && (
|
||||||
<div className="kvs">
|
<>
|
||||||
<div className="k">FROM</div> <div className="v">{nodes.find((n) => n.id === edge.from)?.name ?? edge.from}</div>
|
<div className="kvs">
|
||||||
<div className="k">TO</div> <div className="v">{nodes.find((n) => n.id === edge.to)?.name ?? edge.to}</div>
|
<div className="k">FROM</div> <div className="v">{nodes.find((n) => n.id === edge.from)?.name ?? edge.from}</div>
|
||||||
<div className="k">TRAFFIC</div> <div className="v">{edge.traffic.toUpperCase()}</div>
|
<div className="k">TO</div> <div className="v">{nodes.find((n) => n.id === edge.to)?.name ?? edge.to}</div>
|
||||||
{edge.label && (<>
|
<div className="k">TRAFFIC</div> <div className="v">{edge.traffic.toUpperCase()}</div>
|
||||||
<div className="k">LABEL</div> <div className="v">{edge.label}</div>
|
{edge.label && (<>
|
||||||
</>)}
|
<div className="k">LABEL</div> <div className="v">{edge.label}</div>
|
||||||
</div>
|
</>)}
|
||||||
|
</div>
|
||||||
|
{onDeleteEdge && (
|
||||||
|
<button type="button" className="maze-btn ghost" onClick={() => onDeleteEdge(edge.id)}>
|
||||||
|
<Trash2 size={12} /> REMOVE EDGE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -277,7 +277,8 @@
|
|||||||
/* ── Context menu ───────────────────────────── */
|
/* ── Context menu ───────────────────────────── */
|
||||||
.ctx-scrim { position: absolute; inset: 0; z-index: 30; }
|
.ctx-scrim { position: absolute; inset: 0; z-index: 30; }
|
||||||
.ctx-menu {
|
.ctx-menu {
|
||||||
position: absolute; z-index: 40;
|
position: fixed; z-index: 1000;
|
||||||
|
width: auto; border-radius: var(--radius-0, 0);
|
||||||
background: var(--panel); border: 1px solid var(--violet);
|
background: var(--panel); border: 1px solid var(--violet);
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.8), var(--violet-glow);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.8), var(--violet-glow);
|
||||||
@@ -289,9 +290,14 @@
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.ctx-item {
|
.ctx-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||||
padding: 7px 12px; font-size: 0.74rem; cursor: pointer; letter-spacing: 0.5px;
|
padding: 7px 12px; font-size: 0.74rem; cursor: pointer; letter-spacing: 0.5px;
|
||||||
|
background: transparent; border: 0; color: var(--matrix); text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
.ctx-item:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.ctx-item:disabled:hover { background: transparent; color: inherit; }
|
||||||
|
.ghost-edge.snap { stroke: var(--matrix); opacity: 0.9; }
|
||||||
.ctx-item:hover { background: var(--violet-tint-10); color: var(--violet); }
|
.ctx-item:hover { background: var(--violet-tint-10); color: var(--violet); }
|
||||||
.ctx-item.danger { color: var(--alert); }
|
.ctx-item.danger { color: var(--alert); }
|
||||||
.ctx-item.danger:hover { background: rgba(255, 65, 65, 0.12); }
|
.ctx-item.danger:hover { background: rgba(255, 65, 65, 0.12); }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Palette from './Palette';
|
|||||||
import Canvas from './Canvas';
|
import Canvas from './Canvas';
|
||||||
import Inspector from './Inspector';
|
import Inspector from './Inspector';
|
||||||
import type { Selection } from './Inspector';
|
import type { Selection } from './Inspector';
|
||||||
|
import ContextMenu, { type MenuItem } from './ContextMenu';
|
||||||
import { DEFAULT_SERVICES, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data';
|
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';
|
||||||
@@ -29,9 +30,103 @@ const MazeNET: React.FC = () => {
|
|||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const applyChange = useCallback((pc: PendingChange) => {
|
const applyChange = useCallback((pc: PendingChange) => {
|
||||||
setPending((p) => [...p, pc]);
|
setPending((p) => [...p, pc]);
|
||||||
|
if (pc.op === 'add_edge') {
|
||||||
|
const payload = pc.payload;
|
||||||
|
setEdges((prev) => prev.some((e) => e.id === payload.id)
|
||||||
|
? prev
|
||||||
|
: [...prev, { id: payload.id, from: payload.from, to: payload.to, traffic: 'active' as const }]);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const interaction = useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef });
|
const interaction = useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef });
|
||||||
|
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
||||||
|
|
||||||
|
const removeNet = (id: string) => {
|
||||||
|
const net = nets.find((n) => n.id === id);
|
||||||
|
if (!net || net.kind === 'internet') return;
|
||||||
|
setNets((p) => p.filter((n) => n.id !== id));
|
||||||
|
setNodes((p) => p.filter((n) => n.netId !== id));
|
||||||
|
setEdges((p) => p.filter((e) => {
|
||||||
|
const a = nodes.find((x) => x.id === e.from)?.netId;
|
||||||
|
const b = nodes.find((x) => x.id === e.to)?.netId;
|
||||||
|
return a !== id && b !== id;
|
||||||
|
}));
|
||||||
|
applyChange({ op: 'remove_lan', payload: { id } });
|
||||||
|
setSelection(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNode = (id: string) => {
|
||||||
|
const node = nodes.find((n) => n.id === id);
|
||||||
|
if (!node || node.kind === 'observed') return;
|
||||||
|
setNodes((p) => p.filter((n) => n.id !== id));
|
||||||
|
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
|
||||||
|
applyChange({ op: 'remove_decky', payload: { nodeId: id } });
|
||||||
|
setSelection(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEdge = (id: string) => {
|
||||||
|
setEdges((p) => p.filter((e) => e.id !== id));
|
||||||
|
applyChange({ op: 'remove_edge', payload: { id } });
|
||||||
|
setSelection(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNodeContextMenu = (id: string) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const node = nodes.find((n) => n.id === id);
|
||||||
|
if (!node) return;
|
||||||
|
setSelection({ type: 'node', id });
|
||||||
|
const isObs = node.kind === 'observed';
|
||||||
|
setCtxMenu({
|
||||||
|
x: e.clientX, y: e.clientY,
|
||||||
|
items: [
|
||||||
|
{ label: 'INSPECT', onClick: () => setSelection({ type: 'node', id }) },
|
||||||
|
{ separator: true, label: '' },
|
||||||
|
{
|
||||||
|
label: 'DELETE NODE',
|
||||||
|
danger: true,
|
||||||
|
disabled: isObs,
|
||||||
|
title: isObs ? 'observed entity — not a deployed decky' : undefined,
|
||||||
|
onClick: () => removeNode(id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNetContextMenu = (id: string) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const net = nets.find((n) => n.id === id);
|
||||||
|
if (!net) return;
|
||||||
|
setSelection({ type: 'net', id });
|
||||||
|
setCtxMenu({
|
||||||
|
x: e.clientX, y: e.clientY,
|
||||||
|
items: [
|
||||||
|
{ label: 'INSPECT', onClick: () => setSelection({ type: 'net', id }) },
|
||||||
|
{ separator: true, label: '' },
|
||||||
|
{
|
||||||
|
label: 'DELETE NET',
|
||||||
|
danger: true,
|
||||||
|
disabled: net.kind === 'internet',
|
||||||
|
title: net.kind === 'internet' ? 'internet zone cannot be removed' : undefined,
|
||||||
|
onClick: () => removeNet(id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEdgeContextMenu = (id: string) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelection({ type: 'edge', id });
|
||||||
|
setCtxMenu({
|
||||||
|
x: e.clientX, y: e.clientY,
|
||||||
|
items: [
|
||||||
|
{ label: 'REMOVE EDGE', danger: true, onClick: () => removeEdge(id) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/* 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;
|
||||||
@@ -128,11 +223,19 @@ const MazeNET: React.FC = () => {
|
|||||||
pan={interaction.pan}
|
pan={interaction.pan}
|
||||||
dropTargetId={interaction.dropTargetId}
|
dropTargetId={interaction.dropTargetId}
|
||||||
dragging={interaction.dragging}
|
dragging={interaction.dragging}
|
||||||
|
edgeDraw={interaction.edgeDraw}
|
||||||
onCanvasMouseDown={interaction.onCanvasMouseDown}
|
onCanvasMouseDown={interaction.onCanvasMouseDown}
|
||||||
onNodeMouseDown={interaction.onNodeMouseDown}
|
onNodeMouseDown={interaction.onNodeMouseDown}
|
||||||
onNetMouseDown={interaction.onNetMouseDown}
|
onNetMouseDown={interaction.onNetMouseDown}
|
||||||
onNetResizeMouseDown={interaction.onNetResizeMouseDown}
|
onNetResizeMouseDown={interaction.onNetResizeMouseDown}
|
||||||
|
onPortMouseDown={interaction.onPortMouseDown}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
|
onNetContextMenu={onNetContextMenu}
|
||||||
|
onEdgeContextMenu={onEdgeContextMenu}
|
||||||
/>
|
/>
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
||||||
|
)}
|
||||||
{inspectorOpen && (
|
{inspectorOpen && (
|
||||||
<Inspector
|
<Inspector
|
||||||
selection={selection}
|
selection={selection}
|
||||||
@@ -141,6 +244,9 @@ const MazeNET: React.FC = () => {
|
|||||||
edges={edges}
|
edges={edges}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onClose={() => setInspectorOpen(false)}
|
onClose={() => setInspectorOpen(false)}
|
||||||
|
onDeleteNet={removeNet}
|
||||||
|
onDeleteNode={removeNode}
|
||||||
|
onDeleteEdge={removeEdge}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ interface Props {
|
|||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
onHeaderMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
onHeaderMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onResizeMouseDown?: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
|
onResizeMouseDown?: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NetBox: React.FC<Props> = ({
|
const NetBox: React.FC<Props> = ({
|
||||||
net, selected, dropTarget, inactive, onSelect, onHeaderMouseDown, onResizeMouseDown, children,
|
net, selected, dropTarget, inactive, onSelect, onHeaderMouseDown, onResizeMouseDown, onContextMenu, children,
|
||||||
}) => {
|
}) => {
|
||||||
const classes = [
|
const classes = [
|
||||||
'maze-net-box',
|
'maze-net-box',
|
||||||
@@ -43,6 +44,7 @@ const NetBox: React.FC<Props> = ({
|
|||||||
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={handleBoxDown}
|
onMouseDown={handleBoxDown}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
>
|
>
|
||||||
<div className="maze-net-box-head" onMouseDown={handleHeadDown}>
|
<div className="maze-net-box-head" onMouseDown={handleHeadDown}>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ interface Props {
|
|||||||
dragging?: boolean;
|
dragging?: boolean;
|
||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
|
onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
|
onContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown }) => {
|
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown, onPortMouseDown, onContextMenu }) => {
|
||||||
const classes = [
|
const classes = [
|
||||||
'maze-node',
|
'maze-node',
|
||||||
node.kind === 'observed' ? 'observed' : '',
|
node.kind === 'observed' ? 'observed' : '',
|
||||||
@@ -26,7 +28,12 @@ const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSel
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes} style={{ left: absX, top: absY }} onMouseDown={handleDown}>
|
<div
|
||||||
|
className={classes}
|
||||||
|
style={{ left: absX, top: absY }}
|
||||||
|
onMouseDown={handleDown}
|
||||||
|
onContextMenu={onContextMenu?.(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 && (
|
||||||
@@ -40,9 +47,11 @@ const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSel
|
|||||||
)}
|
)}
|
||||||
{node.kind === 'decky' && <>
|
{node.kind === 'decky' && <>
|
||||||
<span className="mn-port in" />
|
<span className="mn-port in" />
|
||||||
<span className="mn-port out" />
|
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
|
||||||
</>}
|
</>}
|
||||||
{node.kind === 'observed' && <span className="mn-port out" />}
|
{node.kind === 'observed' && (
|
||||||
|
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,10 +19,20 @@ interface Args {
|
|||||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EdgeDraw {
|
||||||
|
fromId: string;
|
||||||
|
fromX: number; fromY: number;
|
||||||
|
toX: number; toY: number;
|
||||||
|
hoverTarget: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }: Args) {
|
export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }: Args) {
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||||
const [drag, setDrag] = useState<Drag>(null);
|
const [drag, setDrag] = useState<Drag>(null);
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
||||||
|
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
|
||||||
|
const edgeDrawRef = useRef<EdgeDraw | null>(null);
|
||||||
|
useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]);
|
||||||
|
|
||||||
/* Refs to avoid re-binding global listeners on every state change. */
|
/* Refs to avoid re-binding global listeners on every state change. */
|
||||||
const netsRef = useRef(nets);
|
const netsRef = useRef(nets);
|
||||||
@@ -74,6 +84,19 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
setDrag({ type: 'net', id, offX: w.x - net.x, offY: w.y - net.y });
|
setDrag({ type: 'net', id, offX: w.x - net.x, offY: w.y - net.y });
|
||||||
}, [toWorld]);
|
}, [toWorld]);
|
||||||
|
|
||||||
|
const onPortMouseDown = 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 parent = netsRef.current.find((n) => n.id === node.netId);
|
||||||
|
if (!parent) return;
|
||||||
|
const fx = parent.x + node.x + 140;
|
||||||
|
const fy = parent.y + node.y + 22;
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
setEdgeDraw({ fromId: id, fromX: fx, fromY: fy, toX: w.x, toY: w.y, hoverTarget: null });
|
||||||
|
}, [toWorld]);
|
||||||
|
|
||||||
const onNetResizeMouseDown = useCallback((id: string, handle: ResizeHandle) => (e: React.MouseEvent) => {
|
const onNetResizeMouseDown = useCallback((id: string, handle: ResizeHandle) => (e: React.MouseEvent) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -86,6 +109,24 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMove = (e: MouseEvent) => {
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const ed = edgeDrawRef.current;
|
||||||
|
if (ed) {
|
||||||
|
const o = canvasOriginRef.current();
|
||||||
|
const p = panRef.current;
|
||||||
|
const wx = e.clientX - o.x - p.x;
|
||||||
|
const wy = e.clientY - o.y - p.y;
|
||||||
|
const hover = nodesRef.current.find((n) => {
|
||||||
|
if (n.id === ed.fromId) return false;
|
||||||
|
const parent = netsRef.current.find((nn) => nn.id === n.netId);
|
||||||
|
if (!parent) return false;
|
||||||
|
const ax = parent.x + n.x;
|
||||||
|
const ay = parent.y + n.y;
|
||||||
|
return wx >= ax - 8 && wx <= ax + 8 && wy >= ay + 14 && wy <= ay + 30;
|
||||||
|
});
|
||||||
|
setEdgeDraw({ ...ed, toX: wx, toY: wy, hoverTarget: hover?.id ?? null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
|
|
||||||
@@ -147,6 +188,19 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
|
const ed = edgeDrawRef.current;
|
||||||
|
if (ed) {
|
||||||
|
if (ed.hoverTarget && ed.hoverTarget !== ed.fromId) {
|
||||||
|
const target = nodesRef.current.find((n) => n.id === ed.hoverTarget);
|
||||||
|
if (target && target.kind !== 'observed') {
|
||||||
|
const id = `e-${ed.fromId}-${ed.hoverTarget}-${Date.now()}`;
|
||||||
|
applyChange({ op: 'add_edge', payload: { id, from: ed.fromId, to: ed.hoverTarget } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEdgeDraw(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
|
|
||||||
@@ -197,10 +251,12 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
pan,
|
pan,
|
||||||
dropTargetId,
|
dropTargetId,
|
||||||
dragging: drag !== null,
|
dragging: drag !== null,
|
||||||
|
edgeDraw,
|
||||||
onCanvasMouseDown,
|
onCanvasMouseDown,
|
||||||
onNodeMouseDown,
|
onNodeMouseDown,
|
||||||
onNetMouseDown,
|
onNetMouseDown,
|
||||||
onNetResizeMouseDown,
|
onNetResizeMouseDown,
|
||||||
|
onPortMouseDown,
|
||||||
resetPan,
|
resetPan,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user