Download src/App.js from Abdfah/Map: direct link, hf CLI and curl.
- Browser
- Download file 21.2 kB
-
https://huggingface.co/spaces/Abdfah/Map/resolve/main/src/App.js
- Command line
-
hf download hf://spaces/Abdfah/Map/src/App.js
-
curl -L -o App.js https://huggingface.co/spaces/Abdfah/Map/resolve/main/src/App.js
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> | |
| ); } | |