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 4fc93a6..3ccc57f 100644 Binary files a/backend/prisma/dev.db and b/backend/prisma/dev.db differ 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()); +};