@@ -5,6 +5,27 @@ import { StrokeControl } from "./StrokeControl";
55import { toast } from "sonner" ;
66import { 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+
829export 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} ;
0 commit comments