Abdfah commited on
Commit
a89f403
·
verified ·
1 Parent(s): e31cb5c

Create Index.html

Browse files
Files changed (1) hide show
  1. Index.html +418 -0
Index.html ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useState, useEffect } from "react";
2
+
3
+ // MindmapCreatorApp.jsx // Single-file React component (Tailwind CSS assumed) // Features implemented: // - Central node + add/edit/delete nodes // - Drag (mouse + touch) with momentum-less simple handling // - Pan & zoom (pinch + wheel + two-finger drag) // - Connect nodes (simple straight / curved link) // - Color picker, font size, node size // - Upload image into node (uses FileReader) // - Export as PNG and download JSON of project // - Templates and basic auto-layout (radial) // Notes: This component uses only built-in browser APIs so it can run without extra deps.
4
+
5
+ export default function MindmapCreatorApp() { // Node model: { id, x, y, r, title, color, textSize, image } const [nodes, setNodes] = useState(() => { const center = { id: "center", x: 800, y: 450, r: 90, title: "Central Topic", color: "#0ea5e9", textSize: 18 }; return [center]; }); const [links, setLinks] = useState([]); // { from, to } const svgRef = useRef(null); const containerRef = useRef(null);
6
+
7
+ // Canvas transform state (for pan/zoom) const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const panState = useRef({ dragging: false, start: null });
8
+
9
+ // Interaction state const [draggingNode, setDraggingNode] = useState(null); const dragTouchId = useRef(null);
10
+
11
+ // Selection & UI const [selectedNodeId, setSelectedNodeId] = useState(null); const [isConnecting, setIsConnecting] = useState(false); const [connectFrom, setConnectFrom] = useState(null);
12
+
13
+ // Utility helpers const generateId = (prefix = "n") => prefix + Math.random().toString(36).slice(2, 9);
14
+
15
+ // convert client coords to canvas (world) coords function clientToWorld(clientX, clientY) { const rect = svgRef.current.getBoundingClientRect(); const cx = clientX - rect.left; const cy = clientY - rect.top; return { x: (cx - offset.x) / zoom, y: (cy - offset.y) / zoom }; }
16
+
17
+ // Add node at point function addNodeAt(clientX, clientY) { const pos = clientToWorld(clientX, clientY); const id = generateId(); const newNode = { id, x: pos.x, y: pos.y, r: 60, title: "New Node", color: "#0284c7", textSize: 14 }; setNodes((s) => [...s, newNode]); setSelectedNodeId(id); }
18
+
19
+ // Delete node and its links function deleteNode(id) { setNodes((s) => s.filter((n) => n.id !== id)); setLinks((l) => l.filter((link) => link.from !== id && link.to !== id)); if (selectedNodeId === id) setSelectedNodeId(null); }
20
+
21
+ // Add link function addLink(a, b) { if (a === b) return; setLinks((s) => { // avoid duplicates (unordered pair) const exists = s.some((l) => (l.from === a && l.to === b) || (l.from === b && l.to === a)); if (exists) return s; return [...s, { id: generateId('l'), from: a, to: b }]; }); }
22
+
23
+ // Dragging nodes function onPointerDownNode(e, node) { e.stopPropagation(); e.preventDefault(); setDraggingNode(node.id); setSelectedNodeId(node.id);
24
+
25
+ // capture pointer for mouse/touch unified handling
26
+ const pid = e.pointerId;
27
+ dragTouchId.current = pid;
28
+ e.target.setPointerCapture && e.target.setPointerCapture(pid);
29
+
30
+ } function onPointerMoveNode(e) { if (!draggingNode) return; if (e.pointerId !== dragTouchId.current) return; const world = clientToWorld(e.clientX, e.clientY); setNodes((s) => s.map((n) => (n.id === draggingNode ? { ...n, x: world.x, y: world.y } : n))); } function onPointerUpNode(e) { if (!draggingNode) return; setDraggingNode(null); dragTouchId.current = null; }
31
+
32
+ // Global canvas pointer handlers for pan and adding nodes useEffect(() => { const svg = svgRef.current; if (!svg) return;
33
+
34
+ function onWheel(e) {
35
+ // zoom at mouse point
36
+ e.preventDefault();
37
+ const delta = -e.deltaY;
38
+ const zoomFactor = Math.exp(delta * 0.0012);
39
+ const rect = svg.getBoundingClientRect();
40
+ const mouseX = e.clientX - rect.left;
41
+ const mouseY = e.clientY - rect.top;
42
+
43
+ // world coords BEFORE zoom
44
+ const wx = (mouseX - offset.x) / zoom;
45
+ const wy = (mouseY - offset.y) / zoom;
46
+
47
+ const newZoom = Math.max(0.2, Math.min(3, zoom * zoomFactor));
48
+
49
+ // adjust offset so zoom focuses at mouse
50
+ const nx = mouseX - wx * newZoom;
51
+ const ny = mouseY - wy * newZoom;
52
+
53
+ setZoom(newZoom);
54
+ setOffset({ x: nx, y: ny });
55
+ }
56
+
57
+ svg.addEventListener("wheel", onWheel, { passive: false });
58
+ return () => svg.removeEventListener("wheel", onWheel);
59
+
60
+ }, [zoom, offset]);
61
+
62
+ // Pan start function onPointerDownCanvas(e) { // ignore if clicking on node handlers (we check target) if (e.target.closest && e.target.closest('.node')) return; panState.current.dragging = true; panState.current.start = { x: e.clientX, y: e.clientY, off: { ...offset } }; const id = e.pointerId; panState.current.pointerId = id; e.target.setPointerCapture && e.target.setPointerCapture(id);
63
+
64
+ // double-tap or double-click add node
65
+ if (e.detail === 2) {
66
+ addNodeAt(e.clientX, e.clientY);
67
+ }
68
+
69
+ } function onPointerMoveCanvas(e) { if (!panState.current.dragging) return; if (e.pointerId !== panState.current.pointerId) return; const dx = e.clientX - panState.current.start.x; const dy = e.clientY - panState.current.start.y; setOffset({ x: panState.current.start.off.x + dx, y: panState.current.start.off.y + dy }); } function onPointerUpCanvas(e) { panState.current.dragging = false; panState.current.pointerId = null; }
70
+
71
+ // Touch pinch zoom handling useEffect(() => { const el = containerRef.current; if (!el) return; let touchState = { active: false, startDist: 0, startZoom: zoom, startOff: null, center: null };
72
+
73
+ function dist(a, b) {
74
+ return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
75
+ }
76
+ function centerPoint(a, b) {
77
+ return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
78
+ }
79
+
80
+ function onTouchStart(e) {
81
+ if (e.touches && e.touches.length === 2) {
82
+ touchState.active = true;
83
+ touchState.startDist = dist(e.touches[0], e.touches[1]);
84
+ touchState.startZoom = zoom;
85
+ touchState.startOff = { ...offset };
86
+ touchState.center = centerPoint(e.touches[0], e.touches[1]);
87
+ }
88
+ }
89
+ function onTouchMove(e) {
90
+ if (!touchState.active) return;
91
+ if (e.touches.length !== 2) return;
92
+ const d = dist(e.touches[0], e.touches[1]);
93
+ const factor = d / touchState.startDist;
94
+ const newZoom = Math.max(0.2, Math.min(3, touchState.startZoom * factor));
95
+
96
+ // zoom around center
97
+ const rect = svgRef.current.getBoundingClientRect();
98
+ const mouseX = touchState.center.x - rect.left;
99
+ const mouseY = touchState.center.y - rect.top;
100
+ const wx = (mouseX - touchState.startOff.x) / touchState.startZoom;
101
+ const wy = (mouseY - touchState.startOff.y) / touchState.startZoom;
102
+ const nx = mouseX - wx * newZoom;
103
+ const ny = mouseY - wy * newZoom;
104
+ setZoom(newZoom);
105
+ setOffset({ x: nx, y: ny });
106
+ e.preventDefault();
107
+ }
108
+ function onTouchEnd(e) {
109
+ if (e.touches.length < 2) touchState.active = false;
110
+ }
111
+
112
+ el.addEventListener('touchstart', onTouchStart, { passive: false });
113
+ el.addEventListener('touchmove', onTouchMove, { passive: false });
114
+ el.addEventListener('touchend', onTouchEnd);
115
+
116
+ return () => {
117
+ el.removeEventListener('touchstart', onTouchStart);
118
+ el.removeEventListener('touchmove', onTouchMove);
119
+ el.removeEventListener('touchend', onTouchEnd);
120
+ };
121
+
122
+ }, [zoom, offset]);
123
+
124
+ // Export SVG to PNG async function exportPNG() { const svgNode = svgRef.current; const serializer = new XMLSerializer(); const svgString = serializer.serializeToString(svgNode); const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob);
125
+
126
+ const img = new Image();
127
+ img.onload = () => {
128
+ const canvas = document.createElement('canvas');
129
+ canvas.width = svgNode.clientWidth * 2; // high res
130
+ canvas.height = svgNode.clientHeight * 2;
131
+ const ctx = canvas.getContext('2d');
132
+ // white background
133
+ ctx.fillStyle = '#ffffff';
134
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
135
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
136
+ const dataUrl = canvas.toDataURL('image/png');
137
+ const a = document.createElement('a');
138
+ a.href = dataUrl;
139
+ a.download = 'mindmap.png';
140
+ a.click();
141
+ URL.revokeObjectURL(url);
142
+ };
143
+ img.crossOrigin = 'anonymous';
144
+ img.src = url;
145
+
146
+ }
147
+
148
+ // Save / Load JSON function exportJSON() { const data = { nodes, links, zoom, offset }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'mindmap.json'; a.click(); URL.revokeObjectURL(url); } function importJSON(file) { const reader = new FileReader(); reader.onload = (e) => { try { const parsed = JSON.parse(e.target.result); setNodes(parsed.nodes || []); setLinks(parsed.links || []); setZoom(parsed.zoom || 1); setOffset(parsed.offset || { x: 0, y: 0 }); } catch (err) { alert('Invalid JSON'); } }; reader.readAsText(file); }
149
+
150
+ // Image upload into selected node function handleImageUpload(file) { if (!selectedNodeId) return alert('Select a node first'); const reader = new FileReader(); reader.onload = (e) => { const src = e.target.result; setNodes((s) => s.map((n) => (n.id === selectedNodeId ? { ...n, image: src } : n))); }; reader.readAsDataURL(file); }
151
+
152
+ // Templates function applyTemplate(type) { if (type === 'birthday') { const center = { id: 'center', x: 800, y: 450, r: 90, title: 'Plan a Birthday', color: '#06b6d4', textSize: 18 }; const kids = [ { id: 't1', x: 1000, y: 350, r: 60, title: 'Theme', color: '#fb7185', textSize: 14 }, { id: 't2', x: 1200, y: 500, r: 60, title: 'Budget', color: '#34d399', textSize: 14 }, { id: 't3', x: 600, y: 550, r: 60, title: 'Food', color: '#f59e0b', textSize: 14 }, ]; setNodes([center, ...kids]); setLinks([{ id: 'l1', from: 'center', to: 't1' }, { id: 'l2', from: 'center', to: 't2' }, { id: 'l3', from: 'center', to: 't3' }]); } else if (type === 'guidelines') { // simple example setNodes([ { id: 'center', x: 800, y: 450, r: 90, title: 'Mind Map Guidelines', color: '#0ea5e9', textSize: 18 }, { id: 'g1', x: 600, y: 300, r: 50, title: 'Clarity', color: '#0369a1', textSize: 13 }, { id: 'g2', x: 1000, y: 300, r: 50, title: 'Style', color: '#0369a1', textSize: 13 }, { id: 'g3', x: 600, y: 600, r: 50, title: 'Use', color: '#0369a1', textSize: 13 }, ]); setLinks([{ id: 'l1', from: 'center', to: 'g1' }, { id: 'l2', from: 'center', to: 'g2' }, { id: 'l3', from: 'center', to: 'g3' }]); } }
153
+
154
+ // Simple radial auto-layout around center node function radialLayout(centerId = 'center') { const center = nodes.find((n) => n.id === centerId); if (!center) return; const children = nodes.filter((n) => n.id !== centerId); const count = children.length; const radius = 220; const updated = nodes.map((n, i) => { if (n.id === centerId) return n; const idx = i - 1; // skip center const angle = (2 * Math.PI * idx) / Math.max(1, count); return { ...n, x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius }; }); setNodes(updated); }
155
+
156
+ // keyboard handlers useEffect(() => { function onKey(e) { if (e.key === 'Delete' && selectedNodeId) deleteNode(selectedNodeId); if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); exportJSON(); } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [selectedNodeId, nodes]);
157
+
158
+ // render links as curved paths function renderLink(link) { const a = nodes.find((n) => n.id === link.from); const b = nodes.find((n) => n.id === link.to); if (!a || !b) return null; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.hypot(dx, dy); const offsetDist = Math.min(80, dist * 0.3); const nx = a.x + (dx / dist) * (a.r + 6); const ny = a.y + (dy / dist) * (a.r + 6); const mx = b.x - (dx / dist) * (b.r + 6); const my = b.y - (dy / dist) * (b.r + 6); const cx1 = nx + -dy * 0.2; const cy1 = ny + dx * 0.2; const cx2 = mx + dy * 0.2; const cy2 = my + -dx * 0.2; return ( <path key={link.id} d={M ${nx} ${ny} C ${cx1} ${cy1} ${cx2} ${cy2} ${mx} ${my}} stroke="#0ea5e9" strokeWidth={3} fill="none" strokeLinecap="round" opacity={0.95} /> ); }
159
+
160
+ return ( <div className="flex h-screen w-screen bg-gray-50"> {/* Left UI panel */} <div className="w-80 bg-white p-4 border-r overflow-auto"> <h2 className="text-xl font-bold mb-3">MindMap Creator</h2> <div className="space-y-3"> <button onClick={() => applyTemplate('birthday')} className="w-full py-2 rounded bg-gradient-to-r from-sky-500 to-cyan-400 text-white font-semibold" > Apply Birthday Template </button>
161
+
162
+ <button
163
+ onClick={() => applyTemplate('guidelines')}
164
+ className="w-full py-2 rounded border text-sky-600 font-semibold"
165
+ >
166
+ Apply Guidelines Template
167
+ </button>
168
+
169
+ <div className="bg-gray-50 p-2 rounded">
170
+ <div className="mb-2 font-semibold">Selected Node</div>
171
+ {selectedNodeId ? (
172
+ (() => {
173
+ const sel = nodes.find((n) => n.id === selectedNodeId);
174
+ if (!sel) return <div>Not found.</div>;
175
+ return (
176
+ <div className="space-y-2">
177
+ <input
178
+ className="w-full p-2 border rounded"
179
+ value={sel.title}
180
+ onChange={(e) => setNodes((s) => s.map((n) => (n.id === sel.id ? { ...n, title: e.target.value } : n)))}
181
+ />
182
+ <div className="flex gap-2">
183
+ <label className="flex-1">
184
+ Color
185
+ <input
186
+ type="color"
187
+ value={sel.color}
188
+ onChange={(e) => setNodes((s) => s.map((n) => (n.id === sel.id ? { ...n, color: e.target.value } : n)))}
189
+ className="w-full h-8 mt-1"
190
+ />
191
+ </label>
192
+ <label className="w-20">
193
+ Size
194
+ <input
195
+ type="range"
196
+ min={30}
197
+ max={140}
198
+ value={sel.r}
199
+ onChange={(e) => setNodes((s) => s.map((n) => (n.id === sel.id ? { ...n, r: Number(e.target.value) } : n)))}
200
+ />
201
+ </label>
202
+ </div>
203
+ <label>
204
+ Upload Image
205
+ <input
206
+ type="file"
207
+ accept="image/*"
208
+ onChange={(e) => e.target.files && handleImageUpload(e.target.files[0])}
209
+ className="w-full mt-1"
210
+ />
211
+ </label>
212
+ <div className="flex gap-2">
213
+ <button
214
+ className="flex-1 py-2 rounded border"
215
+ onClick={() => {
216
+ setNodes((s) => s.map((n) => (n.id === sel.id ? { ...n, image: null } : n)));
217
+ }}
218
+ >
219
+ Remove Image
220
+ </button>
221
+ <button className="bg-red-500 text-white px-3 py-2 rounded" onClick={() => deleteNode(sel.id)}>
222
+ Delete
223
+ </button>
224
+ </div>
225
+ </div>
226
+ );
227
+ })()
228
+ ) : (
229
+ <div className="text-sm text-gray-500">No node selected. Tap a node to edit.</div>
230
+ )}
231
+ </div>
232
+
233
+ <div className="bg-gray-50 p-2 rounded">
234
+ <div className="font-semibold mb-1">Canvas</div>
235
+ <div className="flex gap-2">
236
+ <button onClick={() => radialLayout()} className="flex-1 py-2 rounded border">
237
+ Auto Layout
238
+ </button>
239
+ <button onClick={() => setZoom(1)} className="py-2 px-3 rounded border">
240
+ Reset Zoom
241
+ </button>
242
+ </div>
243
+ <div className="mt-2 flex gap-2">
244
+ <button onClick={exportPNG} className="flex-1 py-2 rounded bg-sky-600 text-white">
245
+ Export PNG
246
+ </button>
247
+ <button onClick={exportJSON} className="py-2 px-3 rounded border">
248
+ Save
249
+ </button>
250
+ </div>
251
+ <label className="block mt-2 text-sm text-gray-600">
252
+ Load JSON
253
+ <input
254
+ type="file"
255
+ accept="application/json"
256
+ onChange={(e) => e.target.files && importJSON(e.target.files[0])}
257
+ className="w-full mt-1"
258
+ />
259
+ </label>
260
+ </div>
261
+
262
+ <div className="bg-gray-50 p-2 rounded">
263
+ <div className="font-semibold mb-1">Connect Mode</div>
264
+ <div className="flex gap-2">
265
+ <button
266
+ className={`flex-1 py-2 rounded ${isConnecting ? 'bg-emerald-500 text-white' : 'border'}`}
267
+ onClick={() => {
268
+ setIsConnecting(!isConnecting);
269
+ setConnectFrom(null);
270
+ }}
271
+ >
272
+ {isConnecting ? 'Connecting: ON' : 'Connect Nodes'}
273
+ </button>
274
+ <button
275
+ onClick={() => {
276
+ setLinks([]);
277
+ }}
278
+ className="py-2 px-3 rounded border text-red-600"
279
+ >
280
+ Clear Links
281
+ </button>
282
+ </div>
283
+ <div className="text-xs text-gray-500 mt-2">Tap a node then another to create a link. Double-tap canvas to add node.</div>
284
+ </div>
285
+
286
+ <div className="text-xs text-gray-500 mt-6">Touch & mobile-friendly: use pinch to zoom, drag to pan, long-press nodes to drag them.</div>
287
+ </div>
288
+ </div>
289
+
290
+ {/* Canvas area */}
291
+ <div className="flex-1 relative overflow-hidden" ref={containerRef}>
292
+ <svg
293
+ ref={svgRef}
294
+ xmlns="http://www.w3.org/2000/svg"
295
+ width="1600"
296
+ height="900"
297
+ viewBox={`0 0 1600 900`}
298
+ className="w-full h-full touch-none"
299
+ onPointerDown={onPointerDownCanvas}
300
+ onPointerMove={onPointerMoveCanvas}
301
+ onPointerUp={onPointerUpCanvas}
302
+ >
303
+ {/* Invisible rect background to capture events */}
304
+ <rect width="1600" height="900" fill="#fafafa" />
305
+
306
+ {/* group with transform for pan/zoom */}
307
+ <g transform={`translate(${offset.x}, ${offset.y}) scale(${zoom})`}>
308
+ {/* render links */}
309
+ <g>{links.map((l) => renderLink(l))}</g>
310
+
311
+ {/* render nodes */}
312
+ {nodes.map((n) => (
313
+ <g
314
+ key={n.id}
315
+ className="node"
316
+ transform={`translate(${n.x}, ${n.y})`}
317
+ onPointerDown={(e) => onPointerDownNode(e, n)}
318
+ onPointerMove={onPointerMoveNode}
319
+ onPointerUp={onPointerUpNode}
320
+ style={{ touchAction: 'none', cursor: 'grab' }}
321
+ >
322
+ {/* node shadow */}
323
+ <circle cx={0} cy={0} r={n.r + 6} fill="rgba(2,6,23,0.06)" />
324
+
325
+ {/* node body */}
326
+ <circle cx={0} cy={0} r={n.r} fill={n.color} stroke="#0f172a" strokeWidth={1} />
327
+
328
+ {/* image inside node if available */}
329
+ {n.image && (
330
+ <clipPath id={`clip-${n.id}`}>
331
+ <circle cx={0} cy={0} r={n.r - 8} />
332
+ </clipPath>
333
+ )}
334
+ {n.image && (
335
+ <image
336
+ href={n.image}
337
+ x={-(n.r - 8)}
338
+ y={-(n.r - 8)}
339
+ width={(n.r - 8) * 2}
340
+ height={(n.r - 8) * 2}
341
+ clipPath={`url(#clip-${n.id})`}
342
+ preserveAspectRatio="xMidYMid slice"
343
+ />
344
+ )}
345
+
346
+ {/* text */}
347
+ <text
348
+ x={0}
349
+ y={n.r + 18}
350
+ textAnchor="middle"
351
+ style={{ fontSize: n.textSize, fontFamily: 'Inter, sans-serif', pointerEvents: 'none' }}
352
+ >
353
+ {n.title}
354
+ </text>
355
+
356
+ {/* small connect indicator */}
357
+ <circle
358
+ cx={n.r - 12}
359
+ cy={-(n.r - 12)}
360
+ r={10}
361
+ fill="#fff"
362
+ stroke="#0ea5e9"
363
+ strokeWidth={2}
364
+ onClick={(ev) => {
365
+ ev.stopPropagation();
366
+ if (!isConnecting) return;
367
+ if (!connectFrom) {
368
+ setConnectFrom(n.id);
369
+ } else {
370
+ addLink(connectFrom, n.id);
371
+ setConnectFrom(null);
372
+ }
373
+ }}
374
+ />
375
+ </g>
376
+ ))}
377
+
378
+ {/* temporary connecting line while connecting */}
379
+ {isConnecting && connectFrom && (() => {
380
+ const fromNode = nodes.find((x) => x.id === connectFrom);
381
+ if (!fromNode) return null;
382
+ // get pointer position for line from window mouse
383
+ return (
384
+ <line x1={fromNode.x} y1={fromNode.y} x2={fromNode.x + 1} y2={fromNode.y + 1} stroke="transparent" />
385
+ );
386
+ })()}
387
+ </g>
388
+ </svg>
389
+
390
+ {/* Floating quick controls for touch devices */}
391
+ <div className="absolute right-4 bottom-4 grid gap-2">
392
+ <button
393
+ className="p-3 rounded-full bg-white shadow-lg"
394
+ title="Add node at center"
395
+ onClick={(e) => {
396
+ // add at center of viewport
397
+ const rect = svgRef.current.getBoundingClientRect();
398
+ addNodeAt(rect.left + rect.width / 2, rect.top + rect.height / 2);
399
+ }}
400
+ >
401
+ +
402
+ </button>
403
+
404
+ <label className="p-3 rounded-full bg-white shadow-lg cursor-pointer">
405
+ 📁
406
+ <input
407
+ type="file"
408
+ accept="image/*"
409
+ style={{ display: 'none' }}
410
+ onChange={(e) => e.target.files && handleImageUpload(e.target.files[0])}
411
+ />
412
+ </label>
413
+ </div>
414
+ </div>
415
+ </div>
416
+
417
+ ); }
418
+