Skip to content

Commit 5e79d93

Browse files
committed
feat: Add real-time cursors with user labels
1 parent 159a496 commit 5e79d93

File tree

2 files changed

+198
-83
lines changed

2 files changed

+198
-83
lines changed

client/src/components/Canvas.jsx

Lines changed: 175 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ import { StrokeControl } from "./StrokeControl";
55
import { toast } from "sonner";
66
import { io } from "socket.io-client";
77

8+
// COLLAB CURSOR: THROTTLE FUNCTION
9+
function throttle(fn, wait) {
10+
let lastTime = 0;
11+
let timeout = null;
12+
let savedArgs = null;
13+
return function throttled(...args) {
14+
const now = Date.now();
15+
if (now - lastTime >= wait) {
16+
lastTime = now;
17+
fn.apply(this, args);
18+
} else {
19+
clearTimeout(timeout);
20+
savedArgs = args;
21+
timeout = setTimeout(() => {
22+
lastTime = Date.now();
23+
fn.apply(this, savedArgs);
24+
}, wait - (now - lastTime));
25+
}
26+
};
27+
}
28+
829
export const Canvas = () => {
930
const canvasRef = useRef(null);
1031
const [activeTool, setActiveTool] = useState("pen");
@@ -26,35 +47,15 @@ export const Canvas = () => {
2647
!!localStorage.getItem("token")
2748
);
2849

29-
const handleLogout = async () => {
30-
const token = localStorage.getItem("token");
31-
if (!token) return;
32-
try {
33-
const res = await fetch("http://localhost:3000/api/auth/logout", {
34-
method: "POST",
35-
headers: { "Content-Type": "application/json" },
36-
body: JSON.stringify({ token }),
37-
});
38-
const data = await res.json();
39-
if (res.ok) {
40-
localStorage.removeItem("token");
41-
setIsLoggedIn(false);
42-
toast.success("Logged out successfully!");
43-
} else {
44-
toast.error(data.message);
45-
}
46-
} catch (err) {
47-
console.error(err);
48-
toast.error("Logout failed!");
49-
}
50-
};
50+
const [otherCursors, setOtherCursors] = useState({}); // socketId -> {x, y, username}
5151

52+
// Initialize socket only once per component lifecycle
5253
useEffect(() => {
5354
const s = io("http://localhost:3000");
5455
setSocket(s);
5556
s.on("connect", () => console.log("Connected to server:", s.id));
57+
// Listen for draw events from other users
5658
s.on("draw", ({ x, y, color, width, type, tool }) => {
57-
if (!joined) return;
5859
const ctx = canvasRef.current?.getContext("2d");
5960
if (!ctx) return;
6061
if (type === "start") ctx.beginPath();
@@ -63,8 +64,31 @@ export const Canvas = () => {
6364
ctx.lineTo(x, y);
6465
ctx.stroke();
6566
});
66-
return () => s.disconnect();
67-
}, [joined]);
67+
// Listen for cursor updates
68+
const handleCursorUpdate = ({ x, y, username, socketId }) => {
69+
console.log('[CLIENT] RECEIVED CURSOR UPDATE:', { x, y, username, socketId });
70+
if (socketId === s.id) return;
71+
console.log('[CLIENT] Adding cursor to state:', { socketId, x, y, username });
72+
setOtherCursors((prev) => {
73+
const newState = { ...prev, [socketId]: { x, y, username } };
74+
console.log('[CLIENT] Updated otherCursors state:', newState);
75+
return newState;
76+
});
77+
};
78+
const handleCursorRemove = ({ socketId }) => {
79+
setOtherCursors((prev) => {
80+
const next = { ...prev };
81+
delete next[socketId];
82+
return next;
83+
});
84+
};
85+
s.on("cursor-update", handleCursorUpdate);
86+
s.on("cursor-remove", handleCursorRemove);
87+
s.on("disconnect", () => setOtherCursors({}));
88+
return () => {
89+
s.disconnect();
90+
};
91+
}, []);
6892

6993
useEffect(() => {
7094
const canvas = canvasRef.current;
@@ -96,6 +120,29 @@ export const Canvas = () => {
96120
return () => window.removeEventListener("keydown", handleKeyDown);
97121
}, [isCanvasFocused]);
98122

123+
const handleLogout = async () => {
124+
const token = localStorage.getItem("token");
125+
if (!token) return;
126+
try {
127+
const res = await fetch("http://localhost:3000/api/auth/logout", {
128+
method: "POST",
129+
headers: { "Content-Type": "application/json" },
130+
body: JSON.stringify({ token }),
131+
});
132+
const data = await res.json();
133+
if (res.ok) {
134+
localStorage.removeItem("token");
135+
setIsLoggedIn(false);
136+
toast.success("Logged out successfully!");
137+
} else {
138+
toast.error(data.message);
139+
}
140+
} catch (err) {
141+
console.error(err);
142+
toast.error("Logout failed!");
143+
}
144+
};
145+
99146
// Drawing logic handlers
100147
const startDrawing = (e) => {
101148
const canvas = canvasRef.current;
@@ -120,7 +167,6 @@ export const Canvas = () => {
120167
tool: activeTool,
121168
});
122169
}
123-
// Save snapshot for preview tools
124170
if (activeTool === "line" || activeTool === "rectangle") {
125171
snapshot.current = ctx.getImageData(0, 0, canvas.width, canvas.height);
126172
}
@@ -206,62 +252,82 @@ export const Canvas = () => {
206252
}
207253
};
208254

