From 9ee9d6ccfeccea8b3cfcccaa0e2282e1609140c6 Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Fri, 21 Nov 2025 22:06:12 -0800 Subject: [PATCH] working live collab --- backend/package-lock.json | 276 ++++++++++++++++++++++++++- backend/package.json | 4 +- backend/prisma/dev.db | Bin 36864 -> 86016 bytes backend/src/index.ts | 91 ++++++++- frontend/package-lock.json | 137 ++++++++++++++ frontend/package.json | 1 + frontend/src/pages/Editor.tsx | 328 ++++++++++++++++++++++++++++++--- frontend/src/utils/identity.ts | 102 ++++++++++ frontend/src/utils/sync.ts | 58 ++++++ 9 files changed, 960 insertions(+), 37 deletions(-) create mode 100644 frontend/src/utils/identity.ts create mode 100644 frontend/src/utils/sync.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 7abe640..cb6ef7b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,9 +10,11 @@ "license": "ISC", "dependencies": { "@prisma/client": "^5.22.0", + "@types/socket.io": "^3.0.1", "cors": "^2.8.5", "dotenv": "^17.2.3", - "express": "^5.1.0" + "express": "^5.1.0", + "socket.io": "^4.8.1" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -133,6 +135,12 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -186,7 +194,6 @@ "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -235,7 +242,6 @@ "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -289,6 +295,15 @@ "@types/node": "*" } }, + "node_modules/@types/socket.io": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.1.tgz", + "integrity": "sha512-XSma2FhVD78ymvoxYV4xGXrIH/0EKQ93rR+YR0Y+Kw1xbPzLDCip/UWSejZ08FpxYeYNci/PZPQS9anrvJRqMA==", + "license": "MIT", + "dependencies": { + "socket.io": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -356,6 +371,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -620,6 +644,95 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1456,6 +1569,141 @@ "node": ">=10" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1594,7 +1842,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -1628,6 +1875,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 75d8d16..a28b437 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,9 +13,11 @@ "type": "commonjs", "dependencies": { "@prisma/client": "^5.22.0", + "@types/socket.io": "^3.0.1", "cors": "^2.8.5", "dotenv": "^17.2.3", - "express": "^5.1.0" + "express": "^5.1.0", + "socket.io": "^4.8.1" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index 4fc93a68d45fadb9817e7982bafd79bfeb0a397d..3ccc57ff5d2ab894a42b3217e29b53a0bfe4746a 100644 GIT binary patch literal 86016 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 R9#K>P5fyx`_<#K%{r~WrzG(me diff --git a/backend/src/index.ts b/backend/src/index.ts index 90022b9..169513f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,8 @@ import express from "express"; import cors from "cors"; import dotenv from "dotenv"; import path from "path"; +import { createServer } from "http"; +import { Server } from "socket.io"; // @ts-ignore import { PrismaClient } from "./generated/client"; @@ -14,12 +16,94 @@ process.env.DATABASE_URL = `file:${dbPath}`; console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL); const app = express(); +const httpServer = createServer(app); +const io = new Server(httpServer, { + cors: { + origin: "*", + }, + maxHttpBufferSize: 1e8, // 100 MB +}); const prisma = new PrismaClient(); const PORT = process.env.PORT || 8000; app.use(cors()); app.use(express.json({ limit: "50mb" })); +// Socket.io Logic +interface User { + id: string; + name: string; + initials: string; + color: string; + socketId: string; + isActive: boolean; +} + +const roomUsers = new Map(); + +io.on("connection", (socket) => { + socket.on( + "join-room", + ({ + drawingId, + user, + }: { + drawingId: string; + user: Omit; + }) => { + const roomId = `drawing_${drawingId}`; + socket.join(roomId); + + const newUser: User = { ...user, socketId: socket.id, isActive: true }; + + const currentUsers = roomUsers.get(roomId) || []; + const filteredUsers = currentUsers.filter((u) => u.id !== user.id); + filteredUsers.push(newUser); + roomUsers.set(roomId, filteredUsers); + + io.to(roomId).emit("presence-update", filteredUsers); + } + ); + + socket.on("cursor-move", (data) => { + const roomId = `drawing_${data.drawingId}`; + // Use volatile for high-frequency, low-importance updates (cursors) + // If network is congested, drop these packets + socket.volatile.to(roomId).emit("cursor-move", data); + }); + + socket.on("element-update", (data) => { + const roomId = `drawing_${data.drawingId}`; + socket.to(roomId).emit("element-update", data); + }); + + socket.on( + "user-activity", + ({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => { + const roomId = `drawing_${drawingId}`; + const users = roomUsers.get(roomId); + if (users) { + const user = users.find((u) => u.socketId === socket.id); + if (user) { + user.isActive = isActive; + io.to(roomId).emit("presence-update", users); + } + } + } + ); + + socket.on("disconnect", () => { + roomUsers.forEach((users, roomId) => { + const index = users.findIndex((u) => u.socketId === socket.id); + if (index !== -1) { + users.splice(index, 1); + roomUsers.set(roomId, users); + io.to(roomId).emit("presence-update", users); + } + }); + }); +}); + // --- Drawings --- // GET /drawings @@ -38,10 +122,7 @@ app.get("/drawings", async (req, res) => { where.collectionId = String(collectionId); } else { // Default: Exclude trash, but include unorganized (null) - where.OR = [ - { collectionId: { not: "trash" } }, - { collectionId: null }, - ]; + where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }]; } const drawings = await prisma.drawing.findMany({ @@ -262,7 +343,7 @@ const ensureTrashCollection = async () => { } }; -app.listen(PORT, async () => { +httpServer.listen(PORT, async () => { await ensureTrashCollection(); console.log(`Server running on port ${PORT}`); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9d902c5..e17082f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.9.6", + "socket.io-client": "^4.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, @@ -2288,6 +2289,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3871,6 +3878,45 @@ "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", "license": "EPL-2.0" }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -6474,6 +6520,68 @@ "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", "license": "MIT" }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -7124,6 +7232,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f16cc46..bb09658 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.9.6", + "socket.io-client": "^4.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index d7c7a28..7c239e0 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -4,10 +4,34 @@ import { ArrowLeft } from 'lucide-react'; import { Excalidraw, convertToExcalidrawElements, exportToSvg } from '@excalidraw/excalidraw'; import '@excalidraw/excalidraw/index.css'; import debounce from 'lodash/debounce'; +import throttle from 'lodash/throttle'; import { Toaster, toast } from 'sonner'; +import { io, Socket } from 'socket.io-client'; +import { getUserIdentity } from '../utils/identity'; +import { reconcileElements } from '../utils/sync'; +import type { UserIdentity } from '../utils/identity'; import * as api from '../api'; import { useTheme } from '../context/ThemeContext'; +interface Peer extends UserIdentity { + isActive: boolean; +} + +interface ElementVersionInfo { + version: number; + versionNonce: number; +} + +// Move UIOptions outside to prevent re-creation on every render +const UIOptions = { + canvasActions: { + saveToActiveFile: false, + loadScene: false, + export: { saveFileToDisk: false }, + toggleTheme: true, + }, +}; + export const Editor: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -18,8 +42,163 @@ export const Editor: React.FC = () => { const [newName, setNewName] = useState(''); const [initialData, setInitialData] = useState(null); + const [peers, setPeers] = useState([]); + const [me] = useState(getUserIdentity()); + const [isReady, setIsReady] = useState(false); + const socketRef = useRef(null); + const lastCursorEmit = useRef(0); + const elementVersionMap = useRef>(new Map()); + const isSyncing = useRef(false); + const cursorBuffer = useRef>(new Map()); + const animationFrameId = useRef(0); + + const recordElementVersion = useCallback((element: any) => { + elementVersionMap.current.set(element.id, { + version: element.version ?? 0, + versionNonce: element.versionNonce ?? 0, + }); + }, []); + + const hasElementChanged = useCallback((element: any) => { + const previous = elementVersionMap.current.get(element.id); + if (!previous) return true; + + const nextVersion = element.version ?? 0; + const nextNonce = element.versionNonce ?? 0; + + return previous.version !== nextVersion || previous.versionNonce !== nextNonce; + }, []); + + useEffect(() => { + if (!id || !isReady) return; + + const socket = io(import.meta.env.VITE_API_URL || 'http://localhost:8000', { + transports: ['websocket'], + }); + socketRef.current = socket; + + socket.emit('join-room', { drawingId: id, user: me }); + + // Start the render loop for cursors + const renderLoop = () => { + if (cursorBuffer.current.size > 0 && excalidrawAPI.current) { + const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); + + cursorBuffer.current.forEach((data, userId) => { + collaborators.set(userId, data); + }); + + cursorBuffer.current.clear(); + excalidrawAPI.current.updateScene({ collaborators }); + } + animationFrameId.current = requestAnimationFrame(renderLoop); + }; + renderLoop(); + + socket.on('presence-update', (users: Peer[]) => { + setPeers(users.filter(u => u.id !== me.id)); + + // Update collaborators map to remove inactive users + if (excalidrawAPI.current) { + const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); + users.forEach(user => { + if (!user.isActive && user.id !== me.id) { + collaborators.delete(user.id); + } + }); + excalidrawAPI.current.updateScene({ collaborators }); + } + }); + + socket.on('cursor-move', (data: any) => { + // Just buffer the data + cursorBuffer.current.set(data.userId, { + pointer: data.pointer, + button: data.button || 'up', + selectedElementIds: data.selectedElementIds || {}, + username: data.username, + avatarUrl: data.avatarUrl, + color: { background: data.color, stroke: data.color }, + id: data.userId, + }); + }); + + socket.on('element-update', ({ elements }: { elements: any[] }) => { + if (!excalidrawAPI.current) return; + + isSyncing.current = true; + + // 3. THE SELECTION GUARD (Fixes Dragging/Snap-back) + // Get IDs of elements YOU are currently holding + const currentAppState = excalidrawAPI.current.getAppState(); + const mySelectedIds = currentAppState.selectedElementIds || {}; + + // Filter out updates for elements you are currently dragging + // This prevents the server from pulling the object out of your hand + const validRemoteElements = elements.filter((el: any) => !mySelectedIds[el.id]); + + const localElements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); + const mergedElements = reconcileElements(localElements, validRemoteElements); + + // Update version map with remote versions to avoid echoing + validRemoteElements.forEach((el: any) => { + recordElementVersion(el); + }); + + excalidrawAPI.current.updateScene({ elements: mergedElements }); + isSyncing.current = false; + }); + + // Activity Tracking + const handleActivity = (isActive: boolean) => { + socket.emit('user-activity', { drawingId: id, isActive }); + }; + + const onFocus = () => handleActivity(true); + const onBlur = () => handleActivity(false); + const onMouseEnter = () => handleActivity(true); + const onMouseLeave = () => handleActivity(false); + + window.addEventListener('focus', onFocus); + window.addEventListener('blur', onBlur); + document.addEventListener('mouseenter', onMouseEnter); + document.addEventListener('mouseleave', onMouseLeave); + + return () => { + window.removeEventListener('focus', onFocus); + window.removeEventListener('blur', onBlur); + document.removeEventListener('mouseenter', onMouseEnter); + document.removeEventListener('mouseleave', onMouseLeave); + socket.off('presence-update'); + socket.off('cursor-move'); + socket.off('element-update'); + socket.disconnect(); + cancelAnimationFrame(animationFrameId.current); + }; + }, [id, me, isReady, recordElementVersion]); + + const onPointerUpdate = useCallback((payload: any) => { + const now = Date.now(); + if (now - lastCursorEmit.current > 50 && socketRef.current) { + socketRef.current.emit('cursor-move', { + pointer: payload.pointer, + button: payload.button, + username: me.name, + userId: me.id, + drawingId: id, + color: me.color + }); + lastCursorEmit.current = now; + } + }, [id, me]); + // Refs for API interaction const excalidrawAPI = useRef(null); + + const setExcalidrawAPI = useCallback((api: any) => { + excalidrawAPI.current = api; + setIsReady(true); + }, []); // ------------------------------------------------------------------ // 1. STABLE SAVE LOGIC (The Fix) @@ -27,6 +206,7 @@ export const Editor: React.FC = () => { // doesn't need to be recreated on every render. // ------------------------------------------------------------------ const saveDataRef = useRef<(elements: any, appState: any) => Promise>(null); + const savePreviewRef = useRef<(elements: any, appState: any, files: any) => Promise>(null); // Update the ref on every render to ensure it has access to the latest props/state saveDataRef.current = async (elements, appState) => { @@ -37,9 +217,22 @@ export const Editor: React.FC = () => { viewBackgroundColor: appState.viewBackgroundColor, gridSize: appState.gridSize, }; + + await api.updateDrawing(id, { + elements, + appState: persistableAppState, + }); + } catch (err) { + console.error('Failed to save drawing', err); + toast.error("Failed to save changes"); + } + }; + savePreviewRef.current = async (elements, appState, files) => { + if (!id) return; + + try { // Generate preview - const files = excalidrawAPI.current?.getFiles() || null; const svg = await exportToSvg({ elements, appState: { @@ -50,15 +243,10 @@ export const Editor: React.FC = () => { files, }); const preview = svg.outerHTML; - - await api.updateDrawing(id, { - elements, - appState: persistableAppState, - preview, - }); + + await api.updateDrawing(id, { preview }); } catch (err) { - console.error('Failed to save drawing', err); - toast.error("Failed to save changes"); + console.error('Failed to save preview', err); } }; @@ -73,6 +261,39 @@ export const Editor: React.FC = () => { [] // Empty dependency array = Stable across renders ); + const debouncedSavePreview = useCallback( + debounce((elements, appState, files) => { + if (savePreviewRef.current) { + savePreviewRef.current(elements, appState, files); + } + }, 10000), + [] + ); + + const broadcastChanges = useCallback( + throttle((elements: readonly any[]) => { + if (!socketRef.current || !id) return; + + const changes: any[] = []; + + elements.forEach((el) => { + if (hasElementChanged(el)) { + changes.push(el); + recordElementVersion(el); + } + }); + + if (changes.length > 0) { + socketRef.current.emit('element-update', { + drawingId: id, + elements: changes, + userId: me.id + }); + } + }, 100, { leading: true, trailing: true }), + [id, hasElementChanged, recordElementVersion] + ); + // ------------------------------------------------------------------ // 2. DATA LOADING // ------------------------------------------------------------------ @@ -83,8 +304,15 @@ export const Editor: React.FC = () => { const data = await api.getDrawing(id); setDrawingName(data.name); + const elements = convertToExcalidrawElements(data.elements || []); + + // Initialize version tracking with loaded data + elements.forEach((el: any) => { + recordElementVersion(el); + }); + setInitialData({ - elements: convertToExcalidrawElements(data.elements || []), + elements, appState: { ...data.appState, collaborators: new Map(), @@ -96,7 +324,7 @@ export const Editor: React.FC = () => { } }; loadData(); - }, [id]); + }, [id, recordElementVersion]); // ------------------------------------------------------------------ // 3. HANDLERS @@ -107,11 +335,14 @@ export const Editor: React.FC = () => { const handleKeyDown = async (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); - if (excalidrawAPI.current && saveDataRef.current) { + if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { const elements = excalidrawAPI.current.getSceneElements(); const appState = excalidrawAPI.current.getAppState(); + const files = excalidrawAPI.current.getFiles() || null; // Call save immediately, bypassing debounce await saveDataRef.current(elements, appState); + // Also update preview + savePreviewRef.current(elements, appState, files); toast.success("Saved changes to server"); } } @@ -120,10 +351,26 @@ export const Editor: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, []); - const handleCanvasChange = (elements: readonly any[], appState: any) => { - // Trigger the stable debounced save - debouncedSave(elements, appState); - }; + const handleCanvasChange = useCallback((elements: readonly any[], appState: any) => { + // 4. STOP THE ECHO + // If this change was caused by a socket update, do NOT broadcast it back + if (isSyncing.current) return; + + // Get ALL elements including deleted (fixes the "deletion not syncing" bug) + const allElements = excalidrawAPI.current + ? excalidrawAPI.current.getSceneElementsIncludingDeleted() + : elements; + + // Trigger Sync (Throttled) + broadcastChanges(allElements); + + // Trigger Fast Save + debouncedSave(allElements, appState); + + // Trigger Slow Preview Gen + const files = excalidrawAPI.current?.getFiles() || null; + debouncedSavePreview(allElements, appState, files); + }, [debouncedSave, debouncedSavePreview, broadcastChanges]); const handleRenameSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -139,14 +386,7 @@ export const Editor: React.FC = () => { }; // Disable native Excalidraw save dialogs - const UIOptions = { - canvasActions: { - saveToActiveFile: false, - loadScene: false, - export: { saveFileToDisk: false }, - toggleTheme: true, - }, - }; + // UIOptions is now defined outside the component return (
@@ -177,8 +417,41 @@ export const Editor: React.FC = () => { )}
-
- {/* Status indicator removed */} +
+
+
+
+ {me.initials} +
+
+ {me.name} (You) +
+
+ +
+ +
+ {peers.map(peer => ( +
+
+ {peer.initials} +
+
+ {peer.name} +
+
+ ))} +
+
@@ -187,7 +460,8 @@ export const Editor: React.FC = () => { theme={theme === 'dark' ? 'dark' : 'light'} initialData={initialData} onChange={handleCanvasChange} - excalidrawAPI={(api) => (excalidrawAPI.current = api)} + onPointerUpdate={onPointerUpdate} + excalidrawAPI={setExcalidrawAPI} UIOptions={UIOptions} /> diff --git a/frontend/src/utils/identity.ts b/frontend/src/utils/identity.ts new file mode 100644 index 0000000..94fe966 --- /dev/null +++ b/frontend/src/utils/identity.ts @@ -0,0 +1,102 @@ +export interface UserIdentity { + id: string; + name: string; + initials: string; + color: string; +} + +const TRANSFORMERS = [ + { name: "Optimus Prime", initials: "OP" }, + { name: "Megatron", initials: "ME" }, + { name: "Starscream", initials: "ST" }, + { name: "Bumblebee", initials: "BB" }, + { name: "Ultra Magnus", initials: "UM" }, + { name: "Shockwave", initials: "SH" }, + { name: "Soundwave", initials: "SW" }, + { name: "Ironhide", initials: "IR" }, + { name: "Ratchet", initials: "RA" }, + { name: "Prowl", initials: "PR" }, + { name: "Jazz", initials: "JA" }, + { name: "Hot Rod", initials: "HR" }, + { name: "Alpha Trion", initials: "AT" }, + { name: "Wheeljack", initials: "WH" }, + { name: "Sideswipe", initials: "SI" }, + { name: "Sunstreaker", initials: "SU" }, + { name: "Inferno", initials: "IN" }, + { name: "Grapple", initials: "GR" }, + { name: "Blaster", initials: "BL" }, + { name: "Perceptor", initials: "PE" }, + { name: "Trailbreaker", initials: "TR" }, + { name: "Cosmos", initials: "CO" }, + { name: "Warpath", initials: "WA" }, + { name: "Powerglide", initials: "PO" }, + { name: "Arcee", initials: "AR" }, + { name: "Springer", initials: "SP" }, + { name: "Kup", initials: "KU" }, + { name: "Blurr", initials: "BU" }, + { name: "Grimlock", initials: "GL" }, + { name: "Swoop", initials: "WO" }, + { name: "Skywarp", initials: "SK" }, + { name: "Thundercracker", initials: "TH" }, + { name: "Ramjet", initials: "AM" }, + { name: "Cyclonus", initials: "CY" }, + { name: "Scourge", initials: "SC" }, + { name: "Galvatron", initials: "GA" }, + { name: "Astrotrain", initials: "AS" }, + { name: "Blitzwing", initials: "BZ" }, + { name: "Rumble", initials: "RU" }, + { name: "Frenzy", initials: "FR" }, + { name: "Laserbeak", initials: "LA" }, + { name: "Ravage", initials: "RV" }, + { name: "Unicron", initials: "UN" }, + { name: "Devastator", initials: "DE" }, + { name: "Menasor", initials: "MN" }, + { name: "Bruticus", initials: "BR" }, + { name: "Motormaster", initials: "MO" }, + { name: "Scrapper", initials: "CR" }, + { name: "Mixmaster", initials: "MA" }, + { name: "Bonecrusher", initials: "BO" }, + { name: "Hook", initials: "HO" }, + { name: "Vortex", initials: "VO" }, + { name: "Swindle", initials: "WI" }, +]; + +const COLORS = [ + "#ef4444", // red-500 + "#f97316", // orange-500 + "#f59e0b", // amber-500 + "#84cc16", // lime-500 + "#22c55e", // green-500 + "#10b981", // emerald-500 + "#14b8a6", // teal-500 + "#06b6d4", // cyan-500 + "#0ea5e9", // sky-500 + "#3b82f6", // blue-500 + "#6366f1", // indigo-500 + "#8b5cf6", // violet-500 + "#a855f7", // purple-500 + "#d946ef", // fuchsia-500 + "#ec4899", // pink-500 + "#f43f5e", // rose-500 +]; + +export const getUserIdentity = (): UserIdentity => { + const stored = localStorage.getItem("excalidash-user-id"); + if (stored) { + return JSON.parse(stored); + } + + const randomTransformer = + TRANSFORMERS[Math.floor(Math.random() * TRANSFORMERS.length)]; + const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)]; + + const identity: UserIdentity = { + id: crypto.randomUUID(), + name: randomTransformer.name, + initials: randomTransformer.initials, + color: randomColor, + }; + + localStorage.setItem("excalidash-user-id", JSON.stringify(identity)); + return identity; +}; diff --git a/frontend/src/utils/sync.ts b/frontend/src/utils/sync.ts new file mode 100644 index 0000000..cdaa154 --- /dev/null +++ b/frontend/src/utils/sync.ts @@ -0,0 +1,58 @@ +export const reconcileElements = ( + localElements: readonly any[], + remoteElements: readonly any[] +): any[] => { + const localMap = new Map(); + + // Index local elements + localElements.forEach((el) => { + localMap.set(el.id, el); + }); + + // Merge remote elements + // Prefer version + updated timestamp to determine ordering; nonces are random. + const getVersion = (element: any) => element?.version ?? 0; + const getVersionNonce = (element: any) => element?.versionNonce ?? 0; + const getUpdated = (element: any) => { + const value = element?.updated; + return typeof value === 'number' ? value : Number(value) || 0; + }; + + remoteElements.forEach((remoteEl) => { + const localEl = localMap.get(remoteEl.id); + + if (!localEl) { + localMap.set(remoteEl.id, remoteEl); + return; + } + + const remoteVersion = getVersion(remoteEl); + const localVersion = getVersion(localEl); + + if (remoteVersion > localVersion) { + localMap.set(remoteEl.id, remoteEl); + return; + } + + if (remoteVersion < localVersion) { + return; + } + + const remoteUpdated = getUpdated(remoteEl); + const localUpdated = getUpdated(localEl); + + if (remoteUpdated > localUpdated) { + localMap.set(remoteEl.id, remoteEl); + return; + } + + if ( + remoteUpdated === localUpdated && + getVersionNonce(remoteEl) !== getVersionNonce(localEl) + ) { + localMap.set(remoteEl.id, remoteEl); + } + }); + + return Array.from(localMap.values()); +};