Skip to content

Commit fe35aeb

Browse files
committed
feat: add real-time remote cursor streaming for unique users with pointer and username display
1 parent 5329de4 commit fe35aeb

File tree

9 files changed

+2966
-126
lines changed

9 files changed

+2966
-126
lines changed

apps/collabydraw/canvas-engine/CanvasEngine.ts

Lines changed: 503 additions & 90 deletions
Large diffs are not rendered by default.

apps/collabydraw/canvas-engine/temp.ts

Lines changed: 2329 additions & 0 deletions
Large diffs are not rendered by default.

apps/collabydraw/components/CollaborationToolbar.tsx

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
1414
import { Share2 } from "lucide-react";
1515
import { getRoomSharingUrl, isInRoom } from "@/utils/roomParams";
1616
import { BASE_URL } from "@/config/constants";
17+
import { getClientColor } from "@/utils/getClientColor";
1718

1819
export default function CollaborationToolbar({ participants, hash }: { participants?: RoomParticipants[], hash?: string }) {
1920
const pathname = usePathname();
@@ -114,26 +115,4 @@ export default function CollaborationToolbar({ participants, hash }: { participa
114115
)}
115116
</div>
116117
)
117-
}
118-
119-
function hashToInteger(id: string) {
120-
let hash = 0;
121-
if (!id) return hash;
122-
123-
for (let i = 0; i < id.length; i++) {
124-
const char = id.charCodeAt(i);
125-
hash = (hash << 5) - hash + char;
126-
}
127-
return hash;
128-
}
129-
130-
export const getClientColor = (collaborator: { userId: string; userName: string; }) => {
131-
if (!collaborator?.userId) return "hsl(0, 0%, 83%)";
132-
133-
const hash = Math.abs(hashToInteger(collaborator?.userId));
134-
const hue = (hash % 36) * 10;
135-
const saturation = 90;
136-
const lightness = 75;
137-
138-
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
139-
};
118+
}

apps/collabydraw/components/canvas/CanvasBoard.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default function CanvasBoard() {
5252
grabbing: false,
5353
sidebarOpen: false,
5454
canvasColor: canvasBgLight[0],
55-
isCanvasEmpty: true
55+
isCanvasEmpty: true,
5656
});
5757
const userRef = useRef({
5858
roomId: null as string | null,
@@ -117,6 +117,12 @@ export default function CanvasBoard() {
117117
setCanvasEngineState(prev => ({ ...prev, canvasColor: canvasBgLight[0] }));
118118
}, [theme])
119119

120+
useEffect(() => {
121+
if (canvasEngineState.engine && theme) {
122+
canvasEngineState.engine.setTheme(theme === 'light' ? "light" : "dark");
123+
}
124+
}, [theme, canvasEngineState.engine]);
125+
120126
useEffect(() => {
121127
const storedShapes = localStorage.getItem(LOCALSTORAGE_CANVAS_KEY);
122128
const isEmpty = !storedShapes || JSON.parse(storedShapes).length === 0;
@@ -201,7 +207,8 @@ export default function CanvasBoard() {
201207
setParticipants(updatedParticipants);
202208
} : null,
203209
mode === 'room' ? (connectionStatus) => setIsConnected(connectionStatus) : null,
204-
userRef.current.encryptionKey
210+
userRef.current.encryptionKey,
211+
theme === 'light' ? "light" : "dark"
205212
);
206213
engine.setOnShapeCountChange((count: number) => {
207214
setCanvasEngineState(prev => ({
@@ -210,7 +217,7 @@ export default function CanvasBoard() {
210217
}));
211218
});
212219
return engine;
213-
}, [canvasEngineState.canvasColor, mode]);
220+
}, [canvasEngineState.canvasColor, mode, theme]);
214221

