Skip to content

Commit aa4ff9d

Browse files
committed
feat: add real-time streaming for shape dragging and resizing across participants
- Implemented STREAM_UPDATE WebSocket message type - Streamed live shape updates during drag/resize actions - Updated SelectionController to trigger shape + cursor updates while interacting - Synced streamed shape states via CanvasEngine to remote clients
1 parent db083df commit aa4ff9d

File tree

4 files changed

+121
-25
lines changed

4 files changed

+121
-25
lines changed

apps/collabydraw/canvas-engine/CanvasEngine.ts

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type WebSocketConnection = {
5454
connected: boolean;
5555
};
5656

57+
// NOTE: Comments in this Canvas Engine are not AI generated. This are for my personal understanding.
5758
export class CanvasEngine {
5859
private canvas: HTMLCanvasElement;
5960
private ctx: CanvasRenderingContext2D;
@@ -126,6 +127,7 @@ export class CanvasEngine {
126127
private remoteClickIndicators: Map<string, number> = new Map();
127128

128129
private currentTheme: "light" | "dark" | null = null;
130+
private onLiveUpdateFromSelection?: (shape: Shape) => void;
129131

130132
constructor(
131133
canvas: HTMLCanvasElement,
@@ -176,6 +178,12 @@ export class CanvasEngine {
176178
);
177179
}
178180
});
181+
this.SelectionController.setOnLiveUpdate((shape) => {
182+
this.streamShapeUpdate(shape);
183+
});
184+
this.SelectionController.setOnDragOrResizeCursorMove((x, y) => {
185+
this.sendCursorMove(x, y);
186+
});
179187
if (!this.isStandalone && this.token && this.roomId) {
180188
console.log("✅Connecting to WebSocket…");
181189
this.connectWebSocket();
@@ -332,11 +340,6 @@ export class CanvasEngine {
332340
this.remoteStreamingShapes.set(streamKey, streamedShape);
333341
const userConnKey = `${data.userId}-${data.connectionId}`;
334342
this.remoteClickIndicators.set(userConnKey, Date.now());
335-
// console.log(
336-
// "this.remoteClickIndicators = ",
337-
// this.remoteClickIndicators
338-
// );
339-
340343
this.clearCanvas();
341344
} catch (err) {
342345
console.error("Error handling streamed shape:", err);
@@ -378,6 +381,29 @@ export class CanvasEngine {
378381
}
379382
break;
380383

384+
case WsDataType.STREAM_UPDATE:
385+
if (
386+
data.userId !== this.userId &&
387+
data.connectionId &&
388+
data.message
389+
) {
390+
const decrypted = await decryptData(
391+
data.message,
392+
this.encryptionKey!
393+
);
394+
const streamedShape = JSON.parse(decrypted);
395+
const streamKey = getStreamKey({
396+
userId: data.userId,
397+
connectionId: data.connectionId,
398+
shapeId: streamedShape.id,
399+
});
400+
this.remoteStreamingShapes.set(streamKey, streamedShape);
401+
const userConnKey = `${data.userId}-${data.connectionId}`;
402+
this.remoteClickIndicators.set(userConnKey, Date.now());
403+
this.clearCanvas();
404+
}
405+
break;
406+
381407
case WsDataType.DRAW:
382408
case WsDataType.UPDATE:
383409
if (
@@ -533,6 +559,48 @@ export class CanvasEngine {
533559
}, this.streamingUpdateInterval);
534560
}
535561

562+
private streamShapeUpdate(shape: Shape) {
563+
if (!this.isConnected || this.isStandalone) return;
564+
if (this.streamingThrottleTimeout !== null) return;
565+
566+
this.streamingThrottleTimeout = window.setTimeout(() => {
567+
if (this.socket?.readyState === WebSocket.OPEN && this.roomId) {
568+
const message = {
569+
type: WsDataType.STREAM_UPDATE,
570+
id: shape.id,
571+
message: shape,
572+
roomId: this.roomId,
573+
userId: this.userId!,
574+
userName: this.userName!,
575+
timestamp: new Date().toISOString(),
576+
connectionId: this.connectionId,
577+
};
578+
579+
this.sendMessage?.(JSON.stringify(message)).catch((e) => {
580+
console.error("Error streaming shape update", e);
581+
});
582+
}
583+
this.streamingThrottleTimeout = null;
584+
}, this.streamingUpdateInterval);
585+
}
586+
587+
private sendCursorMove(x: number, y: number) {
588+
if (!this.isStandalone && this.isConnected) {
589+
const message = {
590+
type: WsDataType.CURSOR_MOVE,
591+
roomId: this.roomId,
592+
userId: this.userId!,
593+
userName: this.userName!,
594+
connectionId: this.connectionId,
595+
message: JSON.stringify({ x, y }),
596+
};
597+
598+
if (this.socket?.readyState === WebSocket.OPEN) {
599+
this.socket.send(JSON.stringify(message));
600+
}
601+
}
602+
}
603+
536604
async init() {
537605
if (this.isStandalone) {
538606
try {
@@ -706,6 +774,13 @@ export class CanvasEngine {
706774
);
707775

708776
this.existingShapes.map((shape: Shape) => {
777+
const isBeingStreamed = [...this.remoteStreamingShapes.values()].some(
778+
(streamingShape) => streamingShape.id === shape.id
779+
);
780+
781+
if (isBeingStreamed) {
782+
return;
783+
}
709784
if (shape.type === "rectangle") {
710785
this.drawRect(
711786
shape.x,
@@ -891,7 +966,7 @@ export class CanvasEngine {
891966
const screenX = x * this.scale + this.panX;
892967
const screenY = y * this.scale + this.panY;
893968

894-
const cursorColor = getClientColor({ userId, userName });
969+
const cursorColor: string = getClientColor({ userId, userName });
895970
// const labelStrokeColor = this.currentTheme === "dark" ? "#2f6330" : COLOR_DRAG_CALL;
896971
const boxBackground = cursorColor;
897972
const boxTextColor = COLOR_CHARCOAL_BLACK;
@@ -951,13 +1026,12 @@ export class CanvasEngine {
9511026
this.ctx.fill();
9521027
this.ctx.stroke();
9531028

954-
// Calculate label box positioning
9551029
const offsetX = screenX + pointerWidth / 2;
9561030
const offsetY = screenY + pointerHeight + 2;
9571031
const paddingX = 5;
9581032
const paddingY = 3;
9591033

960-
this.ctx.font = "600 12px sans-serif";
1034+
this.ctx.font = "600 13px sans-serif";
9611035
const textMetrics = this.ctx.measureText(userName);
9621036
const textHeight =
9631037
textMetrics.actualBoundingBoxAscent +
@@ -976,7 +1050,7 @@ export class CanvasEngine {
9761050
this.ctx.strokeStyle = COLOR_WHITE;
9771051
this.ctx.stroke();
9781052

979-
// Optional highlight stroke for speaker
1053+
// Optional highlight stroke for speaker // Option 2 for showing active indicator
9801054
// this.ctx.beginPath();
9811055
// this.ctx.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
9821056
// this.ctx.strokeStyle = labelStrokeColor;
@@ -995,7 +1069,7 @@ export class CanvasEngine {
9951069

9961070
this.ctx.restore();
9971071
});
998-
// 🧼 Cleanup expired indicators (older than 1s)
1072+
9991073
this.remoteClickIndicators.forEach((timestamp, key) => {
10001074
if (Date.now() - timestamp > 1000) {
10011075
this.remoteClickIndicators.delete(key);
@@ -2264,7 +2338,6 @@ export class CanvasEngine {
22642338

22652339
this.ctx.restore();
22662340
} else {
2267-
// // For rough/sketchy style, use the existing implementation
22682341
const pathStr = points.reduce(
22692342
(path, point, index) =>
22702343
path +
@@ -2283,19 +2356,6 @@ export class CanvasEngine {
22832356
fillStyle
22842357
);
22852358
this.roughCanvas.path(pathStr, options);
2286-
2287-
// For rough/sketchy style, use the improved path with rough.js
2288-
// const options = this.getRoughOptions(
2289-
// strokeWidth,
2290-
// strokeFill,
2291-
// 0,
2292-
// bgFill,
2293-
// "solid",
2294-
// fillStyle
2295-
// );
2296-
2297-
// // Use the SVG path data from perfect-freehand with rough.js
2298-
// this.roughCanvas.path(svgPathData, options);
22992359
}
23002360
}
23012361

apps/collabydraw/canvas-engine/SelectionController.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ export class SelectionController {
4040
private triggerUpdate() {
4141
this.onUpdateCallback();
4242
}
43+
44+
private onLiveUpdateCallback: ((shape: Tool) => void) | null = null;
45+
setOnLiveUpdate(cb: (shape: Tool) => void) {
46+
this.onLiveUpdateCallback = cb;
47+
}
48+
private onDragOrResizeCursorMove: ((x: number, y: number) => void) | null = null;
49+
public setOnDragOrResizeCursorMove(cb: (x: number, y: number) => void) {
50+
this.onDragOrResizeCursorMove = cb;
51+
}
4352
constructor(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) {
4453
this.ctx = ctx;
4554
this.canvas = canvas;
@@ -290,7 +299,6 @@ export class SelectionController {
290299
}
291300
}
292301

293-
// In updateDragging()
294302
updateDragging(x: number, y: number) {
295303
if (this.isDragging && this.selectedShape) {
296304
const dx = x - this.dragOffset.x;
@@ -320,6 +328,10 @@ export class SelectionController {
320328
this.selectedShape.y = dy;
321329
}
322330
this.triggerUpdate();
331+
if (this.onLiveUpdateCallback) {
332+
this.onLiveUpdateCallback(this.selectedShape);
333+
}
334+
this.onDragOrResizeCursorMove?.(x, y);
323335
}
324336
}
325337

@@ -399,6 +411,10 @@ export class SelectionController {
399411
}
400412
}
401413
this.triggerUpdate();
414+
if (this.onLiveUpdateCallback) {
415+
this.onLiveUpdateCallback(this.selectedShape);
416+
}
417+
this.onDragOrResizeCursorMove?.(x, y);
402418
}
403419
}
404420

apps/ws/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,25 @@ wss.on("connection", function connection(ws, req) {
341341
);
342342
break;
343343

344+
case WsDataType.STREAM_UPDATE:
345+
broadcastToRoom(
346+
parsedData.roomId,
347+
{
348+
type: parsedData.type,
349+
id: parsedData.id,
350+
message: parsedData.message,
351+
roomId: parsedData.roomId,
352+
userId: connection.userId,
353+
userName: connection.userName,
354+
connectionId: connection.connectionId,
355+
timestamp: new Date().toISOString(),
356+
participants: null,
357+
},
358+
[connection.connectionId],
359+
false
360+
);
361+
break;
362+
344363
case WsDataType.DRAW: {
345364
if (!parsedData.message || !parsedData.id || !parsedData.roomId) {
346365
console.error(

packages/common/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export enum WsDataType {
6464
CONNECTION_READY = "CONNECTION_READY",
6565
EXISTING_SHAPES = "EXISTING_SHAPES",
6666
STREAM_SHAPE = "STREAM_SHAPE",
67+
STREAM_UPDATE = "STREAM_UPDATE",
6768
CURSOR_MOVE = "CURSOR_MOVE",
6869
}
6970

0 commit comments

Comments
 (0)