Skip to content

Commit 31eb52d

Browse files
committed
feat: Improve graph editor UX (selection, modal editing, command bar)
- Selection highlight: clicked node gets a sky-blue ring (stacked outside the start/finish ring); clicked edge turns sky-blue and thicker. - Modal keyboard editing: N add-node, E add-edge (chain through clicked nodes), D delete — all persistent until Esc. On a selected node: S/F/Del; on a selected edge: X reverse (directed) / Del. Disabled React Flow's built-in delete key so deletion goes through one path. - On-canvas command bar is now context-sensitive: shows the available options for the current mode or the selected node/edge. - Menu controls rendered as <kbd> keycaps; per-element actions collapsed to a single "click a Node / Edge for options" pointer to the command bar. - Shared Key component (kbd.jsx).
1 parent 2cca466 commit 31eb52d

6 files changed

Lines changed: 146 additions & 54 deletions

File tree

src/components/graph/floating-edge.jsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function center(node) {
2727
return { x: x + w / 2, y: y + h / 2 };
2828
}
2929

30-
export default function FloatingEdge({ id, source, target, markerEnd, data }) {
30+
export default function FloatingEdge({ id, source, target, markerEnd, data, selected }) {
3131
const setWeight = useContext(EdgeWeightContext);
3232
const [editing, setEditing] = useState(false);
3333
const [draft, setDraft] = useState('');
@@ -60,10 +60,11 @@ export default function FloatingEdge({ id, source, target, markerEnd, data }) {
6060
const ey = to.y - uy * R;
6161
const [path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: ex, targetY: ey });
6262

63-
const stroke = STROKE[state] || STROKE.normal;
64-
// Constant width so the directed arrowhead (sized in stroke-width units)
65-
// doesn't grow during traversal; state is conveyed by color + the mid arrow.
66-
const strokeWidth = 2;
63+
// Selection highlights the edge (sky blue, thicker) for editing; otherwise
64+
// the stroke encodes state at a constant width so the directed arrowhead
65+
// (sized in stroke-width units) doesn't grow during traversal.
66+
const stroke = selected ? '#0ea5e9' : (STROKE[state] || STROKE.normal);
67+
const strokeWidth = selected ? 3.5 : 2;
6768

6869
// only show the direction arrow when a travel direction was set (BFS/DFS/SSSP);
6970
// undirected uses like MST leave travelTo null -> no arrow

src/components/graph/graph-canvas.jsx

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,74 @@ import { ReactFlow, Background, Controls } from '@xyflow/react';
22
import '@xyflow/react/dist/style.css';
33
import GraphNode from './graph-node';
44
import FloatingEdge, { EdgeWeightContext } from './floating-edge';
5+
import { Key } from './kbd';
56

67
// Renders the editable graph for a useGraphEditor() instance: the React Flow
7-
// canvas, the mode-indicator + status overlays, and (for weighted editors) the
8-
// EdgeWeightContext provider that enables inline weight editing.
8+
// canvas, a context-sensitive command bar + status overlay, and (for weighted
9+
// editors) the EdgeWeightContext provider that enables inline weight editing.
910

1011
const nodeTypes = { graphNode: GraphNode };
1112
const edgeTypes = { floating: FloatingEdge };
1213

13-
const baseHint = {
14-
'add-node': 'Add node — click anywhere on the canvas',
15-
'add-edge': 'Add edge — click two nodes',
16-
};
14+
// Build the command bar: a label + a list of {keys?, text} actions, chosen by
15+
// the current mode and what (if anything) is selected.
16+
function buildHint(editor) {
17+
const { mode, directed, weighted } = editor;
18+
if (mode === 'add-node') return { label: 'Add-node', actions: [{ text: 'click to drop nodes' }, { keys: ['Esc'], text: 'exit' }] };
19+
if (mode === 'add-edge') return { label: 'Add-edge', actions: [{ text: 'click nodes to chain' }, { keys: ['Esc'], text: 'exit' }] };
20+
if (mode === 'delete') return { label: 'Delete', actions: [{ text: 'click a node or edge' }, { keys: ['Esc'], text: 'exit' }] };
21+
22+
const selNode = editor.nodes.find((n) => n.selected);
23+
const selEdge = editor.edges.find((e) => e.selected);
24+
if (selNode) {
25+
return {
26+
label: 'Node',
27+
actions: [
28+
{ keys: ['S'], text: 'mark start' },
29+
{ keys: ['F'], text: 'mark finish' },
30+
{ keys: ['Del'], text: 'delete' },
31+
{ keys: ['Esc'], text: 'cancel' },
32+
],
33+
};
34+
}
35+
if (selEdge) {
36+
return {
37+
label: 'Edge',
38+
actions: [
39+
...(directed ? [{ keys: ['X'], text: 'reverse' }] : []),
40+
{ keys: ['Del'], text: 'delete' },
41+
{ keys: ['Esc'], text: 'cancel' },
42+
],
43+
};
44+
}
45+
return {
46+
label: 'Select',
47+
actions: [
48+
{ keys: ['N'], text: 'add node' },
49+
{ keys: ['E'], text: 'add edge' },
50+
{ keys: ['D'], text: 'delete' },
51+
...(weighted ? [{ text: '· click a weight to edit' }] : []),
52+
],
53+
};
54+
}
1755

1856
export default function GraphCanvas({ editor }) {
19-
const idleHint = editor.weighted
20-
? 'Click a node to select · N add node · E add edge · click a weight to edit'
21-
: 'Click a node to select · N add node · E add edge';
22-
const hint = baseHint[editor.mode] || idleHint;
57+
const hint = buildHint(editor);
2358

2459
return (
2560
<div className="relative flex-1">
26-
<div className="absolute top-3 left-3 z-10 flex flex-col gap-2">
27-
<div className="rounded-md bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow">
28-
{hint}
61+
<div className="absolute top-3 left-3 z-10 flex flex-col items-start gap-2">
62+
<div className="flex items-center gap-2 rounded-md bg-white/95 px-3 py-1.5 text-xs text-gray-700 shadow">
63+
<span className="font-semibold text-gray-800">{hint.label}</span>
64+
{hint.actions.map((a, i) => (
65+
<span key={i} className="flex items-center gap-1">
66+
{a.keys && a.keys.map((k) => <Key key={k}>{k}</Key>)}
67+
{a.text && <span className="text-gray-600">{a.text}</span>}
68+
</span>
69+
))}
2970
</div>
3071
{editor.status && (
31-
<div className="self-start rounded-md bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white shadow">
72+
<div className="rounded-md bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white shadow">
3273
{editor.status}
3374
</div>
3475
)}
@@ -39,12 +80,13 @@ export default function GraphCanvas({ editor }) {
3980
edges={editor.edges}
4081
onNodesChange={editor.onNodesChange}
4182
onEdgesChange={editor.onEdgesChange}
42-
onNodesDelete={editor.onNodesDelete}
4383
onPaneClick={editor.onPaneClick}
4484
onNodeClick={editor.onNodeClick}
85+
onEdgeClick={editor.onEdgeClick}
4586
nodeTypes={nodeTypes}
4687
edgeTypes={edgeTypes}
4788
nodesConnectable={false}
89+
deleteKeyCode={null}
4890
fitView
4991
>
5092
<Background />

src/components/graph/graph-menu.jsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,28 @@ import { CustomSelect } from '@/components/custom-select';
22
import { CustomSlider } from '@/components/custom-slider';
33
import { CustomToggle } from '@/components/custom-toggle';
44
import { Button } from '@/components/ui/button';
5-
import { Play, RotateCcw } from 'lucide-react';
5+
import { Play, RotateCcw, MousePointerClick } from 'lucide-react';
6+
import { Key } from './kbd';
67

78
// Shared sidebar for the graph visualizers. The page owns the menu and wires
89
// these callbacks to a useGraphEditor() instance + its chosen algorithms.
910

10-
const BASE_CONTROLS = [
11-
['N then click', 'add node'],
12-
['E then 2 nodes', 'add edge'],
13-
['click + Del', 'delete'],
14-
['click + S', 'set start'],
15-
['click + F', 'set finish'],
16-
['drag', 'move node'],
17-
['Esc', 'cancel'],
11+
// Each control: optional `click` (mouse icon) + `text` prefix, `keys` caps, desc.
12+
// Per-element actions (S/F/Del/X) are surfaced in the on-canvas command bar when
13+
// a node/edge is selected, so the menu only points you there.
14+
const CONTROLS = [
15+
{ keys: ['N'], desc: 'add-node mode' },
16+
{ keys: ['E'], desc: 'add-edge (chain)' },
17+
{ keys: ['D'], desc: 'delete mode' },
18+
{ keys: ['Esc'], desc: 'select mode' },
19+
{ click: true, text: 'Node / Edge', desc: 'select for options' },
20+
{ text: 'drag', desc: 'move node' },
1821
];
1922

2023
export default function GraphMenu({
2124
title,
2225
algorithms,
2326
presets,
24-
weighted = false,
2527
hideDirected = false,
2628
disabled,
2729
onDirectedChange,
@@ -31,9 +33,7 @@ export default function GraphMenu({
3133
onVisualize,
3234
onClear,
3335
}) {
34-
const controls = weighted
35-
? [BASE_CONTROLS[0], BASE_CONTROLS[1], ['click weight', 'edit it'], ...BASE_CONTROLS.slice(2)]
36-
: BASE_CONTROLS;
36+
const controls = CONTROLS;
3737

3838
return (
3939
<div className="w-64 bg-gray-100 p-4 space-y-6 overflow-auto">
@@ -90,11 +90,15 @@ export default function GraphMenu({
9090
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Controls</span>
9191
<div className="h-px flex-1 bg-gray-300" />
9292
</div>
93-
<dl className="text-xs text-gray-600 space-y-1">
94-
{controls.map(([key, desc]) => (
95-
<div key={key} className="flex justify-between gap-2">
96-
<dt className="font-mono text-gray-800 whitespace-nowrap">{key}</dt>
97-
<dd className="text-right">{desc}</dd>
93+
<dl className="text-xs text-gray-600 space-y-1.5">
94+
{controls.map((c, i) => (
95+
<div key={i} className="flex items-center justify-between gap-2">
96+
<dt className="flex items-center gap-1 whitespace-nowrap">
97+
{c.click && <MousePointerClick className="h-3 w-3 text-gray-400" />}
98+
{c.text && <span className="text-gray-500">{c.text}</span>}
99+
{c.keys && c.keys.map((k) => <Key key={k}>{k}</Key>)}
100+
</dt>
101+
<dd className="text-right">{c.desc}</dd>
98102
</div>
99103
))}
100104
</dl>

src/components/graph/graph-node.jsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ const FILL = {
1414
negcycle: ['#f43f5e', '#be123c'],
1515
};
1616

17-
function GraphNode({ data }) {
17+
function GraphNode({ data, selected }) {
1818
const [bg, border] = FILL[data.state] || FILL.normal;
1919
const ring = data.role === 'start' ? '#10b981' : data.role === 'finish' ? '#f43f5e' : null;
2020

21+
// stack rings: role ring (inner) + selection ring (outer)
22+
const layers = [];
23+
if (ring) layers.push(`0 0 0 3px ${ring}`);
24+
if (selected) layers.push(`0 0 0 ${ring ? 5 : 3}px #0ea5e9`);
25+
const boxShadow = layers.length ? layers.join(', ') : '0 1px 3px rgba(0,0,0,0.3)';
26+
2127
return (
2228
<div
2329
style={{
@@ -33,7 +39,7 @@ function GraphNode({ data }) {
3339
justifyContent: 'center',
3440
fontWeight: 600,
3541
fontSize: 15,
36-
boxShadow: ring ? `0 0 0 3px ${ring}` : '0 1px 3px rgba(0,0,0,0.3)',
42+
boxShadow,
3743
}}
3844
>
3945
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} isConnectable={false} />

src/components/graph/kbd.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Small keycap, shared by the graph menu and the on-canvas command bar.
2+
export function Key({ children }) {
3+
return (
4+
<kbd className="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-gray-300 border-b-2 bg-white px-1 py-px text-[10px] font-semibold text-gray-700 shadow-sm">
5+
{children}
6+
</kbd>
7+
);
8+
}

src/components/graph/use-graph-editor.js

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,33 +132,54 @@ export function useGraphEditor({ weighted = false, initialPreset }) {
132132
);
133133
};
134134

135+
const deleteNode = (id) => {
136+
setNodes((ns) => ns.filter((n) => n.id !== id));
137+
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
138+
if (startIdRef.current === id) startIdRef.current = null;
139+
if (finishIdRef.current === id) finishIdRef.current = null;
140+
if (pendingEdgeRef.current === id) pendingEdgeRef.current = null;
141+
};
142+
143+
const deleteEdge = (id) => setEdges((es) => es.filter((e) => e.id !== id));
144+
145+
// directed only: swap endpoints so the arrow flips (id/key kept stable)
146+
const reverseEdge = (id) => {
147+
if (!directedRef.current) return;
148+
setEdges((es) => es.map((e) => (e.id === id ? { ...e, source: e.target, target: e.source } : e)));
149+
};
150+
151+
// Persistent modes: add-node drops a node on every pane click; add-edge
152+
// chains edges through consecutively clicked nodes; delete removes the
153+
// clicked node/edge. Esc returns to select.
135154
const onPaneClick = (event) => {
136155
if (isRunningRef.current) return;
137156
if (modeRef.current === 'add-node') {
138157
addNodeAt(screenToFlowPosition({ x: event.clientX, y: event.clientY }));
139-
setModeBoth('idle');
158+
} else {
159+
pendingEdgeRef.current = null; // clicking empty lifts the edge anchor
140160
}
141-
pendingEdgeRef.current = null;
142161
};
143162

144163
const onNodeClick = (_event, node) => {
145164
if (isRunningRef.current) return;
146-
if (modeRef.current === 'add-edge') {
165+
const m = modeRef.current;
166+
if (m === 'add-edge') {
147167
if (pendingEdgeRef.current == null) {
148168
pendingEdgeRef.current = node.id;
149169
} else {
150170
addEdge(pendingEdgeRef.current, node.id);
151-
pendingEdgeRef.current = null;
152-
setModeBoth('idle');
171+
pendingEdgeRef.current = node.id; // chain: anchor moves to this node
153172
}
173+
} else if (m === 'delete') {
174+
deleteNode(node.id);
154175
}
176+
// idle: React Flow handles selection
155177
};
156178

157-
const onNodesDelete = (deleted) => {
158-
for (const n of deleted) {
159-
if (n.id === startIdRef.current) startIdRef.current = null;
160-
if (n.id === finishIdRef.current) finishIdRef.current = null;
161-
}
179+
const onEdgeClick = (_event, edge) => {
180+
if (isRunningRef.current) return;
181+
if (modeRef.current === 'delete') deleteEdge(edge.id);
182+
// idle: React Flow handles selection
162183
};
163184

164185
useEffect(() => {
@@ -167,13 +188,23 @@ export function useGraphEditor({ weighted = false, initialPreset }) {
167188
const el = e.target;
168189
if (el && el.closest && el.closest('input,select,textarea,[role="combobox"],button')) return;
169190

170-
const selected = nodesRef.current.find((n) => n.selected);
191+
const selNode = () => nodesRef.current.find((n) => n.selected);
192+
const selEdge = () => edgesRef.current.find((n) => n.selected);
171193
const k = e.key.toLowerCase();
194+
172195
if (k === 'n') { setModeBoth('add-node'); pendingEdgeRef.current = null; }
173196
else if (k === 'e') { setModeBoth('add-edge'); pendingEdgeRef.current = null; }
174-
else if (k === 's' && selected) setRole(selected.id, 'start');
175-
else if (k === 'f' && selected) setRole(selected.id, 'finish');
197+
else if (k === 'd') { setModeBoth('delete'); pendingEdgeRef.current = null; }
176198
else if (e.key === 'Escape') { setModeBoth('idle'); pendingEdgeRef.current = null; }
199+
else if (k === 's') { const n = selNode(); if (n) setRole(n.id, 'start'); }
200+
else if (k === 'f') { const n = selNode(); if (n) setRole(n.id, 'finish'); }
201+
else if (k === 'x') { const ed = selEdge(); if (ed) reverseEdge(ed.id); }
202+
else if (e.key === 'Delete' || e.key === 'Backspace') {
203+
const n = selNode();
204+
const ed = selEdge();
205+
if (n) deleteNode(n.id);
206+
else if (ed) deleteEdge(ed.id);
207+
}
177208
};
178209
window.addEventListener('keydown', onKey);
179210
return () => window.removeEventListener('keydown', onKey);
@@ -210,7 +241,7 @@ export function useGraphEditor({ weighted = false, initialPreset }) {
210241
// reactive
211242
nodes, edges, directed, mode, status, isRunning, weighted,
212243
// react flow wiring
213-
onNodesChange, onEdgesChange, onNodesDelete, onPaneClick, onNodeClick,
244+
onNodesChange, onEdgesChange, onPaneClick, onNodeClick, onEdgeClick,
214245
// methods
215246
run, getContext, setWeight, setDirected, loadPreset, clear, setSpeed,
216247
};

0 commit comments

Comments
 (0)