215222
useEffect(() => {
216223
if (!isCanvasReady) return;

apps/collabydraw/config/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export const DEFAULT_STROKE_WIDTH = 1;
77
export const DEFAULT_STROKE_FILL = "rgba(255, 255, 255)";
88
export const DEFAULT_BG_FILL = "rgba(18, 18, 18)";
99
export const ARROW_HEAD_LENGTH = 20;
10+
export const COLOR_WHITE = "#ffffff";
11+
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
12+
export const COLOR_DRAG_CALL = "#a2f1a5";
1013
export const getDashArrayDashed = (strokeWidth: number) => [
1114
strokeWidth,
1215
strokeWidth * 4,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* https://stackoverflow.com/a/3368118
3+
* Draws a rounded rectangle using the current state of the canvas.
4+
* @param {CanvasRenderingContext2D} context
5+
* @param {Number} x The top left x coordinate
6+
* @param {Number} y The top left y coordinate
7+
* @param {Number} width The width of the rectangle
8+
* @param {Number} height The height of the rectangle
9+
* @param {Number} radius The corner radius
10+
*/
11+
export const roundRect = (
12+
context: CanvasRenderingContext2D,
13+
x: number,
14+
y: number,
15+
width: number,
16+
height: number,
17+
radius: number,
18+
strokeColor?: string
19+
) => {
20+
context.beginPath();
21+
context.moveTo(x + radius, y);
22+
context.lineTo(x + width - radius, y);
23+
context.quadraticCurveTo(x + width, y, x + width, y + radius);
24+
context.lineTo(x + width, y + height - radius);
25+
context.quadraticCurveTo(
26+
x + width,
27+
y + height,
28+
x + width - radius,
29+
y + height
30+
);
31+
context.lineTo(x + radius, y + height);
32+
context.quadraticCurveTo(x, y + height, x, y + height - radius);
33+
context.lineTo(x, y + radius);
34+
context.quadraticCurveTo(x, y, x + radius, y);
35+
context.closePath();
36+
context.fill();
37+
if (strokeColor) {
38+
context.strokeStyle = strokeColor;
39+
}
40+
context.stroke();
41+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
function hashToInteger(id: string) {
2+
let hash = 0;
3+
if (!id) return hash;
4+
5+
for (let i = 0; i < id.length; i++) {
6+
const char = id.charCodeAt(i);
7+
hash = (hash << 5) - hash + char;
8+
}
9+
return hash;
10+
}
11+
12+
export const getClientColor = (collaborator: {
13+
userId: string;
14+
userName: string;
15+
}) => {
16+
if (!collaborator?.userId) return "hsl(0, 0%, 83%)";
17+
18+
const hash = Math.abs(hashToInteger(collaborator?.userId));
19+
const hue = (hash % 36) * 10;
20+
const saturation = 90;
21+
const lightness = 75;
22+
23+
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
24+
};

apps/ws/src/index.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,18 @@ wss.on("connection", function connection(ws, req) {
8585
};
8686
connections.push(newConnection);
8787

88-
console.log(`New connection established: ${connectionId} for user ${userId}`);
88+
ws.send(
89+
JSON.stringify({
90+
type: WsDataType.CONNECTION_READY,
91+
connectionId,
92+
})
93+
);
94+
console.log("✅ Sent CONNECTION_READY to:", connectionId);
8995

9096
ws.on("error", (err) =>
9197
console.error(`WebSocket error for user ${userId}:`, err)
9298
);
9399

94-
ws.on("open", () => {
95-
ws.send(
96-
JSON.stringify({
97-
type: WsDataType.CONNECTION_READY,
98-
connectionId,
99-
})
100-
);
101-
});
102-
103100
ws.on("message", async function message(data) {
104101
try {
105102
const parsedData: WebSocketMessage = JSON.parse(data.toString());
@@ -299,6 +296,51 @@ wss.on("connection", function connection(ws, req) {
299296
}
300297
}
301298

299+
case WsDataType.CURSOR_MOVE:
300+
if (
301+
parsedData.roomId &&
302+
parsedData.userId &&
303+
parsedData.connectionId &&
304+
parsedData.message
305+
) {
306+
broadcastToRoom(
307+
parsedData.roomId,
308+
{
309+
type: parsedData.type,
310+
roomId: parsedData.roomId,
311+
userId: connection.userId,
312+
userName: connection.userName,
313+
connectionId: connection.connectionId,
314+
message: parsedData.message,
315+
timestamp: new Date().toISOString(),
316+
id: null,
317+
participants: null,
318+
},
319+
[parsedData.connectionId],
320+
false
321+
);
322+
}
323+
break;
324+
325+
case WsDataType.STREAM_SHAPE:
326+
broadcastToRoom(
327+
parsedData.roomId,
328+
{
329+
type: parsedData.type,
330+
id: parsedData.id,
331+
message: parsedData.message,
332+
roomId: parsedData.roomId,
333+
userId: connection.userId,
334+
userName: connection.userName,
335+
connectionId: connection.connectionId,
336+
timestamp: new Date().toISOString(),
337+
participants: null,
338+
},
339+
[connection.connectionId],
340+
false
341+
);
342+
break;
343+
302344
case WsDataType.DRAW: {
303345
if (!parsedData.message || !parsedData.id || !parsedData.roomId) {
304346
console.error(

packages/common/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export enum WsDataType {
6363
CLOSE_ROOM = "CLOSE_ROOM",
6464
CONNECTION_READY = "CONNECTION_READY",
6565
EXISTING_SHAPES = "EXISTING_SHAPES",
66+
STREAM_SHAPE = "STREAM_SHAPE",
67+
CURSOR_MOVE = "CURSOR_MOVE",
6668
}
6769

6870
export interface WebSocketMessage {

0 commit comments

Comments
 (0)