Skip to content

Commit e60be13

Browse files
committed
feat: implement real-time cursor tracking for collaborative users
- Add cursor position broadcasting via Socket.IO - Display other users' cursors with custom arrow design - Show username labels next to each cursor - Track users joining/leaving rooms - Auto-remove cursors on disconnect - Add username input to room join modal - Fix socket reconnection issues Resolves #9
1 parent fccebb7 commit e60be13

File tree

3 files changed

+244
-7
lines changed

3 files changed

+244
-7
lines changed

client/src/components/Canvas.jsx

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
22
import { Toolbar } from "./Toolbar";
33
import { ColorPicker } from "./ColorPicker";
44
import { StrokeControl } from "./StrokeControl";
5+
import { Cursor } from "./Cursor";
56
import { toast } from "sonner";
67
import { io } from "socket.io-client";
78
import tinycolor from "tinycolor2";
@@ -28,10 +29,15 @@ export const Canvas = () => {
2829

2930
// --- Collaboration State ---
3031
const [roomId, setRoomId] = useState("");
32+
const [username, setUsername] = useState("");
3133
const [joined, setJoined] = useState(false);
3234
const [socket, setSocket] = useState(null);
3335
const [isModalOpen, setIsModalOpen] = useState(false);
3436

37+
// --- Cursor Tracking State ---
38+
const [otherCursors, setOtherCursors] = useState(new Map()); // userId -> { x, y, username, color }
39+
const cursorColors = useRef(new Map()); // userId -> color
40+
3541
const [isLoggedIn, setIsLoggedIn] = useState(!!localStorage.getItem("token"));
3642

3743
const handleLogout = async () => {
@@ -115,12 +121,34 @@ export const Canvas = () => {
115121
canvasImage.current = canvas.toDataURL();
116122
};
117123

124+
// Generate a random color for a user
125+
const getColorForUser = (userId) => {
126+
if (!cursorColors.current.has(userId)) {
127+
const colors = [
128+
"#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A",
129+
"#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E2",
130+
"#F8B739", "#52D3AA", "#E74C3C", "#3498DB"
131+
];
132+
const color = colors[Math.floor(Math.random() * colors.length)];
133+
cursorColors.current.set(userId, color);
134+
}
135+
return cursorColors.current.get(userId);
136+
};
137+
118138
useEffect(() => {
119139
const s = io("http://localhost:3000");
120140
setSocket(s);
121-
s.on("connect", () => console.log("Connected to server:", s.id));
141+
142+
s.on("connect", () => {
143+
console.log("✅ Connected to server:", s.id);
144+
});
145+
146+
s.on("disconnect", () => {
147+
console.log("❌ Disconnected from server");
148+
});
149+
122150
s.on("draw", ({ x, y, color, width, type, tool }) => {
123-
if (!joined) return;
151+
console.log("📥 Received draw event:", { x, y, type, tool });
124152
const ctx = canvasRef.current?.getContext("2d");
125153
if (!ctx) return;
126154

@@ -139,8 +167,80 @@ export const Canvas = () => {
139167
ctx.restore(); // Restore to default transform
140168
saveCanvasState(); // Save state after remote draw
141169
});
142-
return () => s.disconnect();
143-
}, [joined, scale, offset]); // Add scale/offset dependencies
170+
171+
// Handle cursor movements from other users
172+
s.on("cursor-move", ({ userId, x, y }) => {
173+
console.log("🖱️ Received cursor from:", userId, "at", x, y);
174+
setOtherCursors((prev) => {
175+
const updated = new Map(prev);
176+
const existing = updated.get(userId) || {};
177+
const newCursor = {
178+
x,
179+
y,
180+
username: existing.username || `User-${userId.slice(0, 4)}`,
181+
color: getColorForUser(userId)
182+
};
183+
console.log("📌 Setting cursor:", userId, newCursor);
184+
updated.set(userId, newCursor);
185+
console.log("🗺️ Total cursors:", updated.size);
186+
return updated;
187+
});
188+
});
189+
190+
// Handle new user joining
191+
s.on("user-joined", ({ userId, username }) => {
192+
console.log("👤 User joined:", username, "(ID:", userId, ")");
193+
setOtherCursors((prev) => {
194+
const updated = new Map(prev);
195+
updated.set(userId, {
196+
x: 0,
197+
y: 0,
198+
username,
199+
color: getColorForUser(userId)
200+
});
201+
console.log("🗺️ Total cursors after join:", updated.size);
202+
return updated;
203+
});
204+
toast.info(`${username} joined the room`);
205+
});
206+
207+
// Handle existing users when joining
208+
s.on("existing-users", (users) => {
209+
console.log("👥 Existing users:", users);
210+
setOtherCursors((prev) => {
211+
const updated = new Map(prev);
212+
users.forEach(({ userId, username }) => {
213+
updated.set(userId, {
214+
x: 0,
215+
y: 0,
216+
username,
217+
color: getColorForUser(userId)
218+
});
219+
});
220+
return updated;
221+
});
222+
});
223+
224+
// Handle user leaving
225+
s.on("user-left", ({ userId }) => {
226+
console.log("👋 User left:", userId);
227+
setOtherCursors((prev) => {
228+
const updated = new Map(prev);
229+
const user = updated.get(userId);
230+
updated.delete(userId);
231+
cursorColors.current.delete(userId);
232+
if (user) {
233+
toast.info(`${user.username} left the room`);
234+
}
235+
return updated;
236+
});
237+
});
238+
239+
return () => {
240+
console.log("🔌 Disconnecting socket...");
241+
s.disconnect();
242+
};
243+
}, []); // Remove dependencies to prevent socket recreation!
144244

145245
useEffect(() => {
146246
const canvas = canvasRef.current;
@@ -527,7 +627,9 @@ export const Canvas = () => {
527627
// --- Collaboration Handlers (Unchanged) ---
528628
const handleJoinRoom = () => {
529629
if (!roomId.trim() || !socket) return;
530-
socket.emit("join-room", roomId.trim());
630+
const displayName = username.trim() || `User-${socket.id?.slice(0, 4)}`;
631+
console.log("🚨 Joining room:", roomId.trim(), "as", displayName);
632+
socket.emit("join-room", roomId.trim(), displayName);
531633
setJoined(true);
532634
setIsModalOpen(false);
533635
toast.success(`Collaborative mode active - joined room: ${roomId}`);
@@ -542,6 +644,8 @@ export const Canvas = () => {
542644
if (socket) {
543645
socket.emit("leave-room", roomId);
544646
setJoined(false);
647+
setOtherCursors(new Map()); // Clear all cursors
648+
cursorColors.current.clear(); // Clear color mappings
545649
toast.success(`Left room: ${roomId}`);
546650
}
547651
};
@@ -641,17 +745,45 @@ export const Canvas = () => {
641745
onFocus={() => setIsCanvasFocused(true)}
642746
onBlur={() => setIsCanvasFocused(false)}
643747
onMouseDown={startDrawing}
644-
onMouseMove={draw}
748+
onMouseMove={(e) => {
749+
// Send cursor position to other users when in a room
750+
if (joined && socket) {
751+
const { x, y } = getWorldPoint(e);
752+
socket.emit("cursor-move", { roomId, x, y });
753+
}
754+
draw(e);
755+
}}
645756
onMouseUp={stopDrawing}
646757
onMouseLeave={stopDrawing}
647758
onWheel={handleWheel} // Added wheel handler
648759
className={`${getCursor()} focus:outline-2 focus:outline-primary`} // Dynamic cursor
649760
/>
761+
762+
{/* --- Render Other Users' Cursors --- */}
763+
{joined && Array.from(otherCursors.entries()).map(([userId, cursor]) => {
764+
console.log("🎯 Rendering cursor for:", userId, cursor);
765+
return (
766+
<Cursor
767+
key={userId}
768+
x={cursor.x * scale + offset.x}
769+
y={cursor.y * scale + offset.y}
770+
username={cursor.username}
771+
color={cursor.color}
772+
/>
773+
);
774+
})}
650775

651776
{/* --- Modal and Info (Unchanged) --- */}
652777
{isModalOpen && (
653778
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white/90 border border-gray-400 rounded-xl shadow-xl p-6 z-50 flex flex-col items-center gap-3">
654779
<h2 className="text-xl font-semibold">Join a Room</h2>
780+
<input
781+
type="text"
782+
placeholder="Enter Your Name"
783+
value={username}
784+
onChange={(e) => setUsername(e.target.value)}
785+
className="border border-gray-400 rounded-md px-4 py-2 w-64 text-center"
786+
/>
655787
<input
656788
type="text"
657789
placeholder="Enter Room ID"

client/src/components/Cursor.jsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export const Cursor = ({ x, y, username, color }) => {
2+
console.log("🖌️ Cursor component rendering:", { x, y, username, color });
3+
4+
return (
5+
<div
6+
className="pointer-events-none fixed z-[9999] transition-transform duration-75"
7+
style={{
8+
left: 0,
9+
top: 0,
10+
transform: `translate(${x}px, ${y}px)`,
11+
}}
12+
>
13+
{/* Simple Mouse Cursor SVG */}
14+
<svg
15+
width="20"
16+
height="20"
17+
viewBox="0 0 20 20"
18+
fill="none"
19+
xmlns="http://www.w3.org/2000/svg"
20+
className="drop-shadow-lg"
21+
>
22+
<path
23+
d="M2 2 L2 14 L6 10 L9 16 L11 15 L8 9 L14 9 Z"
24+
fill={color}
25+
stroke="white"
26+
strokeWidth="1.5"
27+
strokeLinejoin="round"
28+
/>
29+
</svg>
30+
31+
{/* Username label */}
32+
<div
33+
className="ml-5 -mt-4 px-2 py-1 rounded text-xs font-semibold text-white whitespace-nowrap shadow-lg"
34+
style={{
35+
backgroundColor: color,
36+
}}
37+
>
38+
{username}
39+
</div>
40+
</div>
41+
);
42+
};

server/index.js

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,86 @@ const io = new Server(server, {
3232
},
3333
});
3434

35+
// Track users in rooms
36+
const roomUsers = new Map(); // roomId -> Set of { socketId, username }
37+
3538
io.on("connection", (socket) => {
3639
console.log("🟢 User connected:", socket.id);
3740

3841
// Join room
39-
socket.on("join-room", (roomId) => {
42+
socket.on("join-room", (roomId, username) => {
4043
socket.join(roomId);
44+
45+
// Add user to room tracking
46+
if (!roomUsers.has(roomId)) {
47+
roomUsers.set(roomId, new Map());
48+
}
49+
roomUsers.get(roomId).set(socket.id, { username: username || `User-${socket.id.slice(0, 4)}` });
50+
4151
console.log(`User ${socket.id} joined room ${roomId}`);
52+
53+
// Notify others in the room about the new user
54+
socket.to(roomId).emit("user-joined", {
55+
userId: socket.id,
56+
username: username || `User-${socket.id.slice(0, 4)}`
57+
});
58+
59+
// Send existing users list to the new user
60+
const existingUsers = Array.from(roomUsers.get(roomId).entries())
61+
.filter(([id]) => id !== socket.id)
62+
.map(([id, data]) => ({ userId: id, username: data.username }));
63+
socket.emit("existing-users", existingUsers);
4264
});
4365

4466
// Forward drawing events to all others in the room
4567
socket.on("draw", (data) => {
4668
const { roomId } = data;
69+
console.log(`🎨 Draw event from ${socket.id} in room ${roomId}`);
4770
socket.to(roomId).emit("draw", data);
4871
});
72+
73+
// Handle cursor position updates
74+
socket.on("cursor-move", (data) => {
75+
const { roomId, x, y } = data;
76+
console.log(`🖱️ Cursor from ${socket.id} in room ${roomId}:`, x, y);
77+
socket.to(roomId).emit("cursor-move", {
78+
userId: socket.id,
79+
x,
80+
y
81+
});
82+
});
83+
84+
// Handle leave room
85+
socket.on("leave-room", (roomId) => {
86+
socket.leave(roomId);
87+
88+
// Remove user from room tracking
89+
if (roomUsers.has(roomId)) {
90+
roomUsers.get(roomId).delete(socket.id);
91+
if (roomUsers.get(roomId).size === 0) {
92+
roomUsers.delete(roomId);
93+
}
94+
}
95+
96+
// Notify others in the room
97+
socket.to(roomId).emit("user-left", { userId: socket.id });
98+
console.log(`User ${socket.id} left room ${roomId}`);
99+
});
49100

50101
socket.on("disconnect", () => {
51102
console.log("🔴 User disconnected:", socket.id);
103+
104+
// Remove user from all rooms and notify others
105+
roomUsers.forEach((users, roomId) => {
106+
if (users.has(socket.id)) {
107+
users.delete(socket.id);
108+
socket.to(roomId).emit("user-left", { userId: socket.id });
109+
110+
if (users.size === 0) {
111+
roomUsers.delete(roomId);
112+
}
113+
}
114+
});
52115
});
53116
});
54117

0 commit comments

Comments
 (0)