209-
return (
210-
<div className="relative w-full h-screen overflow-hidden bg-canvas">
211-
212-
{/* 🔹 Login / Logout buttons */}
213-
<div className="fixed top-4 left-4 z-[9999]">
214-
{isLoggedIn ? (
215-
// Logout button if logged in
216-
<button
217-
onClick={handleLogout}
218-
className="bg-red-600 text-white px-4 py-2 rounded-lg shadow hover:bg-red-700"
219-
>
220-
Logout
221-
</button>
222-
) : (
223-
<>
224-
{/* Desktop view: two separate buttons */}
225-
<div className="hidden sm:flex gap-3">
226-
<button
227-
onClick={() => window.location.href = "/login"}
228-
className="bg-blue-600 text-white px-4 py-2 rounded-lg shadow hover:bg-blue-700"
229-
>
230-
Sign In
231-
</button>
232-
<button
233-
onClick={() => window.location.href = "/register"}
234-
className="bg-blue-600 text-white px-4 py-2 rounded-lg shadow hover:bg-blue-700"
235-
>
236-
Sign Up
237-
</button>
238-
</div>
255+
const getUsername = () => {
256+
return localStorage.getItem("username") || "anon-" + (socket?.id?.slice(-5) || "user");
257+
};
239258

240-
{/* Mobile view: dropdown */}
241-
<div className="sm:hidden relative">
242-
<details className="bg-blue-600 text-white px-4 py-2 rounded-lg shadow cursor-pointer select-none">
243-
<summary className="outline-none list-none">Menu ☰</summary>
244-
<div className="absolute left-0 mt-2 w-32 bg-white text-black rounded-lg shadow-lg border">
245-
<button
246-
onClick={() => window.location.href = "/login"}
247-
className="block w-full text-left px-4 py-2 hover:bg-gray-100"
248-
>
249-
Sign In
250-
</button>
251-
<button
252-
onClick={() => window.location.href = "/register"}
253-
className="block w-full text-left px-4 py-2 hover:bg-gray-100"
254-
>
255-
Sign Up
256-
</button>
257-
</div>
258-
</details>
259-
</div>
260-
</>
261-
)}
262-
</div>
259+
// COLLAB CURSOR CLIENT LOGIC
260+
const sendCursorUpdate = throttle((x, y) => {
261+
if (joined && socket && roomId) {
262+
console.log('[CLIENT] SENDING CURSOR UPDATE:', { roomId, x, y, username: getUsername(), socketId: socket.id });
263+
socket.emit("cursor-move", {
264+
roomId,
265+
x,
266+
y,
267+
username: getUsername(),
268+
socketId: socket.id,
269+
});
270+
}
271+
}, 33);
263272

273+
function handleCanvasMouseMove(e) {
274+
if (!joined || !socket || !canvasRef.current) return;
275+
const rect = canvasRef.current.getBoundingClientRect();
276+
const x = e.clientX - rect.left;
277+
const y = e.clientY - rect.top;
278+
sendCursorUpdate(x, y);
279+
draw(e);
280+
}
264281

282+
return (
283+
<div className="relative w-full h-screen overflow-hidden bg-canvas">
284+
{/* Login / Logout buttons */}
285+
<div className="fixed top-4 left-4 z-[9999]">
286+
{isLoggedIn ? (
287+
<button
288+
onClick={handleLogout}
289+
className="bg-red-600 text-white px-4 py-2 rounded-lg shadow hover:bg-red-700"
290+
>
291+
Logout
292+
</button>
293+
) : (
294+
<>
295+
<div className="hidden sm:flex gap-3">
296+
<button
297+
onClick={() => window.location.href = "/login"}
298+
className="bg-blue-600 text-white px-4 py-2 rounded-lg shadow hover:bg-blue-700"
299+
>
300+
Sign In
301+
</button>
302+
<button
303+
onClick={() => window.location.href = "/register"}
304+
className="bg-blue-600 text-white px-4 py-2 rounded-lg shadow hover:bg-blue-700"
305+
>
306+
Sign Up
307+
</button>
308+
</div>
309+
<div className="sm:hidden relative">
310+
<details className="bg-blue-600 text-white px-4 py-2 rounded-lg shadow cursor-pointer select-none">
311+
<summary className="outline-none list-none">Menu ☰</summary>
312+
<div className="absolute left-0 mt-2 w-32 bg-white text-black rounded-lg shadow-lg border">
313+
<button
314+
onClick={() => window.location.href = "/login"}
315+
className="block w-full text-left px-4 py-2 hover:bg-gray-100"
316+
>
317+
Sign In
318+
</button>
319+
<button
320+
onClick={() => window.location.href = "/register"}
321+
className="block w-full text-left px-4 py-2 hover:bg-gray-100"
322+
>
323+
Sign Up
324+
</button>
325+
</div>
326+
</details>
327+
</div>
328+
</>
329+
)}
330+
</div>
265331

266332
<Toolbar
267333
activeTool={activeTool}
@@ -294,7 +360,7 @@ export const Canvas = () => {
294360
onFocus={() => setIsCanvasFocused(true)}
295361
onBlur={() => setIsCanvasFocused(false)}
296362
onMouseDown={startDrawing}
297-
onMouseMove={draw}
363+
onMouseMove={handleCanvasMouseMove}
298364
onMouseUp={stopDrawing}
299365
onMouseLeave={stopDrawing}
300366
className="cursor-crosshair focus:outline-2 focus:outline-primary"
@@ -332,6 +398,35 @@ export const Canvas = () => {
332398
</p>
333399
</div>
334400
</div>
401+
{/* Overlay for remote users' cursors */}
402+
{Object.entries(otherCursors).map(([sid, { x, y, username }]) => (
403+
<div
404+
key={sid}
405+
style={{ position: "fixed", left: x, top: y, pointerEvents: "none", zIndex: 9000, transform: "translate(-50%,-50%)" }}
406+
className="user-cursor-overlay"
407+
>
408+
<div style={{
409+
background: "#222",
410+
color: "#fff",
411+
padding: "2px 7px",
412+
borderRadius: 6,
413+
fontSize: 13,
414+
fontWeight: 500,
415+
marginBottom: 0,
416+
whiteSpace: "nowrap",
417+
position: "absolute",
418+
left: 18,
419+
top: 6,
420+
opacity: 0.89,
421+
pointerEvents: "none"
422+
}}>{username ?? "User"}</div>
423+
{/* Cursor shape: small color dot & tail */}
424+
<svg width="20" height="22" style={{ filter: "drop-shadow(0 2px 4px #0002)" }}>
425+
<circle cx="7" cy="7" r="6" fill="#12b6fa" stroke="#fff" strokeWidth="2" />
426+
<polyline points="7,12 7,18" stroke="#12b6fa" strokeWidth="3" />
427+
</svg>
428+
</div>
429+
))}
335430
</div>
336431
);
337432
};

server/index.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ const express = require("express");
22
const http = require("http");
33
const { Server } = require("socket.io");
44
const cors = require("cors");
5-
const connectDB = require("./config/db");
5+
// const connectDB = require("./config/db");
66
require('dotenv').config();
77

88
const app = express();
99
const PORT = process.env.PORT || 3000;
10-
const URL=process.env.MONGO_URI||"mongodb://localhost:27017/collab-canvas";
11-
connectDB(URL);
10+
// const URL = process.env.MONGO_URI || "mongodb://localhost:27017/collab-canvas";
11+
// connectDB(URL);
1212

1313
// Middleware
1414
app.use(express.json());
@@ -44,11 +44,31 @@ io.on("connection", (socket) => {
4444
// Forward drawing events to all others in the room
4545
socket.on("draw", (data) => {
4646
const { roomId } = data;
47+
console.log("[SERVER] FORWARD DRAW:", data);
4748
socket.to(roomId).emit("draw", data);
4849
});
4950

51+
// --- COLLAB CURSOR LOGIC ---
52+
socket.on("cursor-move", (data) => {
53+
const { roomId, x, y, username, socketId } = data;
54+
console.log("[SERVER] CURSOR-MOVE:", data);
55+
console.log("[SERVER] Broadcasting to room:", roomId);
56+
// Broadcast to all others in the room
57+
socket.to(roomId).emit("cursor-update", {
58+
x, y, username, socketId
59+
});
60+
console.log("[SERVER] Cursor update broadcasted");
61+
});
62+
63+
// --- Cleanup on disconnect ---
5064
socket.on("disconnect", () => {
5165
console.log("🔴 User disconnected:", socket.id);
66+
// Tell all rooms this socket was in to remove its cursor
67+
Array.from(socket.rooms).forEach((roomId) => {
68+
// skip the socket's own room
69+
if (roomId === socket.id) return;
70+
socket.to(roomId).emit("cursor-remove", { socketId: socket.id });
71+
});
5272
});
5373
});
5474

0 commit comments

Comments
 (0)