Create Index.html
Browse files- 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 |
+
|