A real-time collaborative mind mapping application built on a custom SVG infinite canvas engine with offline-first editing, WebSocket-based multi-user sync, AI-powered map generation via Groq, and a zero-re-render drag system β designed to rival tools like Miro and Whimsical at the architecture level.
| Resource | Link |
|---|---|
| π₯οΈ Frontend Repo (this) | mindmap-client |
| βοΈ Backend Repo | mindmap-server |
| π‘ API Reference | API Reference β |
| ποΈ Architecture | Architecture β |
| π Getting Started | Setup β |
π Live App:
https://mindmap-client-nu.vercel.app/π¦ Backend API:
https://mindmap-server-wr7o.onrender.com
| Dashboard View | Infinite Canvas Editor |
|---|---|
![]() |
![]() |
| User Login | Create Account |
|---|---|
![]() |
![]() |
Most collaborative mind mapping tools (Miro, Whimsical, XMind) are closed-source, subscription-gated, and treat maps as read-only in offline mode. MindMap Pro was built to explore:
- How far you can push a custom SVG canvas before needing WebGL β turns out, very far.
- Offline-first collaborative editing without full CRDTs (using operation queuing + Last Write Wins).
- Real-time multi-user state without a specialized backend (Yjs, Liveblocks) β built directly on Socket.io rooms.
The result is a production-grade architecture that maps onto real engineering problems at companies building collaborative software.
- π Features at a Glance
- βοΈ Engineering Challenges
- β‘ Performance Optimizations
- π§ System Design Principles
- ποΈ Architecture
- π΄ Offline Sync
- π οΈ Editor
- π€ AI Mindmap Generation
- π€ Real-Time Collaboration
- π₯ Sharing & Permissions
- π¬ Node Comments
- β³ Version History & Activity
- π Focus Mode
- π¦ Export System
- ποΈ Templates
- π Project Structure
- π§° Tech Stack
- π¦ Getting Started
- β¨οΈ Keyboard Shortcuts
- π€ Contributing
- π Roadmap
- π Scalability Considerations
| Category | Features |
|---|---|
| Canvas | SVG Infinite Canvas Β· Pan Β· Zoom Β· Mini Navigator Β· Lasso Selection |
| Nodes | Rich properties Β· Color coding Β· Font size Β· Notes Β· Collapse/expand |
| Layout | Recursive Auto-Layout (FLIP animated) Β· Align Β· Distribute Β· Fit to Screen |
| Offline | Operation queue (IndexedDB) Β· Sync on reconnect Β· Last-Write-Wins conflict resolution |
| AI | One-prompt mindmap generation via Groq Llama 3 70B |
| Collaboration | Live Cursors Β· Presence Avatars Β· Edit Locking Β· Remote Selection |
| Sharing | Role-Based Permissions (Owner / Editor / Viewer) Β· Invite by Email |
| Comments | Threaded per-node discussion panels Β· Real-time via WebSocket |
| History | Infinite Undo/Redo Β· Version Snapshots Β· Activity Log |
| Focus Mode | Subtree isolation with fade-out Β· One-click exit pill |
| Exports | PNG Β· PDF Β· JSON Β· Markdown |
| Templates | Pre-built map blueprints (Startup, Project, Study, Brainstorm) |
| Auth | JWT Authentication Β· Persistent sessions Β· Protected routes |
Problem: React's reconciler is too slow for 60fps drag on large graphs. Updating Zustand state on every mousemove event caused visible frame drops with 100+ nodes.
Solution: The drag engine bypasses React entirely during drag by mutating the SVG transform attribute directly via a DOM ref. React state is updated only on mouseup, keeping the component tree frozen while the canvas stays fluid.
mousemove β ref.current.setAttribute("transform", ...) β 60fps, zero re-renders
mouseup β commitDragEnd() β Zustand update β React re-render (once)
Problem: Pan + zoom creates a transformed coordinate space. Click positions need to map accurately into "world coordinates" at any zoom level.
Solution: getScreenCTM().inverse() on the SVG viewport element gives the exact inverse transform matrix. Every mouse event runs through this to get precise world-space (x, y) β accurate from 0.1x to 3x zoom.
Problem: Supporting offline edits without full CRDT infrastructure (Yjs, Automerge) while still being safe for concurrent multi-user editing.
Solution: An operation queue in IndexedDB records every edit as a typed Operation object. On sync, the server applies Last Write Wins β operations older than node.updatedAt are discarded. The ProcessedOperation collection prevents double-apply on retry.
Problem: Auto-layout repositions every node simultaneously, which is visually jarring if done with CSS transitions (nodes teleport).
Solution: FLIP (First, Last, Invert, Play) animation using the Web Animations API: capture all node positions before layout, compute new positions, then animate each node from its old position to its new one using cubic-bezier(0.2, 0.8, 0.2, 1).
Problem: Computing which nodes are "inside the focused subtree" on every render caused an infinite loop because the Set reference changed each time, triggering Zustand subscribers.
Solution: The focused subtree Set<string> is computed with useMemo keyed on [nodes, focusNodeId]. The same reference is returned when the inputs haven't changed, which prevents useSyncExternalStore from triggering phantom re-renders.
Problem: On page load, Zustand's persist middleware hydrates the auth token asynchronously. The online event listener could fire syncOperationQueue before the token was ready, resulting in tokenless sync requests β 403s.
Solution: syncOperationQueue now reads from useAuthStore.getState().token as the first guard. If no token, sync aborts silently. The token will be present on the next trigger.
| Optimization | Technique | Impact |
|---|---|---|
| Drag rendering | Direct DOM mutation via ref, bypasses React |
0 re-renders during drag |
| Cursor smoothing | requestAnimationFrame lerp loop (ease = 0.25) |
60fps cursor for all peers |
| Operation compression | Merge consecutive MOVE_NODE/EDIT_NODE ops for same node |
Smaller sync payloads |
| Batch sync | Single POST /sync with all queued ops instead of one-per-op |
Fewer HTTP round trips |
| Subtree memoization | useMemo BFS for Focus Mode subtree |
Stable Set reference, no phantom renders |
| FLIP animation | Web Animations API, not CSS transitions | GPU-composited layout moves |
| Socket debounce | 1000ms disconnect delay guards against StrictMode double-mounts | No spurious reconnects |
| replaceNodes | Silent node replacement without unmounting Canvas | No canvas blink on AI generation |
Every edit dispatches an Operation to a local IndexedDB queue before any network call. The network sync is opportunistic β latency or downtime never blocks the user.
Node changes appear instantly in the local Zustand store. The server sync and Socket.io broadcast happen after, in the background. Users never wait for a server round-trip to see their own edits.
Each operation carries a client-generated UUID (operationId). The server's ProcessedOperation collection tracks applied IDs β retrying a failed sync batch is always safe, since duplicates are detected and skipped.
All multi-user state flows through Socket.io events scoped to a map room. The server is the single authority for relay β it doesn't store ephemeral state like cursor positions. If a user disconnects, their cursor and edit lock are cleaned up automatically by the disconnect handler.
The editor state is split into 6 Zustand slices (canvasSlice, nodeSlice, collabSlice, versionSlice, commentSlice, activitySlice), each owning one concern. The top-level editorStore.ts is a thin 45-line orchestrator that composes them.
Type imports that previously caused circular dependency chains (socket.ts importing from editorStore.ts) have been broken out into src/types/mindmap.ts β a shared type-only module imported by both.
The system relies on a NoSQL document database (MongoDB), but relationships are strictly enforced at the application level to maintain data integrity across collaborative sessions.
erDiagram
USER ||--o{ MINDMAP : "owns/edits"
MINDMAP ||--o{ NODE : contains
MINDMAP ||--o{ MAP_VERSION : has
MINDMAP ||--o{ PROCESSED_OPERATION : logs
MINDMAP ||--o{ COLLABORATOR : "has access"
NODE ||--o{ NODE_COMMENT : "has comments"
USER {
ObjectId _id
String name
String email
}
MINDMAP {
ObjectId _id
String title
ObjectId creator
}
NODE {
String id
ObjectId mindMapId
String text
Float x
Float y
String parentId
}
NODE_COMMENT {
ObjectId _id
String nodeId
ObjectId userId
String text
}
The frontend is highly modularized to prevent unnecessary re-renders in the infinite canvas execution path.
graph TD
App[App.tsx] --> Auth[AuthProvider]
Auth --> Router[React Router]
Router --> Dash[Dashboard]
Router --> EditorView[EditorPage]
EditorView --> Header[EditorHeader]
EditorView --> Toolbar[FloatingToolbar]
EditorView --> CanvasArea[Canvas]
EditorView --> Panels[Side Panels]
CanvasArea --> MiniNav[MiniNavigator]
CanvasArea --> Zoom[ZoomControls]
CanvasArea --> Nodes[Node Components]
CanvasArea --> Edges[EdgeLayer]
CanvasArea --> Cursors[CursorLayer]
Panels --> PropPanel[NodePropertiesPanel]
Panels --> History[VersionPanel]
Panels --> Activity[ActivityPanel]
flowchart TB
subgraph Client["π₯οΈ Frontend (React + Vite)"]
direction TB
Pages["Pages\nAuth Β· Dashboard Β· Editor"]
Store["Zustand Store\nuseEditorStore + useSyncStore + useAuthStore"]
Canvas["SVG Canvas Engine\nNodeLayer Β· EdgeLayer Β· CursorLayer"]
Engine["Motion & Drag Engines\nFLIP (Web Animations API) Β· rAF drag"]
Services["Services\napi.ts (Axios+JWT) Β· socket.ts (Socket.io)"]
IDB["IndexedDB\nOperation Queue Β· Local Map State\n(idb-keyval)"]
end
subgraph Server["βοΈ Backend (Node.js + Express)"]
direction TB
Auth["Auth Middleware\nJWT verify + req.user"]
Routes["Routes"]
Controllers["Controllers"]
SvcLayer["Services\nmapPermissionService Β· aiService Β· authService"]
DB[("MongoDB\n9 collections")]
SocketSrv["Socket.io Server\nRoom presence map"]
end
AI["π€ Groq API\nllama3-70b-8192"]
Pages --> Store
Store --> Canvas
Store --> Engine
Store --> Services
Store --> IDB
Services -- "REST (Axios + JWT)" --> Auth
Services -- "WebSocket" --> SocketSrv
Auth --> Routes --> Controllers --> SvcLayer --> DB
Controllers --> SocketSrv
SvcLayer -- "AI generation" --> AI
IDB -- "sync on reconnect" --> Services
sequenceDiagram
actor User
participant Store as Zustand Store
participant Dispatcher as operationDispatcher
participant IDB as IndexedDB
participant API as REST API
participant Socket as Socket.io
participant Peers as Other Users
User->>Store: Edit node (drag / type / color)
Store->>Store: Optimistic UI update (instant)
Store->>Dispatcher: dispatchOperation("EDIT_NODE", ...)
Dispatcher->>IDB: Push/merge op to queue
alt Online + authenticated
Dispatcher->>API: POST /mindmaps/:id/sync
API-->>Dispatcher: { acknowledged: ["opId"] }
Dispatcher->>IDB: Remove acknowledged ops
API->>Socket: Emit node-updated to room
Socket-->>Peers: See update in real-time
else Offline
Note over IDB: Op stays in queue
Note over Dispatcher: Sync fires on next 'online' event
end
graph TD
ES["useEditorStore\n(45 lines β thin orchestrator)"]
ES --> CS["canvasSlice\nzoom Β· pan Β· selection\nundo/redo Β· history"]
ES --> NS["nodeSlice\nCRUD Β· drag commit\nauto-layout Β· align"]
ES --> CoS["collabSlice\nlive cursors\nonline presence"]
ES --> VS["versionSlice\nsnapshots Β· restore"]
ES --> CmS["commentSlice\nnode comments"]
ES --> AS["activitySlice\nlogs Β· members Β· role"]
NS & CoS & VS & CmS & AS --> MT["src/types/mindmap.ts\nShared type definitions"]
NS & CS --> UI["utils/userInfo.ts"]
NS & CS & VS --> ME["engine/motionEngine.ts\nFLIP animations"]
NS & CS & VS --> SK["services/socket.ts\nWebSocket emitters"]
NS --> OD["operationDispatcher.ts"]
OD --> IDB["indexedDb.ts\nidb-keyval"]
OD --> SS["useSyncStore.ts"]
MindMap Pro is offline-first. Every edit is preserved locally even without internet and automatically synced when connectivity returns.
stateDiagram-v2
[*] --> idle : App loads, user authenticated
idle --> syncing : Edit made + online
syncing --> idle : All ops acknowledged
syncing --> idle : API error (4xx β queue cleared)
idle --> offline : navigator.onLine = false OR network timeout
offline --> idle : window 'online' event fires
offline --> syncing : Reconnected + queue has ops
syncing --> offline : No response (true network failure)
| Type | Triggered By | payload fields |
|---|---|---|
CREATE_NODE |
Adding a new node | text, parentId, x, y, color, fontSize |
MOVE_NODE |
Drag commit on mouseup |
x, y |
EDIT_NODE |
Text / color / notes changes | text?, color?, notes?, fontSize? |
DELETE_NODE |
Node deletion | (empty β nodeId in op root) |
| File | Responsibility |
|---|---|
store/operationDispatcher.ts |
Create, compress, and queue ops; trigger sync |
store/indexedDb.ts |
IndexedDB persistence via idb-keyval |
store/useSyncStore.ts |
networkStatus Β· syncLock Β· lastSyncTimestamp |
app/App.tsx |
window online/offline listeners; sync on reconnect |
Rendered entirely in SVG for full control over transformations, animations, and event delegation. A custom pan/zoom layer applies a transform matrix using getScreenCTM().inverse() to map screen coordinates into world coordinates β accurate at any zoom level (0.1xβ3x).
- Create: Click β on any node, press
Tab, or use the floating toolbar. - Edit: Double-click or press
Enterfor inline text editing. - Rich Properties Panel: Slide-in sidebar with title, multi-line notes, color palette, and font-size controls.
- Color Coding: Six curated colors (Coral, Orange, Green, Blue, Purple, Teal).
- Collapse/Expand: Toggle subtree visibility with animated entrances/exits.
flowchart TD
Start[Trigger Auto-Layout] --> Capture[Capture Current DOM Positions]
Capture --> DFS[Depth-First Traversal]
DFS --> Calc[Calculate Node Subtree Heights & Widths]
Calc --> Position[Assign Target X, Y based on parent & sibling dimensions]
Position --> React[Update Zustand state with Target Coordinates]
React --> Render[React Renders Nodes at Target Coordinates]
Render --> Animate[Web Animations API: Animate from Captured to Target]
Animate --> End[FLIP Animation Complete]
- Recursive Auto-Layout: A depth-first algorithm that calculates the bounding box of each subtree recursively. It positions children nodes avoiding vertical overlap by dynamically spacing them based on the computed heights of their descendants.
- FLIP Animation Engine: Because React state updates instantly snap nodes to their new coordinates, the Web Animations API is used to capture positions before the render paint, and smoothly animate them from their old positions using a physically-based
cubic-bezier(0.2, 0.8, 0.2, 1)easing. - Align Tools: Left, Center, Right, Top, Middle, Bottom edge alignment.
- Distribute Tools: Evenly space selected nodes along horizontal or vertical axes.
Click β¨ AI Generate in the editor toolbar to generate a complete mindmap from a single topic prompt.
sequenceDiagram
actor User
participant C as Frontend (AI Modal)
participant API as Backend API
participant AI as Groq Llama 3
User->>C: Enter prompt & Submit
C->>C: set isLoadingMap(true)
C->>API: POST /api/ai/generate-mindmap { prompt }
API->>AI: Send system prompt + user topic
AI-->>API: Stream/Return JSON tree structure
API->>API: Parse JSON & Apply Layout Algorithm
API-->>C: Return flattened Node array with x,y coords
C->>C: useEditorStore().replaceNodes(nodes)
C->>C: set isLoadingMap(false)
C->>User: View newly generated map
How it works in-depth:
- Prompt Injection: The user prompt is wrapped in a strict system prompt instructing Groq's
llama3-70b-8192model to return a structured nested JSON indicating the tree hierarchy. - Server-Side Rendering (Layout): Before returning the generated nodes, the backend runs a two-pass depth-first search (DFS) layout algorithm to compute subtree-centered
x,ypositions for every node so it renders beautifully on the canvas instantly. - Optimistic Replacement: The existing nodes are replaced silently via
replaceNodesavoiding costly canvas unmounts, preserving the user's viewport matrix.
| Action | Sets isLoadingMap |
Canvas unmounts | Use case |
|---|---|---|---|
loadNodes |
β Yes | β Yes | Initial page load |
replaceNodes |
β No | β No | AI generation, silent refresh |
All editor events are synchronized via Socket.io with a room per mapId.
| Event (client β server) | Event (server β client) | Payload |
|---|---|---|
cursor-move |
cursor-moved |
{ x, y, name, color } |
node-editing |
node-editing-started |
{ nodeId, user } |
node-editing-stopped |
node-editing-stopped |
{ nodeId } |
selection-update |
selection-updated |
{ nodeIds, user } |
node-added |
node-added |
NodeType |
node-updated |
node-updated |
{ id, updates } |
node-deleted |
node-deleted |
{ nodeId } |
map-restored |
map-restored |
{ nodes, versionId } |
Live Cursors: Mouse positions broadcast at ~20 Hz, smoothed with a requestAnimationFrame lerp loop (ease = 0.25) β 60fps cursor movement for all peers.
Edit Locking: When a user edits a node, peers see a colored glow border and name badge. Double-clicks from others are silently blocked. Lock releases automatically on edit end.
| Role | Capabilities |
|---|---|
| Owner | Full control β edit, invite, remove members, delete map |
| Editor | Create, move, edit, delete nodes |
| Viewer | Read-only β canvas is non-interactive |
Per-node threaded comment panels in the Node Properties sidebar. Stored in NodeComment, real-time via comment-added / comment-deleted WebSocket events.
- Snapshots: Named point-in-time saves. Restore broadcasts
map-restoredto all live collaborators. - Activity Log:
NODE_CREATED,NODE_DELETED,NODE_EDITED,NODE_MOVED,NODE_COLOR_CHANGEDβ with user, timestamp, and diff metadata. - Undo/Redo: Full in-memory stack.
Ctrl+Z/Ctrl+Y.
Select any node β β Focus in the toolbar β all outside nodes fade to 15% opacity. A pill at the top reads "Viewing Subtree Β· Exit Focus". Subtree computed via useMemo BFS for a stable Set<string> reference.
| Format | Method |
|---|---|
| PNG | html-to-image captures the SVG viewport |
Same capture piped into jsPDF |
|
| JSON | GET /api/mindmaps/:id/export/json |
| Markdown | GET /api/mindmaps/:id/export/md |
Dashboard Template Gallery with 4 blueprints: π Startup Β· π Project Β· π Study Notes Β· π‘ Brainstorm.
src/
βββ app/ # Root component, routes, online/offline listeners
βββ types/ # Domain types (mindmap.ts, sync.ts, user.ts)
βββ store/
β βββ editorStore.ts # 45-line slice orchestrator
β βββ authStore.ts # JWT session
β βββ operationDispatcher.ts # Queue, compress, sync operations
β βββ indexedDb.ts # IndexedDB via idb-keyval
β βββ useSyncStore.ts # networkStatus, syncLock
β βββ slices/ # canvasSlice Β· nodeSlice Β· collabSlice Β· versionSlice Β· commentSlice Β· activitySlice
βββ components/
β βββ editor/ # Canvas, Node, EdgeLayer, CursorLayer, Panels, Modals
β βββ dashboard/ # TemplateGallery
β βββ ui/ # Toast
βββ context/ # DragContext (ref-based)
βββ engine/ # motionEngine.ts (FLIP)
βββ hooks/ # useDragEngine.ts (rAF-based)
βββ pages/ # Auth, Dashboard, Editor
βββ services/ # api.ts, socket.ts, aiService, exportService
βββ styles/ # global.css (single entry point)
| Technology | Version | Purpose |
|---|---|---|
| React | 19 | UI framework |
| TypeScript | 5.7 | Type safety |
| Vite | 7 | Build tool & dev server |
| Zustand | 5 | Global state β slice pattern |
| React Router | 7 | Client-side routing |
| Socket.io-client | 4.8 | Real-time WebSocket layer |
| Axios | 1.x | HTTP client with JWT interceptor |
| idb-keyval | 6 | IndexedDB for offline operation queue |
| Framer Motion | 12 | Landing page scroll animations |
| html-to-image | 1.x | PNG/PDF canvas export |
| jsPDF | 4 | PDF generation |
| uuid | 13 | Unique operation IDs |
| Web Animations API | browser-native | FLIP layout animations |
- Node.js 20+
- MindMap Pro Backend running on port 5000
git clone https://github.com/your-username/mindmap-client.git
cd mindmap-client
npm install# .env
VITE_API_URL=http://localhost:5000
VITE_API_URLis the base backend URL β no trailing slash, no/apisuffix.
npm run dev # β http://localhost:5173
npm run build # Production build
npm run preview # Preview locally| Category | Action | Shortcut |
|---|---|---|
| Navigation | Pan Canvas | Space + Drag |
| Zoom | Scroll Wheel |
|
| Fit to Screen | Ctrl + 0 |
|
| Editing | Add Child Node | Tab |
| Edit Selected | Enter or Double Click |
|
| Delete Node(s) | Delete / Backspace |
|
| Undo | Ctrl + Z |
|
| Redo | Ctrl + Y |
|
| Deselect | Escape |
|
| Layout | Auto-Layout | Ctrl + L |
Contributions are welcome! To get started:
# 1. Fork the repo and create your branch
git checkout -b feature/your-feature-name
# 2. Install dependencies
npm install
# 3. Run in development
npm run dev
# 4. Lint before submitting
npm run lintGuidelines:
- Keep components focused β one concern per file
- New Zustand state belongs in the relevant slice, not scattered in components
- All new operations must go through
dispatchOperationso they're offline-safe - Add TypeScript types to
src/types/β avoidany
Open to contributions on:
- CRDT-based conflict resolution (replacing LWW)
- Drag-to-group / node grouping
- Rich text node editing
- Mobile touch support
| Priority | Feature | Notes |
|---|---|---|
| π΄ High | CRDT-based sync | Replace LWW with Yjs for true concurrent editing |
| π΄ High | Mobile / touch support | Pinch-to-zoom, tap-to-edit |
| π Medium | WebRTC peer sync | Direct peer connections to reduce server relay load |
| π Medium | Node grouping | Visual containers with drag-in/out |
| π Medium | Markdown editing | Rich text in node bodies |
| π‘ Low | Plugin system | Custom node types and toolbars |
| π‘ Low | Graph DB backend | Replace MongoDB with Neo4j for complex traversals |
| π‘ Low | Graph layout algorithms | Force-directed, radial, treemap modes |
| π‘ Low | Native mobile app | React Native with shared business logic |
| Challenge | Current Approach | Production Scale Solution |
|---|---|---|
| WebSocket horizontal scaling | Single Node.js process | Redis pub/sub adapter (@socket.io/redis-adapter) β events fan out across instances |
| Large maps (1000+ nodes) | All nodes loaded into memory | Viewport culling β only render nodes in the current viewport bounding box |
| Operation queue growth | Unbounded IndexedDB queue | TTL on ProcessedOperation + client-side queue size cap |
| Cursor broadcast at scale | Relay every event | Throttle at 10 Hz + room size limits; move to WebRTC data channels for P2P at scale |
| Map sharding | All maps in one MongoDB cluster | Shard by mapId hash β each shard owns a subset of maps |
| Spatial queries | x/y as plain numbers |
2D spatial index on nodes for viewport queries ($geoWithin) |
| Activity log size | Last 50 in-memory | Paginated cursor with compound (mindMapId, createdAt) index (already in place) |
Full API documentation is in the Backend README.
| Group | Endpoints |
|---|---|
| Auth | POST /api/auth/register Β· POST /api/auth/login Β· GET /api/auth/me |
| Maps | GET/POST /api/mindmaps Β· PATCH /:id/title Β· DELETE /:id Β· PATCH /:id/restore |
| Nodes | GET /:id/nodes Β· POST /nodes Β· PATCH /nodes/:id Β· DELETE /nodes/:id |
| Sync | POST /:id/sync β batch offline operation apply |
| Members | GET/POST /:id/members Β· PUT/DELETE /:id/members/:memberId |
| Comments | Nested under /:mapId/nodes/:nodeId/comments |
| Versions | GET/POST /:id/versions Β· restore Β· delete |
| AI | POST /api/ai/generate-mindmap |
| Export | GET /:id/export/json Β· GET /:id/export/md |
MIT β free to use, modify, and distribute.




