Map / Index.html
Abdfah's picture
Create Index.html
a89f403 verified
Raw History Blame Contribute Delete
21.2 kB
import React, { useRef, useState, useEffect } from "react";
// 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.
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);
// 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 });
// Interaction state const [draggingNode, setDraggingNode] = useState(null); const dragTouchId = useRef(null);
// Selection & UI const [selectedNodeId, setSelectedNodeId] = useState(null); const [isConnecting, setIsConnecting] = useState(false); const [connectFrom, setConnectFrom] = useState(null);
// Utility helpers const generateId = (prefix = "n") => prefix + Math.random().toString(36).slice(2, 9);
// 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 }; }
// 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); }
// 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); }
// 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 }]; }); }
// Dragging nodes function onPointerDownNode(e, node) { e.stopPropagation(); e.preventDefault(); setDraggingNode(node.id); setSelectedNodeId(node.id);
// capture pointer for mouse/touch unified handling
const pid = e.pointerId;
dragTouchId.current = pid;
e.target.setPointerCapture && e.target.setPointerCapture(pid);
} 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; }
// Global canvas pointer handlers for pan and adding nodes useEffect(() => { const svg = svgRef.current; if (!svg) return;
function onWheel(e) {
// zoom at mouse point
e.preventDefault();
const delta = -e.deltaY;
const zoomFactor = Math.exp(delta * 0.0012);
const rect = svg.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// world coords BEFORE zoom
const wx = (mouseX - offset.x) / zoom;
const wy = (mouseY - offset.y) / zoom;
const newZoom = Math.max(0.2, Math.min(3, zoom * zoomFactor));
// adjust offset so zoom focuses at mouse
const nx = mouseX - wx * newZoom;
const ny = mouseY - wy * newZoom;
setZoom(newZoom);
setOffset({ x: nx, y: ny });
}
svg.addEventListener("wheel", onWheel, { passive: false });
return () => svg.removeEventListener("wheel", onWheel);
}, [zoom, offset]);
// 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);
// double-tap or double-click add node
if (e.detail === 2) {
addNodeAt(e.clientX, e.clientY);
}
} 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; }
// Touch pinch zoom handling useEffect(() => { const el = containerRef.current; if (!el) return; let touchState = { active: false, startDist: 0, startZoom: zoom, startOff: null, center: null };
function dist(a, b) {
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
}
function centerPoint(a, b) {
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
}
function onTouchStart(e) {
if (e.touches && e.touches.length === 2) {
touchState.active = true;
touchState.startDist = dist(e.touches[0], e.touches[1]);
touchState.startZoom = zoom;
touchState.startOff = { ...offset };
touchState.center = centerPoint(e.touches[0], e.touches[1]);
}
}
function onTouchMove(e) {
if (!touchState.active) return;
if (e.touches.length !== 2) return;
const d = dist(e.touches[0], e.touches[1]);
const factor = d / touchState.startDist;
const newZoom = Math.max(0.2, Math.min(3, touchState.startZoom * factor));
// zoom around center
const rect = svgRef.current.getBoundingClientRect();
const mouseX = touchState.center.x - rect.left;
const mouseY = touchState.center.y - rect.top;
const wx = (mouseX - touchState.startOff.x) / touchState.startZoom;
const wy = (mouseY - touchState.startOff.y) / touchState.startZoom;
const nx = mouseX - wx * newZoom;
const ny = mouseY - wy * newZoom;
setZoom(newZoom);
setOffset({ x: nx, y: ny });
e.preventDefault();
}
function onTouchEnd(e) {
if (e.touches.length < 2) touchState.active = false;
}
el.addEventListener('touchstart', onTouchStart, { passive: false });
el.addEventListener('touchmove', onTouchMove, { passive: false });
el.addEventListener('touchend', onTouchEnd);
return () => {
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
};
}, [zoom, offset]);
// 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);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = svgNode.clientWidth * 2; // high res
canvas.height = svgNode.clientHeight * 2;
const ctx = canvas.getContext('2d');
// white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'mindmap.png';
a.click();
URL.revokeObjectURL(url);
};
img.crossOrigin = 'anonymous';
img.src = url;
}
// 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); }
// 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); }
// 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' }]); } }
// 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); }
// 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]);
// 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} /> ); }
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>
<button
onClick={() => applyTemplate('guidelines')}
className="w-full py-2 rounded border text-sky-600 font-semibold"
>
Apply Guidelines Template
</button>
<div className="bg-gray-50 p-2 rounded">
<div className="mb-2 font-semibold">Selected Node</div>
{selectedNodeId ? (
(() => {
const sel = nodes.find((n) => n.id === selectedNodeId);
if (!sel) return <div>Not found.</div>;
return (
<div className="space-y-2">
<input
className="w-full p-2 border rounded"
value={sel.title}
onChange={(e) => setNodes((s) => s.map((n) => (n.id === sel.id ? { ...n, title: e.target.value } : n)))}
/>
<div className="flex gap-2">
<label className="flex-1">
Color
<input
type="color"
value={sel.color}
onChange={(e) => setNodes((s) => s.map((n) => (n.id === sel.id ? { ...n, color: e.target.value } : n)))}
className="w-full h-8 mt-1"
/>
</label>
<label className="w-20">
Size
<input
type="range"
min={30}
max={140}
value={sel.r}
onChange={(e) => setNodes((s) => s.map((n) => (n.id === sel.id ? { ...n, r: Number(e.target.value) } : n)))}
/>
</label>
</div>
<label>
Upload Image
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files && handleImageUpload(e.target.files[0])}
className="w-full mt-1"
/>
</label>
<div className="flex gap-2">
<button
className="flex-1 py-2 rounded border"
onClick={() => {
setNodes((s) => s.map((n) => (n.id === sel.id ? { ...n, image: null } : n)));
}}
>
Remove Image
</button>
<button className="bg-red-500 text-white px-3 py-2 rounded" onClick={() => deleteNode(sel.id)}>
Delete
</button>
</div>
</div>
);
})()
) : (
<div className="text-sm text-gray-500">No node selected. Tap a node to edit.</div>
)}
</div>
<div className="bg-gray-50 p-2 rounded">
<div className="font-semibold mb-1">Canvas</div>
<div className="flex gap-2">
<button onClick={() => radialLayout()} className="flex-1 py-2 rounded border">
Auto Layout
</button>
<button onClick={() => setZoom(1)} className="py-2 px-3 rounded border">
Reset Zoom
</button>
</div>
<div className="mt-2 flex gap-2">
<button onClick={exportPNG} className="flex-1 py-2 rounded bg-sky-600 text-white">
Export PNG
</button>
<button onClick={exportJSON} className="py-2 px-3 rounded border">
Save
</button>
</div>
<label className="block mt-2 text-sm text-gray-600">
Load JSON
<input
type="file"
accept="application/json"
onChange={(e) => e.target.files && importJSON(e.target.files[0])}
className="w-full mt-1"
/>
</label>
</div>
<div className="bg-gray-50 p-2 rounded">
<div className="font-semibold mb-1">Connect Mode</div>
<div className="flex gap-2">
<button
className={`flex-1 py-2 rounded ${isConnecting ? 'bg-emerald-500 text-white' : 'border'}`}
onClick={() => {
setIsConnecting(!isConnecting);
setConnectFrom(null);
}}
>
{isConnecting ? 'Connecting: ON' : 'Connect Nodes'}
</button>
<button
onClick={() => {
setLinks([]);
}}
className="py-2 px-3 rounded border text-red-600"
>
Clear Links
</button>
</div>
<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>
</div>
<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>
</div>
</div>
{/* Canvas area */}
<div className="flex-1 relative overflow-hidden" ref={containerRef}>
<svg
ref={svgRef}
xmlns="http://www.w3.org/2000/svg"
width="1600"
height="900"
viewBox={`0 0 1600 900`}
className="w-full h-full touch-none"
onPointerDown={onPointerDownCanvas}
onPointerMove={onPointerMoveCanvas}
onPointerUp={onPointerUpCanvas}
>
{/* Invisible rect background to capture events */}
<rect width="1600" height="900" fill="#fafafa" />
{/* group with transform for pan/zoom */}
<g transform={`translate(${offset.x}, ${offset.y}) scale(${zoom})`}>
{/* render links */}
<g>{links.map((l) => renderLink(l))}</g>
{/* render nodes */}
{nodes.map((n) => (
<g
key={n.id}
className="node"
transform={`translate(${n.x}, ${n.y})`}
onPointerDown={(e) => onPointerDownNode(e, n)}
onPointerMove={onPointerMoveNode}
onPointerUp={onPointerUpNode}
style={{ touchAction: 'none', cursor: 'grab' }}
>
{/* node shadow */}
<circle cx={0} cy={0} r={n.r + 6} fill="rgba(2,6,23,0.06)" />
{/* node body */}
<circle cx={0} cy={0} r={n.r} fill={n.color} stroke="#0f172a" strokeWidth={1} />
{/* image inside node if available */}
{n.image && (
<clipPath id={`clip-${n.id}`}>
<circle cx={0} cy={0} r={n.r - 8} />
</clipPath>
)}
{n.image && (
<image
href={n.image}
x={-(n.r - 8)}
y={-(n.r - 8)}
width={(n.r - 8) * 2}
height={(n.r - 8) * 2}
clipPath={`url(#clip-${n.id})`}
preserveAspectRatio="xMidYMid slice"
/>
)}
{/* text */}
<text
x={0}
y={n.r + 18}
textAnchor="middle"
style={{ fontSize: n.textSize, fontFamily: 'Inter, sans-serif', pointerEvents: 'none' }}
>
{n.title}
</text>
{/* small connect indicator */}
<circle
cx={n.r - 12}
cy={-(n.r - 12)}
r={10}
fill="#fff"
stroke="#0ea5e9"
strokeWidth={2}
onClick={(ev) => {
ev.stopPropagation();
if (!isConnecting) return;
if (!connectFrom) {
setConnectFrom(n.id);
} else {
addLink(connectFrom, n.id);
setConnectFrom(null);
}
}}
/>
</g>
))}
{/* temporary connecting line while connecting */}
{isConnecting && connectFrom && (() => {
const fromNode = nodes.find((x) => x.id === connectFrom);
if (!fromNode) return null;
// get pointer position for line from window mouse
return (
<line x1={fromNode.x} y1={fromNode.y} x2={fromNode.x + 1} y2={fromNode.y + 1} stroke="transparent" />
);
})()}
</g>
</svg>
{/* Floating quick controls for touch devices */}
<div className="absolute right-4 bottom-4 grid gap-2">
<button
className="p-3 rounded-full bg-white shadow-lg"
title="Add node at center"
onClick={(e) => {
// add at center of viewport
const rect = svgRef.current.getBoundingClientRect();
addNodeAt(rect.left + rect.width / 2, rect.top + rect.height / 2);
}}
>
+
</button>
<label className="p-3 rounded-full bg-white shadow-lg cursor-pointer">
📁
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => e.target.files && handleImageUpload(e.target.files[0])}
/>
</label>
</div>
</div>
</div>
); }