working live collab

This commit is contained in:
Zimeng Xiong
2025-11-21 22:06:12 -08:00
parent 0878b5e87f
commit 9ee9d6ccfe
9 changed files with 960 additions and 37 deletions
+272 -4
View File
@@ -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",
+3 -1
View File
@@ -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",
Binary file not shown.
+86 -5
View File
@@ -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<string, User[]>();
io.on("connection", (socket) => {
socket.on(
"join-room",
({
drawingId,
user,
}: {
drawingId: string;
user: Omit<User, "socketId" | "isActive">;
}) => {
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}`);
});
+137
View File
@@ -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",
+1
View File
@@ -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"
},
+300 -26
View File
@@ -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,15 +42,171 @@ export const Editor: React.FC = () => {
const [newName, setNewName] = useState('');
const [initialData, setInitialData] = useState<any>(null);
const [peers, setPeers] = useState<Peer[]>([]);
const [me] = useState(getUserIdentity());
const [isReady, setIsReady] = useState(false);
const socketRef = useRef<Socket | null>(null);
const lastCursorEmit = useRef<number>(0);
const elementVersionMap = useRef<Map<string, ElementVersionInfo>>(new Map());
const isSyncing = useRef(false);
const cursorBuffer = useRef<Map<string, any>>(new Map());
const animationFrameId = useRef<number>(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<any>(null);
const setExcalidrawAPI = useCallback((api: any) => {
excalidrawAPI.current = api;
setIsReady(true);
}, []);
// ------------------------------------------------------------------
// 1. STABLE SAVE LOGIC (The Fix)
// We use a Ref to hold the save function so the debounce wrapper
// doesn't need to be recreated on every render.
// ------------------------------------------------------------------
const saveDataRef = useRef<(elements: any, appState: any) => Promise<void>>(null);
const savePreviewRef = useRef<(elements: any, appState: any, files: any) => Promise<void>>(null);
// Update the ref on every render to ensure it has access to the latest props/state
saveDataRef.current = async (elements, appState) => {
@@ -38,8 +218,21 @@ export const Editor: React.FC = () => {
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: {
@@ -51,14 +244,9 @@ export const Editor: React.FC = () => {
});
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 (
<div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden">
@@ -177,8 +417,41 @@ export const Editor: React.FC = () => {
)}
</div>
<div className="flex items-center gap-2">
{/* Status indicator removed */}
<div className="flex items-center gap-3">
<div className="flex items-center">
<div className="relative group">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold text-white shadow-sm"
style={{ backgroundColor: me.color }}
>
{me.initials}
</div>
<div className="absolute top-full mt-2 right-0 bg-gray-900 text-white text-xs py-1 px-2 rounded whitespace-nowrap z-50 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
{me.name} (You)
</div>
</div>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-700 mx-2" />
<div className="flex items-center gap-2">
{peers.map(peer => (
<div
key={peer.id}
className="relative group"
>
<div
className={`w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold text-white shadow-sm transition-all duration-300 ${!peer.isActive ? 'opacity-30 grayscale' : ''}`}
style={{ backgroundColor: peer.color }}
>
{peer.initials}
</div>
<div className="absolute top-full mt-2 right-0 bg-gray-900 text-white text-xs py-1 px-2 rounded whitespace-nowrap z-50 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
{peer.name}
</div>
</div>
))}
</div>
</div>
</div>
</header>
@@ -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}
/>
<Toaster position="bottom-center" />
+102
View File
@@ -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;
};
+58
View File
@@ -0,0 +1,58 @@
export const reconcileElements = (
localElements: readonly any[],
remoteElements: readonly any[]
): any[] => {
const localMap = new Map<string, any>();
// 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());
};