From d74ef75febb15e682c912ed0d1050f0c0bff0962 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sat, 26 Oct 2024 21:26:13 +0800 Subject: [PATCH 01/16] Set up real-time code editor --- backend/collab-service/app.ts | 2 +- backend/collab-service/package-lock.json | 215 +++- backend/collab-service/package.json | 3 + backend/collab-service/server.ts | 67 +- frontend/package-lock.json | 1034 +++++++++++++++++- frontend/package.json | 11 +- frontend/src/components/CodeEditor/index.tsx | 93 ++ frontend/src/pages/CollabSandbox/index.tsx | 6 +- frontend/src/utils/collabCursor.ts | 214 ++++ frontend/src/utils/collabSocket.ts | 143 +++ 10 files changed, 1775 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/CodeEditor/index.tsx create mode 100644 frontend/src/utils/collabCursor.ts create mode 100644 frontend/src/utils/collabSocket.ts diff --git a/backend/collab-service/app.ts b/backend/collab-service/app.ts index 3b35ab1247..0ccce35d8a 100644 --- a/backend/collab-service/app.ts +++ b/backend/collab-service/app.ts @@ -9,7 +9,7 @@ import collabRoutes from "./src/routes/collabRoutes.ts"; dotenv.config(); -const allowedOrigins = process.env.ORIGINS +export const allowedOrigins = process.env.ORIGINS ? process.env.ORIGINS.split(",") : ["http://localhost:5173", "http://127.0.0.1:5173"]; diff --git a/backend/collab-service/package-lock.json b/backend/collab-service/package-lock.json index 6a4b1388a1..3e7f67f819 100644 --- a/backend/collab-service/package-lock.json +++ b/backend/collab-service/package-lock.json @@ -9,11 +9,14 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@codemirror/collab": "^6.1.1", + "@codemirror/state": "^6.4.1", "axios": "^1.7.7", "body-parser": "^1.20.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", "yaml": "^2.6.0" }, @@ -700,6 +703,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@codemirror/collab": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@codemirror/collab/-/collab-6.1.1.tgz", + "integrity": "sha512-tkIn9Jguh98ie12dbBuba3lE8LHUkaMrIFuCVeVGhncSczFdKmX25vC12+58+yqQW5AXi3py6jWY0W+jelyglA==", + "dependencies": { + "@codemirror/state": "^6.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", @@ -1801,6 +1817,11 @@ "@sinonjs/commons": "^3.0.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==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1867,11 +1888,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, "node_modules/@types/cors": { "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1983,7 +2008,6 @@ "version": "22.7.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz", "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -2643,6 +2667,14 @@ "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==", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -3204,6 +3236,63 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", + "integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==", + "dependencies": { + "@types/cookie": "^0.4.1", + "@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==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "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==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -6114,6 +6203,107 @@ "node": ">=8" } }, + "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==", + "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==", + "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==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "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==", + "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==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "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==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6461,7 +6651,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -6622,6 +6811,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.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==", + "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/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/backend/collab-service/package.json b/backend/collab-service/package.json index 552a3b9808..8424e222c6 100644 --- a/backend/collab-service/package.json +++ b/backend/collab-service/package.json @@ -12,11 +12,14 @@ "license": "ISC", "description": "", "dependencies": { + "@codemirror/collab": "^6.1.1", + "@codemirror/state": "^6.4.1", "axios": "^1.7.7", "body-parser": "^1.20.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", "yaml": "^2.6.0" }, diff --git a/backend/collab-service/server.ts b/backend/collab-service/server.ts index 34443b0614..a11248cb49 100644 --- a/backend/collab-service/server.ts +++ b/backend/collab-service/server.ts @@ -1,9 +1,72 @@ -import app from "./app"; +import http from "http"; +import app, { allowedOrigins } from "./app.ts"; +import { Server, Socket } from "socket.io"; +import { ChangeSet, Text } from "@codemirror/state"; +import { Update } from "@codemirror/collab"; + +let updates: Update[] = []; +let doc = Text.of(["Start document"]); +let pending: ((value: any) => void)[] = []; + +const server = http.createServer(app); +export const io = new Server(server, { + cors: { + origin: allowedOrigins, + methods: ["GET", "POST"], + }, + connectionStateRecovery: {}, +}); + +io.on("connection", (socket: Socket) => { + socket.on("pullUpdates", (version: number) => { + if (version < updates.length) { + socket.emit("pullUpdateResponse", JSON.stringify(updates.slice(version))); + } else { + pending.push((updates) => { + socket.emit( + "pullUpdateResponse", + JSON.stringify(updates.slice(version)) + ); + }); + } + }); + + socket.on("pushUpdates", (version, docUpdates) => { + docUpdates = JSON.parse(docUpdates); + + try { + if (version != updates.length) { + socket.emit("pushUpdateResponse", false); + } else { + for (let update of docUpdates) { + let changes = ChangeSet.fromJSON(update.changes); + updates.push({ + changes, + clientID: update.clientID, + effects: update.effects, // cursor + }); + doc = changes.apply(doc); + } + socket.emit("pushUpdateResponse", true); + + while (pending.length) { + pending.pop()!(updates); + } + } + } catch (error) { + console.error(error); + } + }); + + socket.on("getDocument", () => { + socket.emit("getDocumentResponse", updates.length, doc.toString()); + }); +}); const PORT = process.env.SERVICE_PORT || 3003; if (process.env.NODE_ENV !== "test") { - app.listen(PORT, () => { + server.listen(PORT, () => { console.log(`Collab service server listening on http://localhost:${PORT}`); }); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5833bd7fd3..d6ab181bd5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,13 +11,19 @@ "@babel/preset-env": "^7.25.4", "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", + "@codemirror/collab": "^6.1.1", + "@codemirror/lang-javascript": "^6.2.2", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@fontsource/roboto": "^5.1.0", "@mui/icons-material": "^6.1.0", "@mui/material": "^6.1.0", + "@uiw/codemirror-extensions-basic-setup": "^4.23.6", + "@uiw/codemirror-extensions-langs": "^4.23.6", + "@uiw/react-codemirror": "^4.23.6", "@uiw/react-md-editor": "^4.0.4", "axios": "^1.7.7", + "codemirror": "^6.0.1", "history": "^5.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -26,7 +32,10 @@ "react-router-dom": "^6.3.0", "react-toastify": "^10.0.5", "socket.io-client": "^4.8.0", - "vite-plugin-svgr": "^4.2.0" + "vite-plugin-svgr": "^4.2.0", + "y-codemirror.next": "^0.3.5", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.20" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -1934,6 +1943,392 @@ "dev": true, "license": "MIT" }, + "node_modules/@codemirror/autocomplete": { + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz", + "integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/collab": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@codemirror/collab/-/collab-6.1.1.tgz", + "integrity": "sha512-tkIn9Jguh98ie12dbBuba3lE8LHUkaMrIFuCVeVGhncSczFdKmX25vC12+58+yqQW5AXi3py6jWY0W+jelyglA==", + "dependencies": { + "@codemirror/state": "^6.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz", + "integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-angular": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.3.tgz", + "integrity": "sha512-xgeWGJQQl1LyStvndWtruUvb4SnBZDAu/gvFH/ZU+c0W25tQR8e5hq7WTwiIY2dNxnf+49mRiGI/9yxIwB6f5w==", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.3" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.2.tgz", + "integrity": "sha512-6oYEYUKHvrnacXxWxYa6t4puTlbN3dgV662BDfSH8+MfjQjVmP697/KYTDOqpxgerkvoNm7q5wlFMBeX8ZMocg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz", + "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.1.tgz", + "integrity": "sha512-OOnmhH67h97jHzCuFaIEspbmsT98fNdhVhmA3zCxW0cn7l8rChDhZtwiwJ/JOKXgfm4J+ELxQihxaI7bj7mJRg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", + "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz", + "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-lezer": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-lezer/-/lang-lezer-6.0.1.tgz", + "integrity": "sha512-WHwjI7OqKFBEfkunohweqA5B/jIlxaZso6Nl3weVckz8EafYbPZldQEKSDb4QQ9H9BUkle4PVELP4sftKoA0uQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/lezer": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.2.1.tgz", + "integrity": "sha512-J1Mratcm6JLNEiX+U2OlCDTysGuwbHD76XwuL5o5bo9soJtSbz2g6RU3vGHFyS5DC8rgVmFSzi7i6oBftm7tnA==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.0.tgz", + "integrity": "sha512-lYrI8SdL/vhd0w0aHIEvIRLRecLF7MiiRfzXFZY94dFwHqC9HtgxgagJ8fyYNBldijGatf9wkms60d8SrAj6Nw==", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz", + "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.1.6.tgz", + "integrity": "sha512-ai+01WfZhWqM92UqjnvorkxosZ2aq2u28kHvr+N3gu012XqY2CThD67JPMHnGceRfXPDBmn1HnyqowdpF57bNg==", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.1.tgz", + "integrity": "sha512-344EMWFBzWArHWdZn/NcgkwMvZIWUR1GEBdwG8FEp++6o6vT6KL9V7vGs2ONsKxxFUPXKI0SPcWhyYyl2zPYxQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.8.0.tgz", + "integrity": "sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-wast": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", + "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.1.tgz", + "integrity": "sha512-HV2NzbK9bbVnjWxwObuZh5FuPCowx51mEfoFT9y3y+M37fA3+pbxx4I7uePuygFzDsAmCTwQSc/kXh/flab4uw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz", + "integrity": "sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/language-data": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.1.tgz", + "integrity": "sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==", + "dependencies": { + "@codemirror/lang-angular": "^0.1.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-less": "^6.0.0", + "@codemirror/lang-liquid": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/lang-sass": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-vue": "^0.1.1", + "@codemirror/lang-wast": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.4.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.1.tgz", + "integrity": "sha512-vdg3XY7OAs5uLDx2Iw+cGfnwtd7kM+Et/eMsqAGTfT/JKiVBQZXosTzjEbWAi/FrY6DcQIz8mQjBozFHZEUWQA==", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz", + "integrity": "sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.6", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", + "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", + "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.34.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.34.1.tgz", + "integrity": "sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ==", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -3455,6 +3850,175 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", + "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.2.tgz", + "integrity": "sha512-macwKtyeUO0EW86r3xWQCzOV9/CF8imJLpJlPv3sDY57cPGeUZ8gXWOWNlJr52TVByMV3PayFQCA5SHEERDmVQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.9.tgz", + "integrity": "sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.0.tgz", + "integrity": "sha512-co9JfT3QqX1YkrMmourYw2Z8meGC50Ko4d54QEcQbEYpvdUvN4yb0NBZdn/9ertgvjsySxHsKzH3lbm3vqJ4Jw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", + "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.19.tgz", + "integrity": "sha512-j44kbR1QL26l6dMunZ1uhKBFteVGLVCBGNUD2sUaMnic+rbTviVuoK0CD1l9FTW31EueWvFFswCKMH7Z+M3JRA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz", + "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lezer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@lezer/lezer/-/lezer-1.1.2.tgz", + "integrity": "sha512-O8yw3CxPhzYHB1hvwbdozjnAslhhR8A5BH7vfEMof0xk3p+/DFDfZkA9Tde6J+88WgtwaHy4Sy6ThZSkaI0Evw==", + "dependencies": { + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.1.tgz", + "integrity": "sha512-DGlzU/i8DC8k0uz1F+jeePrkATl0jWakauTzftMQOcbaMkHbNSRki/4E2tOzJWsVpoKYhe7iTJ03aepdwVUXUA==", + "dependencies": { + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz", + "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.14.tgz", + "integrity": "sha512-ykDOb2Ti24n76PJsSa4ZoDF0zH12BSw1LGfQXCYJhJyOGiFTfGaX0Du66Ze72R+u/P35U+O6I9m8TFXov1JzsA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.0.7.tgz", + "integrity": "sha512-8HLlOkuX/SMHOggI2DAsXUw38TuURe+3eQ5hiuk9QmYOUyC55B1dYEIMkav5A4IELVaW4e1T4P9WRiI5ka4mdw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.5.tgz", + "integrity": "sha512-VFouqOzmUWfIg+tfmpcdV33ewtK+NSwd4ngSe1aG7HFb4BN0ExyY1b8msp+ndFrnlG4V4iC8yXacjFtrwERnaw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz", + "integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.1.tgz", @@ -3676,6 +4240,23 @@ } } }, + "node_modules/@nextjournal/lang-clojure": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@nextjournal/lang-clojure/-/lang-clojure-1.0.0.tgz", + "integrity": "sha512-gOCV71XrYD0DhwGoPMWZmZ0r92/lIHsqQu9QWdpZYYBwiChNwMO4sbVMP7eTuAqffFB2BTtCSC+1skSH9d3bNg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@nextjournal/lezer-clojure": "1.0.0" + } + }, + "node_modules/@nextjournal/lezer-clojure": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@nextjournal/lezer-clojure/-/lezer-clojure-1.0.0.tgz", + "integrity": "sha512-VZyuGu4zw5mkTOwQBTaGVNWmsOZAPw5ZRxu1/Knk/Xfs7EDBIogwIs5UXTYkuECX5ZQB8eOB+wKA2pc7VyqaZQ==", + "dependencies": { + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3720,6 +4301,63 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@replit/codemirror-lang-csharp": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-csharp/-/codemirror-lang-csharp-6.2.0.tgz", + "integrity": "sha512-6utbaWkoymhoAXj051mkRp+VIJlpwUgCX9Toevz3YatiZsz512fw3OVCedXQx+WcR0wb6zVHjChnuxqfCLtFVQ==", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@replit/codemirror-lang-nix": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-nix/-/codemirror-lang-nix-6.0.1.tgz", + "integrity": "sha512-lvzjoYn9nfJzBD5qdm3Ut6G3+Or2wEacYIDJ49h9+19WSChVnxv4ojf+rNmQ78ncuxIt/bfbMvDLMeMP0xze6g==", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@replit/codemirror-lang-solidity": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-solidity/-/codemirror-lang-solidity-6.0.2.tgz", + "integrity": "sha512-/dpTVH338KFV6SaDYYSadkB4bI/0B0QRF/bkt1XS3t3QtyR49mn6+2k0OUQhvt2ZSO7kt10J+OPilRAtgbmX0w==", + "dependencies": { + "@lezer/highlight": "^1.2.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@replit/codemirror-lang-svelte": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-svelte/-/codemirror-lang-svelte-6.0.0.tgz", + "integrity": "sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.1", + "@codemirror/lang-html": "^6.2.0", + "@codemirror/lang-javascript": "^6.1.1", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/javascript": "^1.2.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.2.tgz", @@ -4974,6 +5612,73 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.23.6", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.6.tgz", + "integrity": "sha512-bvtq8IOvdkLJMhoJBRGPEzU51fMpPDwEhcAHp9xCR05MtbIokQgsnLXrmD1aZm6e7s/3q47H+qdSfAAkR5MkLA==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/codemirror-extensions-langs": { + "version": "4.23.6", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-langs/-/codemirror-extensions-langs-4.23.6.tgz", + "integrity": "sha512-VKWbEXmVq3EFYrJPWXH4Ei1f92zxuAg6dOlo8suSmwjmEc0qjNEP5Ss2CUi9LlzuWMGMmZgdKw56I3L71wYOog==", + "dependencies": { + "@codemirror/lang-angular": "^0.1.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.2.0", + "@codemirror/lang-html": "^6.4.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-less": "^6.0.1", + "@codemirror/lang-lezer": "^6.0.0", + "@codemirror/lang-liquid": "^6.0.1", + "@codemirror/lang-markdown": "^6.1.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-python": "^6.1.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/lang-sass": "^6.0.1", + "@codemirror/lang-sql": "^6.4.0", + "@codemirror/lang-vue": "^0.1.1", + "@codemirror/lang-wast": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/language-data": ">=6.0.0", + "@codemirror/legacy-modes": ">=6.0.0", + "@nextjournal/lang-clojure": "^1.0.0", + "@replit/codemirror-lang-csharp": "^6.1.0", + "@replit/codemirror-lang-nix": "^6.0.1", + "@replit/codemirror-lang-solidity": "^6.0.1", + "@replit/codemirror-lang-svelte": "^6.0.0", + "codemirror-lang-mermaid": "^0.5.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/language-data": ">=6.0.0", + "@codemirror/legacy-modes": ">=6.0.0" + } + }, "node_modules/@uiw/copy-to-clipboard": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@uiw/copy-to-clipboard/-/copy-to-clipboard-1.0.17.tgz", @@ -4982,6 +5687,31 @@ "url": "https://jaywcjlove.github.io/#/sponsor" } }, + "node_modules/@uiw/react-codemirror": { + "version": "4.23.6", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.23.6.tgz", + "integrity": "sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.23.6", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@uiw/react-markdown-preview": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/@uiw/react-markdown-preview/-/react-markdown-preview-5.1.3.tgz", @@ -5477,6 +6207,25 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/bcp-47-match": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", @@ -5554,6 +6303,29 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5731,6 +6503,30 @@ "node": ">= 0.12.0" } }, + "node_modules/codemirror": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", + "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/codemirror-lang-mermaid": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/codemirror-lang-mermaid/-/codemirror-lang-mermaid-0.5.0.tgz", + "integrity": "sha512-Taw/2gPCyNArQJCxIP/HSUif+3zrvD+6Ugt7KJZ2dUKou/8r3ZhcfG8krNTZfV2iu8AuGnymKuo7bLPFyqsh/A==", + "dependencies": { + "@codemirror/language": "^6.9.0", + "@lezer/highlight": "^1.1.6", + "@lezer/lr": "^1.3.10" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -5915,6 +6711,11 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -6249,6 +7050,11 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/err-code": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", + "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==" + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -6923,6 +7729,11 @@ "node": ">=6.9.0" } }, + "node_modules/get-browser-rtc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz", + "integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -7398,6 +8209,25 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7477,7 +8307,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -7640,6 +8469,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -9604,6 +10442,26 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.98", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.98.tgz", + "integrity": "sha512-XteTiNO0qEXqqweWx+b21p/fBnNHUA1NwAtJNJek1oPrewEZs2uiT4gWivHKr9GqCjDPAhchz0UQO8NwU3bBNA==", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -11177,7 +12035,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -11193,6 +12050,14 @@ } ] }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -11557,6 +12422,19 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -12068,6 +12946,25 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -12132,6 +13029,34 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-peer": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.11.1.tgz", + "integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "buffer": "^6.0.3", + "debug": "^4.3.2", + "err-code": "^3.0.1", + "get-browser-rtc": "^1.1.0", + "queue-microtask": "^1.2.3", + "randombytes": "^2.1.0", + "readable-stream": "^3.6.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -12260,6 +13185,14 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -12359,6 +13292,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" + }, "node_modules/style-to-object": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", @@ -12839,6 +13777,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -12978,6 +13921,11 @@ "vite": "^2.6.0 || 3 || 4 || 5" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -13160,7 +14108,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -13203,6 +14151,68 @@ "node": ">=0.4.0" } }, + "node_modules/y-codemirror.next": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/y-codemirror.next/-/y-codemirror.next-0.3.5.tgz", + "integrity": "sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==", + "dependencies": { + "lib0": "^0.2.42" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "yjs": "^13.5.6" + } + }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-webrtc": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.3.0.tgz", + "integrity": "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==", + "dependencies": { + "lib0": "^0.2.42", + "simple-peer": "^9.11.0", + "y-protocols": "^1.0.6" + }, + "bin": { + "y-webrtc-signaling": "bin/server.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^8.14.2" + }, + "peerDependencies": { + "yjs": "^13.6.8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -13255,6 +14265,22 @@ "node": ">=12" } }, + "node_modules/yjs": { + "version": "13.6.20", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.20.tgz", + "integrity": "sha512-Z2YZI+SYqK7XdWlloI3lhMiKnCdFCVC4PchpdO+mCYwtiTwncjUbnRK9R1JmkNfdmHyDXuWN3ibJAt0wsqTbLQ==", + "dependencies": { + "lib0": "^0.2.98" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7f0aba9259..8629dd89f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,13 +15,19 @@ "@babel/preset-env": "^7.25.4", "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", + "@codemirror/collab": "^6.1.1", + "@codemirror/lang-javascript": "^6.2.2", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@fontsource/roboto": "^5.1.0", "@mui/icons-material": "^6.1.0", "@mui/material": "^6.1.0", + "@uiw/codemirror-extensions-basic-setup": "^4.23.6", + "@uiw/codemirror-extensions-langs": "^4.23.6", + "@uiw/react-codemirror": "^4.23.6", "@uiw/react-md-editor": "^4.0.4", "axios": "^1.7.7", + "codemirror": "^6.0.1", "history": "^5.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -30,7 +36,10 @@ "react-router-dom": "^6.3.0", "react-toastify": "^10.0.5", "socket.io-client": "^4.8.0", - "vite-plugin-svgr": "^4.2.0" + "vite-plugin-svgr": "^4.2.0", + "y-codemirror.next": "^0.3.5", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.20" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx new file mode 100644 index 0000000000..ce8c54ca0c --- /dev/null +++ b/frontend/src/components/CodeEditor/index.tsx @@ -0,0 +1,93 @@ +import CodeMirror from "@uiw/react-codemirror"; +import { langs } from "@uiw/codemirror-extensions-langs"; +import { basicSetup } from "@uiw/codemirror-extensions-basic-setup"; +import { useEffect, useState } from "react"; +import { + collabSocket, + getDocument, + peerExtension, +} from "../../utils/collabSocket"; +import Loader from "../Loader"; +import { cursorExtension } from "../../utils/collabCursor"; + +interface CodeEditorProps { + username: string; +} + +type EditorState = { + connected: boolean; + version: number | null; + doc: string | null; +}; + +const CodeEditor: React.FC = (props) => { + const { username } = props; + + const [editorState, setEditorState] = useState({ + connected: false, + version: null, + doc: null, + }); + + useEffect(() => { + const fetchDocument = async () => { + try { + const { version, doc } = await getDocument(); + setEditorState((prevState) => ({ + ...prevState, + version: version, + doc: doc.toString(), + })); + + collabSocket.on("connect", () => { + setEditorState((prevState) => ({ + ...prevState, + connected: true, + })); + }); + + collabSocket.on("disconnect", () => { + setEditorState((prevState) => ({ + ...prevState, + connected: false, + })); + }); + } catch (error) { + console.error("Error fetching document: ", error); + } + }; + + fetchDocument(); + + return () => { + collabSocket.off("connect"); + collabSocket.off("disconnect"); + collabSocket.off("pullUpdateResponse"); + collabSocket.off("pushUpdateResponse"); + collabSocket.off("getDocumentResponse"); + }; + }, []); + + if (editorState.version === null || editorState.doc === null) { + return ; + } + + return ( + + ); +}; + +export default CodeEditor; diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index 3bc93e2170..18e9018f87 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -6,13 +6,14 @@ import { USE_MATCH_ERROR_MESSAGE } from "../../utils/constants"; import { useEffect } from "react"; import Loader from "../../components/Loader"; import ServerError from "../../components/ServerError"; +import CodeEditor from "../../components/CodeEditor"; const CollabSandbox: React.FC = () => { const match = useMatch(); if (!match) { throw new Error(USE_MATCH_ERROR_MESSAGE); } - const { stopMatch, verifyMatchStatus, partner, loading } = match; + const { stopMatch, verifyMatchStatus, matchUser, partner, loading } = match; useEffect(() => { verifyMatchStatus(); @@ -23,7 +24,7 @@ const CollabSandbox: React.FC = () => { return ; } - if (!partner) { + if (!matchUser || !partner) { return ( { Successfully matched! + diff --git a/frontend/src/utils/collabCursor.ts b/frontend/src/utils/collabCursor.ts new file mode 100644 index 0000000000..81aadaa956 --- /dev/null +++ b/frontend/src/utils/collabCursor.ts @@ -0,0 +1,214 @@ +import { + EditorView, + Decoration, + DecorationSet, + WidgetType, +} from "@codemirror/view"; +import { StateField, StateEffect } from "@codemirror/state"; + +export interface Cursor { + id: string; + from: number; + to: number; +} + +export interface Cursors { + cursors: Cursor[]; +} + +class TooltipWidget extends WidgetType { + private name = "John"; + private suffix = ""; + + constructor(name: string, color: number) { + super(); + this.suffix = `${(color % 8) + 1}`; + this.name = name; + } + + toDOM() { + const dom = document.createElement("div"); + dom.className = "cm-tooltip-none"; + + const cursor_tooltip = document.createElement("div"); + cursor_tooltip.className = `cm-tooltip-cursor cm-tooltip cm-tooltip-above cm-tooltip-${this.suffix}`; + cursor_tooltip.textContent = this.name; + + const cursor_tooltip_arrow = document.createElement("div"); + cursor_tooltip_arrow.className = "cm-tooltip-arrow"; + + cursor_tooltip.appendChild(cursor_tooltip_arrow); + dom.appendChild(cursor_tooltip); + return dom; + } + + ignoreEvent() { + return false; + } +} + +export const addCursor = StateEffect.define(); +export const removeCursor = StateEffect.define(); + +const cursorsItems = new Map(); + +const cursorField = StateField.define({ + create() { + return Decoration.none; + }, + update(cursors, tr) { + let cursorTransacions = cursors.map(tr.changes); + for (const e of tr.effects) + if (e.is(addCursor)) { + const addUpdates = []; + if (!cursorsItems.has(e.value.id)) + cursorsItems.set(e.value.id, cursorsItems.size); + + if (e.value.from !== e.value.to) { + addUpdates.push( + Decoration.mark({ + class: `cm-highlight-${(cursorsItems.get(e.value.id)! % 8) + 1}`, + id: e.value.id, + }).range(e.value.from, e.value.to) + ); + } + + addUpdates.push( + Decoration.widget({ + widget: new TooltipWidget( + e.value.id, + cursorsItems.get(e.value.id)! + ), + block: false, + id: e.value.id, + }).range(e.value.to, e.value.to) + ); + + cursorTransacions = cursorTransacions.update({ + add: addUpdates, + filter: (_from, _to, value) => { + if (value?.spec?.id === e.value.id) return false; + return true; + }, + }); + } + + return cursorTransacions; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +const cursorBaseTheme = EditorView.baseTheme({ + ".cm-tooltip.cm-tooltip-cursor": { + color: "white", + border: "none", + padding: "2px 7px", + borderRadius: "4px", + position: "absolute", + marginTop: "-40px", + marginLeft: "-14px", + "& .cm-tooltip-arrow:after": { + borderTopColor: "transparent", + }, + zIndex: "1000000", + }, + ".cm-tooltip-none": { + width: "0px", + height: "0px", + display: "inline-block", + }, + ".cm-highlight-1": { + backgroundColor: "#6666BB55", + }, + ".cm-highlight-2": { + backgroundColor: "#F76E6E55", + }, + ".cm-highlight-3": { + backgroundColor: "#0CDA6255", + }, + ".cm-highlight-4": { + backgroundColor: "#0CC5DA55", + }, + ".cm-highlight-5": { + backgroundColor: "#0C51DA55", + }, + ".cm-highlight-6": { + backgroundColor: "#980CDA55", + }, + ".cm-highlight-7": { + backgroundColor: "#DA0CBB55", + }, + ".cm-highlight-8": { + backgroundColor: "#DA800C55", + }, + ".cm-tooltip-1": { + backgroundColor: "#66b !important", + "& .cm-tooltip-arrow:before": { + borderTopColor: "#66b !important", + }, + }, + ".cm-tooltip-2": { + backgroundColor: "#F76E6E !important", + "& .cm-tooltip-arrow:before": { + borderTopColor: "#F76E6E !important", + }, + }, + ".cm-tooltip-3": { + backgroundColor: "#0CDA62 !important", + "& .cm-tooltip-arrow:before": { + borderTopColor: "#0CDA62 !important", + }, + }, + ".cm-tooltip-4": { + backgroundColor: "#0CC5DA !important", + "& .cm-tooltip-arrow:before": { + borderTopColor: "#0CC5DA !important", + }, + }, + ".cm-tooltip-5": { + backgroundColor: "#0C51DA !important", + "& .cm-tooltip-arrow:before": { + borderTopColor: "#0C51DA !important", + }, + }, + ".cm-tooltip-6": { + backgroundColor: "#980CDA !important", + "& .cm-tooltip-arrow:before": { + borderTopColor: "#980CDA !important", + }, + }, + ".cm-tooltip-7": { + backgroundColor: "#DA0CBB !important", + "& .cm-tooltip-arrow:before": { + borderTopColor: "#DA0CBB !important", + }, + }, + ".cm-tooltip-8": { + backgroundColor: "#DA800C !important", + "& .cm-tooltip-arrow:before": { + borderTopColor: "#DA800C !important", + }, + }, +}); + +export const cursorExtension = (id: string = "") => { + return [ + cursorField, + cursorBaseTheme, + EditorView.updateListener.of((update) => { + update.transactions.forEach((e) => { + if (e.selection) { + const cursor: Cursor = { + id, + from: e.selection.ranges[0].from, + to: e.selection.ranges[0].to, + }; + + update.view.dispatch({ + effects: addCursor.of(cursor), + }); + } + }); + }), + ]; +}; diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts new file mode 100644 index 0000000000..8d366f0516 --- /dev/null +++ b/frontend/src/utils/collabSocket.ts @@ -0,0 +1,143 @@ +import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; +import { Text, ChangeSet, StateEffect } from "@codemirror/state"; +import { + Update, + receiveUpdates, + sendableUpdates, + collab, + getSyncedVersion, +} from "@codemirror/collab"; +import { io } from "socket.io-client"; +import { addCursor, Cursor, removeCursor } from "./collabCursor"; + +export const collabSocket = io("http://localhost:3003"); + +const pushUpdates = ( + version: number, + fullUpdates: readonly Update[] +): Promise => { + const updates = fullUpdates.map((u) => ({ + clientID: u.clientID, + changes: u.changes.toJSON(), + effects: u.effects, // cursor + })); + + return new Promise(function (resolve) { + collabSocket.emit("pushUpdates", version, JSON.stringify(updates)); + + collabSocket.once("pushUpdateResponse", (status: boolean) => { + resolve(status); + }); + }); +}; + +const pullUpdates = (version: number): Promise => { + return new Promise(function (resolve) { + collabSocket.emit("pullUpdates", version); + + collabSocket.once("pullUpdateResponse", (updates: any) => { + resolve(JSON.parse(updates)); + }); + }).then((updates: any) => + // updates.map((u: any) => ({ + // changes: ChangeSet.fromJSON(u.changes), + // clientID: u.clientID, + // })) + + updates.map((u: any) => { + const effects: StateEffect[] = []; + if (u.effects?.length) { + u.effects.forEach((effect: StateEffect) => { + if (effect.value?.id && effect.value?.from) { + const cursor: Cursor = { + id: effect.value.id, + from: effect.value.from, + to: effect.value.to, + }; + effects.push(addCursor.of(cursor)); + } else if (effect.value?.id) { + const cursorId = effect.value.id; + effects.push(removeCursor.of(cursorId)); + } + }); + } + + return { + changes: ChangeSet.fromJSON(u.changes), + clientID: u.clientID, + effects: effects, + }; + }) + ); +}; + +export const getDocument = (): Promise<{ version: number; doc: Text }> => { + return new Promise(function (resolve) { + collabSocket.emit("getDocument"); + + collabSocket.once("getDocumentResponse", (version: number, doc: string) => { + resolve({ + version: version, + doc: Text.of(doc.split("\n")), + }); + }); + }); +}; + +export const peerExtension = (startVersion: number, id?: string) => { + const plugin = ViewPlugin.fromClass( + class { + private pushing = false; + private done = false; + + constructor(private view: EditorView) { + this.pull(); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.transactions.length) { + // cursor + // if (update.docChanged) { + this.push(); + } + } + + async push() { + const updates = sendableUpdates(this.view.state); + if (this.pushing || !updates.length) { + return; + } + this.pushing = true; + const version = getSyncedVersion(this.view.state); + await pushUpdates(version, updates); + this.pushing = false; + if (sendableUpdates(this.view.state).length) { + setTimeout(() => this.push(), 100); + } + } + + async pull() { + while (!this.done) { + const version = getSyncedVersion(this.view.state); + const updates = await pullUpdates(version); + this.view.dispatch(receiveUpdates(this.view.state, updates)); + } + } + + destroy() { + this.done = true; + } + } + ); + + // return [collab({ startVersion }), plugin]; + return [ + collab({ + startVersion, + clientID: id, + sharedEffects: (tr) => + tr.effects.filter((e) => e.is(addCursor) || e.is(removeCursor)), + }), + plugin, + ]; +}; From c9c054e7a83b8e07050644e66443c28865958c8a Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Thu, 31 Oct 2024 23:15:43 +0800 Subject: [PATCH 02/16] Allow code editor to be read only --- .../src/handlers/websocketHandler.ts | 80 ++++++++++ backend/collab-service/src/server.ts | 50 ------ frontend/src/components/CodeEditor/index.tsx | 48 ++---- frontend/src/pages/CollabSandbox/index.tsx | 5 +- frontend/src/utils/collabCursor.ts | 6 +- frontend/src/utils/collabSocket.ts | 146 ++++++++++-------- 6 files changed, 182 insertions(+), 153 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 5a806a70d4..ba760d5cc7 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -1,6 +1,8 @@ import { Socket } from "socket.io"; import { io } from "../server"; import redisClient from "../config/redis"; +import { ChangeSet, Text } from "@codemirror/state"; +import { rebaseUpdates, Update } from "@codemirror/collab"; enum CollabEvents { // Receive @@ -9,6 +11,10 @@ enum CollabEvents { LEAVE = "leave", DISCONNECT = "disconnect", + PUSH_UPDATES = "push_updates", + PULL_UPDATES = "pull_updates", + GET_DOCUMENT = "get_document", + // Send ROOM_FULL = "room_full", CONNECTED = "connected", @@ -16,6 +22,9 @@ enum CollabEvents { CODE_CHANGE = "code_change", PARTNER_LEFT = "partner_left", PARTNER_DISCONNECTED = "partner_disconnected", + + PULL_UPDATES_RESPONSE = "pull_updates_response", + GET_DOCUMENT_RESPONSE = "get_document_response", } const EXPIRY_TIME = 3600; @@ -69,4 +78,75 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED); } }); + + handleCodeEditorEvents(socket); +}; + +/* Code Editor Events */ +// Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets + +let updates: Update[] = []; // updates.length = current version +let doc = Text.of(["Start document"]); +let pendingPullUpdatesRequests: ((updates: Update[]) => void)[] = []; + +const handleCodeEditorEvents = (socket: Socket) => { + socket.on(CollabEvents.PULL_UPDATES, (version: number) => { + if (version < updates.length) { + // send the new updates + socket.emit( + CollabEvents.PULL_UPDATES_RESPONSE, + JSON.stringify(updates.slice(version)) + ); + } else { + // wait until there are new updates to send + pendingPullUpdatesRequests.push((updates) => { + socket.emit( + CollabEvents.PULL_UPDATES_RESPONSE, + JSON.stringify(updates.slice(version)) + ); + }); + } + }); + + // received new updates, notify any pending pullUpdates requests + socket.on( + CollabEvents.PUSH_UPDATES, + (version: number, newUpdates: string, callback: () => void) => { + let docUpdates = JSON.parse(newUpdates) as readonly Update[]; + + try { + // If the given version is the latest version, apply the new updates. + // Else, rebase updates first. + if (version != updates.length) { + docUpdates = rebaseUpdates(docUpdates, updates.slice(version)); + } + + for (const update of docUpdates) { + const changes = ChangeSet.fromJSON(update.changes); + updates.push({ + clientID: update.clientID, + changes: changes, + effects: update.effects, + }); + doc = changes.apply(doc); + } + callback(); + + while (pendingPullUpdatesRequests.length) { + pendingPullUpdatesRequests.pop()!(updates); + } + } catch (error) { + console.error(error); + callback(); + } + } + ); + + socket.on(CollabEvents.GET_DOCUMENT, () => { + socket.emit( + CollabEvents.GET_DOCUMENT_RESPONSE, + updates.length, + doc.toString() + ); + }); }; diff --git a/backend/collab-service/src/server.ts b/backend/collab-service/src/server.ts index b00a479e02..c1d11c7333 100644 --- a/backend/collab-service/src/server.ts +++ b/backend/collab-service/src/server.ts @@ -3,12 +3,6 @@ import app, { allowedOrigins } from "./app.ts"; import { handleWebsocketCollabEvents } from "./handlers/websocketHandler.ts"; import { Server, Socket } from "socket.io"; import { connectRedis } from "./config/redis.ts"; -import { ChangeSet, Text } from "@codemirror/state"; -import { Update } from "@codemirror/collab"; - -let updates: Update[] = []; -let doc = Text.of(["Start document"]); -let pending: ((value: any) => void)[] = []; const server = http.createServer(app); export const io = new Server(server, { @@ -21,50 +15,6 @@ export const io = new Server(server, { io.on("connection", (socket: Socket) => { handleWebsocketCollabEvents(socket); - - socket.on("pullUpdates", (version: number) => { - if (version < updates.length) { - socket.emit("pullUpdateResponse", JSON.stringify(updates.slice(version))); - } else { - pending.push((updates) => { - socket.emit( - "pullUpdateResponse", - JSON.stringify(updates.slice(version)) - ); - }); - } - }); - - socket.on("pushUpdates", (version, docUpdates) => { - docUpdates = JSON.parse(docUpdates); - - try { - if (version != updates.length) { - socket.emit("pushUpdateResponse", false); - } else { - for (let update of docUpdates) { - let changes = ChangeSet.fromJSON(update.changes); - updates.push({ - changes, - clientID: update.clientID, - effects: update.effects, // cursor - }); - doc = changes.apply(doc); - } - socket.emit("pushUpdateResponse", true); - - while (pending.length) { - pending.pop()!(updates); - } - } - } catch (error) { - console.error(error); - } - }); - - socket.on("getDocument", () => { - socket.emit("getDocumentResponse", updates.length, doc.toString()); - }); }); const PORT = process.env.SERVICE_PORT || 3003; diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index ce8c54ca0c..0140f189f2 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -1,30 +1,32 @@ import CodeMirror from "@uiw/react-codemirror"; import { langs } from "@uiw/codemirror-extensions-langs"; import { basicSetup } from "@uiw/codemirror-extensions-basic-setup"; +import { EditorView } from "@codemirror/view"; +import { EditorState } from "@codemirror/state"; import { useEffect, useState } from "react"; import { - collabSocket, getDocument, peerExtension, + removeListeners, } from "../../utils/collabSocket"; import Loader from "../Loader"; import { cursorExtension } from "../../utils/collabCursor"; interface CodeEditorProps { + uid: string; username: string; + isReadOnly?: boolean; } -type EditorState = { - connected: boolean; +type CodeEditorState = { version: number | null; doc: string | null; }; const CodeEditor: React.FC = (props) => { - const { username } = props; + const { uid, username, isReadOnly = false } = props; - const [editorState, setEditorState] = useState({ - connected: false, + const [codeEditorState, setCodeEditorState] = useState({ version: null, doc: null, }); @@ -33,24 +35,9 @@ const CodeEditor: React.FC = (props) => { const fetchDocument = async () => { try { const { version, doc } = await getDocument(); - setEditorState((prevState) => ({ - ...prevState, + setCodeEditorState({ version: version, doc: doc.toString(), - })); - - collabSocket.on("connect", () => { - setEditorState((prevState) => ({ - ...prevState, - connected: true, - })); - }); - - collabSocket.on("disconnect", () => { - setEditorState((prevState) => ({ - ...prevState, - connected: false, - })); }); } catch (error) { console.error("Error fetching document: ", error); @@ -59,16 +46,10 @@ const CodeEditor: React.FC = (props) => { fetchDocument(); - return () => { - collabSocket.off("connect"); - collabSocket.off("disconnect"); - collabSocket.off("pullUpdateResponse"); - collabSocket.off("pushUpdateResponse"); - collabSocket.off("getDocumentResponse"); - }; + return () => removeListeners(); }, []); - if (editorState.version === null || editorState.doc === null) { + if (codeEditorState.version === null || codeEditorState.doc === null) { return ; } @@ -81,11 +62,12 @@ const CodeEditor: React.FC = (props) => { extensions={[ basicSetup(), langs.c(), - // peerExtension(editorState.version), - peerExtension(editorState.version, username), + peerExtension(codeEditorState.version, uid), cursorExtension(username), + EditorView.editable.of(!isReadOnly), + EditorState.readOnly.of(isReadOnly), ]} - value={editorState.doc} + value={codeEditorState.doc} /> ); }; diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index 1f61e755fb..b858392371 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -100,9 +100,6 @@ const CollabSandbox: React.FC = () => { return ( - {/* - Successfully matched! - */} { sx={{ display: "flex", flexDirection: "column", height: "100%" }} > - + Test cases and chat tabs diff --git a/frontend/src/utils/collabCursor.ts b/frontend/src/utils/collabCursor.ts index 81aadaa956..f3621a4079 100644 --- a/frontend/src/utils/collabCursor.ts +++ b/frontend/src/utils/collabCursor.ts @@ -6,6 +6,8 @@ import { } from "@codemirror/view"; import { StateField, StateEffect } from "@codemirror/state"; +// Adapted from https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets + export interface Cursor { id: string; from: number; @@ -191,7 +193,7 @@ const cursorBaseTheme = EditorView.baseTheme({ }, }); -export const cursorExtension = (id: string = "") => { +export const cursorExtension = (username: string) => { return [ cursorField, cursorBaseTheme, @@ -199,7 +201,7 @@ export const cursorExtension = (id: string = "") => { update.transactions.forEach((e) => { if (e.selection) { const cursor: Cursor = { - id, + id: username, from: e.selection.ranges[0].from, to: e.selection.ranges[0].to, }; diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 8d366f0516..78a23597a0 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -10,61 +10,69 @@ import { import { io } from "socket.io-client"; import { addCursor, Cursor, removeCursor } from "./collabCursor"; -export const collabSocket = io("http://localhost:3003"); +// Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets + +enum CollabEvents { + // Send + PUSH_UPDATES = "push_updates", + PULL_UPDATES = "pull_updates", + GET_DOCUMENT = "get_document", + + // Receive + PULL_UPDATES_RESPONSE = "pull_updates_response", + GET_DOCUMENT_RESPONSE = "get_document_response", +} + +const collabSocket = io("http://localhost:3003"); const pushUpdates = ( version: number, fullUpdates: readonly Update[] -): Promise => { - const updates = fullUpdates.map((u) => ({ - clientID: u.clientID, - changes: u.changes.toJSON(), - effects: u.effects, // cursor +): Promise => { + const updates = fullUpdates.map((update) => ({ + clientID: update.clientID, // client who made the update + changes: update.changes.toJSON(), // document updates + effects: update.effects, // cursor updates })); - return new Promise(function (resolve) { - collabSocket.emit("pushUpdates", version, JSON.stringify(updates)); - - collabSocket.once("pushUpdateResponse", (status: boolean) => { - resolve(status); - }); + return new Promise((resolve) => { + collabSocket.emit( + CollabEvents.PUSH_UPDATES, + version, + JSON.stringify(updates), + () => resolve() + ); }); }; const pullUpdates = (version: number): Promise => { - return new Promise(function (resolve) { - collabSocket.emit("pullUpdates", version); + return new Promise((resolve) => { + collabSocket.emit(CollabEvents.PULL_UPDATES, version); - collabSocket.once("pullUpdateResponse", (updates: any) => { + collabSocket.once(CollabEvents.PULL_UPDATES_RESPONSE, (updates: string) => { resolve(JSON.parse(updates)); }); - }).then((updates: any) => - // updates.map((u: any) => ({ - // changes: ChangeSet.fromJSON(u.changes), - // clientID: u.clientID, - // })) - - updates.map((u: any) => { + }).then((updates) => + updates.map((update) => { const effects: StateEffect[] = []; - if (u.effects?.length) { - u.effects.forEach((effect: StateEffect) => { - if (effect.value?.id && effect.value?.from) { - const cursor: Cursor = { - id: effect.value.id, - from: effect.value.from, - to: effect.value.to, - }; - effects.push(addCursor.of(cursor)); - } else if (effect.value?.id) { - const cursorId = effect.value.id; - effects.push(removeCursor.of(cursorId)); - } - }); - } + + update.effects?.forEach((effect) => { + if (effect.value?.id && effect.value?.from) { + const cursor: Cursor = { + id: effect.value.id, + from: effect.value.from, + to: effect.value.to, + }; + effects.push(addCursor.of(cursor)); + } else if (effect.value?.id) { + const cursorId = effect.value.id; + effects.push(removeCursor.of(cursorId)); + } + }); return { - changes: ChangeSet.fromJSON(u.changes), - clientID: u.clientID, + clientID: update.clientID, + changes: ChangeSet.fromJSON(update.changes), effects: effects, }; }) @@ -72,23 +80,27 @@ const pullUpdates = (version: number): Promise => { }; export const getDocument = (): Promise<{ version: number; doc: Text }> => { - return new Promise(function (resolve) { - collabSocket.emit("getDocument"); - - collabSocket.once("getDocumentResponse", (version: number, doc: string) => { - resolve({ - version: version, - doc: Text.of(doc.split("\n")), - }); - }); + return new Promise((resolve) => { + collabSocket.emit(CollabEvents.GET_DOCUMENT); + + collabSocket.once( + CollabEvents.GET_DOCUMENT_RESPONSE, + (version: number, doc: string) => { + resolve({ + version: version, + doc: Text.of(doc.split("\n")), + }); + } + ); }); }; -export const peerExtension = (startVersion: number, id?: string) => { +// handles push and pull updates +export const peerExtension = (startVersion: number, uid: string) => { const plugin = ViewPlugin.fromClass( class { - private pushing = false; - private done = false; + private pushingUpdates = false; // to ensure only one running push request + private pullUpdates = true; constructor(private view: EditorView) { this.pull(); @@ -96,48 +108,54 @@ export const peerExtension = (startVersion: number, id?: string) => { update(update: ViewUpdate) { if (update.docChanged || update.transactions.length) { - // cursor - // if (update.docChanged) { this.push(); } } async push() { const updates = sendableUpdates(this.view.state); - if (this.pushing || !updates.length) { + if (this.pushingUpdates || !updates.length) { return; } - this.pushing = true; + this.pushingUpdates = true; const version = getSyncedVersion(this.view.state); await pushUpdates(version, updates); - this.pushing = false; + this.pushingUpdates = false; + + // check if there are still updates to push (failed / new updates) if (sendableUpdates(this.view.state).length) { setTimeout(() => this.push(), 100); } } async pull() { - while (!this.done) { + while (this.pullUpdates) { const version = getSyncedVersion(this.view.state); - const updates = await pullUpdates(version); + const updates = await pullUpdates(version); // returns only if there are updates this.view.dispatch(receiveUpdates(this.view.state, updates)); } } destroy() { - this.done = true; + this.pullUpdates = false; } } ); - // return [collab({ startVersion }), plugin]; return [ collab({ - startVersion, - clientID: id, - sharedEffects: (tr) => - tr.effects.filter((e) => e.is(addCursor) || e.is(removeCursor)), + startVersion: startVersion, + clientID: uid, + sharedEffects: (transaction) => + transaction.effects.filter( + (effect) => effect.is(addCursor) || effect.is(removeCursor) + ), }), plugin, ]; }; + +export const removeListeners = () => { + collabSocket.off(CollabEvents.PULL_UPDATES_RESPONSE); + collabSocket.off(CollabEvents.GET_DOCUMENT_RESPONSE); +}; From 129add10e123119d0f3a6cda00f4903c73c72011 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Fri, 1 Nov 2024 01:04:03 +0800 Subject: [PATCH 03/16] Differentiate collab cursor by uid --- frontend/src/components/CodeEditor/index.tsx | 2 +- frontend/src/utils/collabCursor.ts | 83 +++++++++----------- frontend/src/utils/collabSocket.ts | 16 ++-- 3 files changed, 46 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 0140f189f2..553b1bb30b 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -63,7 +63,7 @@ const CodeEditor: React.FC = (props) => { basicSetup(), langs.c(), peerExtension(codeEditorState.version, uid), - cursorExtension(username), + cursorExtension(uid, username), EditorView.editable.of(!isReadOnly), EditorState.readOnly.of(isReadOnly), ]} diff --git a/frontend/src/utils/collabCursor.ts b/frontend/src/utils/collabCursor.ts index f3621a4079..c0391a9d4c 100644 --- a/frontend/src/utils/collabCursor.ts +++ b/frontend/src/utils/collabCursor.ts @@ -9,15 +9,12 @@ import { StateField, StateEffect } from "@codemirror/state"; // Adapted from https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets export interface Cursor { - id: string; + uid: string; + username: string; from: number; to: number; } -export interface Cursors { - cursors: Cursor[]; -} - class TooltipWidget extends WidgetType { private name = "John"; private suffix = ""; @@ -49,55 +46,51 @@ class TooltipWidget extends WidgetType { } } -export const addCursor = StateEffect.define(); -export const removeCursor = StateEffect.define(); +export const updateCursor = StateEffect.define(); -const cursorsItems = new Map(); +const cursors = new Map(); -const cursorField = StateField.define({ - create() { - return Decoration.none; - }, - update(cursors, tr) { - let cursorTransacions = cursors.map(tr.changes); - for (const e of tr.effects) - if (e.is(addCursor)) { +const cursorStateField = StateField.define({ + create: () => Decoration.none, + update: (prevCursorState, transaction) => { + let cursorTransactions = prevCursorState.map(transaction.changes); + for (const effect of transaction.effects) { + if (effect.is(updateCursor)) { const addUpdates = []; - if (!cursorsItems.has(e.value.id)) - cursorsItems.set(e.value.id, cursorsItems.size); - - if (e.value.from !== e.value.to) { + if (!cursors.has(effect.value.uid)) { + cursors.set(effect.value.uid, cursors.size); + } + if (effect.value.from !== effect.value.to) { + // highlight selected text addUpdates.push( Decoration.mark({ - class: `cm-highlight-${(cursorsItems.get(e.value.id)! % 8) + 1}`, - id: e.value.id, - }).range(e.value.from, e.value.to) + class: `cm-highlight-${(cursors.get(effect.value.uid)! % 8) + 1}`, + uid: effect.value.uid, + }).range(effect.value.from, effect.value.to) ); } addUpdates.push( Decoration.widget({ widget: new TooltipWidget( - e.value.id, - cursorsItems.get(e.value.id)! + effect.value.username, + cursors.get(effect.value.uid)! ), block: false, - id: e.value.id, - }).range(e.value.to, e.value.to) + uid: effect.value.uid, + }).range(effect.value.to, effect.value.to) ); - cursorTransacions = cursorTransacions.update({ + // ensure only the latest cursor position and/or selection is displayed + cursorTransactions = cursorTransactions.update({ add: addUpdates, - filter: (_from, _to, value) => { - if (value?.spec?.id === e.value.id) return false; - return true; - }, + filter: (_from, _to, value) => value?.spec?.uid !== effect.value.uid, }); } - - return cursorTransacions; + } + return cursorTransactions; }, - provide: (f) => EditorView.decorations.from(f), + provide: (field) => EditorView.decorations.from(field), }); const cursorBaseTheme = EditorView.baseTheme({ @@ -193,21 +186,23 @@ const cursorBaseTheme = EditorView.baseTheme({ }, }); -export const cursorExtension = (username: string) => { +export const cursorExtension = (uid: string, username: string) => { return [ - cursorField, - cursorBaseTheme, + cursorStateField, // handles cursor positions and highlights + cursorBaseTheme, // provides cursor styling + // detects cursor updates EditorView.updateListener.of((update) => { - update.transactions.forEach((e) => { - if (e.selection) { + update.transactions.forEach((transaction) => { + if (transaction.selection) { const cursor: Cursor = { - id: username, - from: e.selection.ranges[0].from, - to: e.selection.ranges[0].to, + uid: uid, + username: username, + from: transaction.selection.ranges[0].from, + to: transaction.selection.ranges[0].to, }; update.view.dispatch({ - effects: addCursor.of(cursor), + effects: updateCursor.of(cursor), }); } }); diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 78a23597a0..ced6e6551a 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -8,7 +8,7 @@ import { getSyncedVersion, } from "@codemirror/collab"; import { io } from "socket.io-client"; -import { addCursor, Cursor, removeCursor } from "./collabCursor"; +import { updateCursor, Cursor } from "./collabCursor"; // Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets @@ -57,16 +57,14 @@ const pullUpdates = (version: number): Promise => { const effects: StateEffect[] = []; update.effects?.forEach((effect) => { - if (effect.value?.id && effect.value?.from) { + if (effect.value?.uid && effect.value?.from) { const cursor: Cursor = { - id: effect.value.id, + uid: effect.value.uid, + username: effect.value.username, from: effect.value.from, to: effect.value.to, }; - effects.push(addCursor.of(cursor)); - } else if (effect.value?.id) { - const cursorId = effect.value.id; - effects.push(removeCursor.of(cursorId)); + effects.push(updateCursor.of(cursor)); } }); @@ -147,9 +145,7 @@ export const peerExtension = (startVersion: number, uid: string) => { startVersion: startVersion, clientID: uid, sharedEffects: (transaction) => - transaction.effects.filter( - (effect) => effect.is(addCursor) || effect.is(removeCursor) - ), + transaction.effects.filter((effect) => effect.is(updateCursor)), }), plugin, ]; From 6d3f01c1f84d3eb5a5f2ecd3548605562db7be58 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Fri, 1 Nov 2024 19:19:01 +0800 Subject: [PATCH 04/16] Customise cursor style --- .../src/handlers/websocketHandler.ts | 42 ++-- frontend/src/components/CodeEditor/index.tsx | 34 +++- frontend/src/contexts/MatchContext.tsx | 10 +- frontend/src/pages/CollabSandbox/index.tsx | 28 ++- frontend/src/utils/collabCursor.ts | 184 +++++++----------- frontend/src/utils/collabSocket.ts | 47 ++++- 6 files changed, 200 insertions(+), 145 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index ba760d5cc7..c61e0dc02d 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -13,6 +13,7 @@ enum CollabEvents { PUSH_UPDATES = "push_updates", PULL_UPDATES = "pull_updates", + INIT_DOCUMENT = "init_document", GET_DOCUMENT = "get_document", // Send @@ -86,10 +87,28 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { // Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets let updates: Update[] = []; // updates.length = current version -let doc = Text.of(["Start document"]); +let doc = Text.of([""]); let pendingPullUpdatesRequests: ((updates: Update[]) => void)[] = []; const handleCodeEditorEvents = (socket: Socket) => { + socket.on( + CollabEvents.INIT_DOCUMENT, + (template: string, callback: () => void) => { + if (!doc.toString()) { + doc = Text.of([template]); + } + callback(); + } + ); + + socket.on(CollabEvents.GET_DOCUMENT, () => { + socket.emit( + CollabEvents.GET_DOCUMENT_RESPONSE, + updates.length, + doc.toString() + ); + }); + socket.on(CollabEvents.PULL_UPDATES, (version: number) => { if (version < updates.length) { // send the new updates @@ -111,13 +130,18 @@ const handleCodeEditorEvents = (socket: Socket) => { // received new updates, notify any pending pullUpdates requests socket.on( CollabEvents.PUSH_UPDATES, - (version: number, newUpdates: string, callback: () => void) => { + async ( + version: number, + newUpdates: string, + roomId: string, + callback: () => void + ) => { let docUpdates = JSON.parse(newUpdates) as readonly Update[]; try { // If the given version is the latest version, apply the new updates. // Else, rebase updates first. - if (version != updates.length) { + if (version < updates.length) { docUpdates = rebaseUpdates(docUpdates, updates.slice(version)); } @@ -129,6 +153,10 @@ const handleCodeEditorEvents = (socket: Socket) => { effects: update.effects, }); doc = changes.apply(doc); + + await redisClient.set(`collaboration:${roomId}`, doc.toString(), { + EX: EXPIRY_TIME, + }); } callback(); @@ -141,12 +169,4 @@ const handleCodeEditorEvents = (socket: Socket) => { } } ); - - socket.on(CollabEvents.GET_DOCUMENT, () => { - socket.emit( - CollabEvents.GET_DOCUMENT_RESPONSE, - updates.length, - doc.toString() - ); - }); }; diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 553b1bb30b..90c0bb47c5 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -6,6 +6,7 @@ import { EditorState } from "@codemirror/state"; import { useEffect, useState } from "react"; import { getDocument, + initDocument, peerExtension, removeListeners, } from "../../utils/collabSocket"; @@ -15,6 +16,9 @@ import { cursorExtension } from "../../utils/collabCursor"; interface CodeEditorProps { uid: string; username: string; + language: string; + template?: string; + roomId?: string; isReadOnly?: boolean; } @@ -23,8 +27,21 @@ type CodeEditorState = { doc: string | null; }; +const languageSupport = { + Python: langs.python(), + Java: langs.java(), + C: langs.c(), +}; + const CodeEditor: React.FC = (props) => { - const { uid, username, isReadOnly = false } = props; + const { + uid, + username, + language, + template = "", + roomId = "", + isReadOnly = false, + } = props; const [codeEditorState, setCodeEditorState] = useState({ version: null, @@ -32,8 +49,19 @@ const CodeEditor: React.FC = (props) => { }); useEffect(() => { + if (isReadOnly) { + setCodeEditorState({ + version: 0, + doc: template, + }); + return; + } + const fetchDocument = async () => { try { + if (template) { + await initDocument(template); + } const { version, doc } = await getDocument(); setCodeEditorState({ version: version, @@ -61,8 +89,8 @@ const CodeEditor: React.FC = (props) => { id="codeEditor" extensions={[ basicSetup(), - langs.c(), - peerExtension(codeEditorState.version, uid), + languageSupport[language as keyof typeof languageSupport], + peerExtension(codeEditorState.version, uid, roomId), cursorExtension(uid, username), EditorView.editable.of(!isReadOnly), EditorState.readOnly.of(isReadOnly), diff --git a/frontend/src/contexts/MatchContext.tsx b/frontend/src/contexts/MatchContext.tsx index c15a0f5e52..bac689821c 100644 --- a/frontend/src/contexts/MatchContext.tsx +++ b/frontend/src/contexts/MatchContext.tsx @@ -81,12 +81,12 @@ type MatchContextType = { matchingTimeout: () => void; matchOfferTimeout: () => void; verifyMatchStatus: () => void; - getMatchId: () => string | null; handleEndSessionClick: () => void; handleRejectEndSession: () => void; handleConfirmEndSession: () => void; matchUser: MatchUser | null; matchCriteria: MatchCriteria | null; + matchId: string | null; partner: MatchUser | null; matchPending: boolean; loading: boolean; @@ -489,13 +489,9 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { ); }; - const getMatchId = () => { - return matchId; - }; - const handleEndSessionClick = () => { setIsEndSessionModalOpen(true); - } + }; const handleRejectEndSession = () => { setIsEndSessionModalOpen(false); @@ -517,12 +513,12 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { matchingTimeout, matchOfferTimeout, verifyMatchStatus, - getMatchId, handleEndSessionClick, handleRejectEndSession, handleConfirmEndSession, matchUser, matchCriteria, + matchId, partner, matchPending, loading, diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index b858392371..096028ec1d 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -21,6 +21,7 @@ import reducer, { import QuestionDetailComponent from "../../components/QuestionDetail"; import { Navigate } from "react-router-dom"; import CodeEditor from "../../components/CodeEditor"; +import { join, leave } from "../../utils/collabSocket"; const CollabSandbox: React.FC = () => { const [showErrorScreen, setShowErrorScreen] = useState(false); @@ -32,11 +33,12 @@ const CollabSandbox: React.FC = () => { const { verifyMatchStatus, - getMatchId, handleRejectEndSession, handleConfirmEndSession, matchUser, partner, + matchCriteria, + matchId, loading, isEndSessionModalOpen, questionId, @@ -57,9 +59,11 @@ const CollabSandbox: React.FC = () => { getQuestionById(questionId, dispatch); // TODO - // use getMatchId() as the room id in the collab service - console.log(getMatchId()); + // use matchId as the room id in the collab service + console.log(matchId); + join(matchId); + return () => leave(matchId); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -81,7 +85,7 @@ const CollabSandbox: React.FC = () => { return ; } - if (!matchUser || !partner) { + if (!matchUser || !partner || !matchCriteria || !matchId) { return ; } @@ -161,7 +165,21 @@ const CollabSandbox: React.FC = () => { sx={{ display: "flex", flexDirection: "column", height: "100%" }} > - + Test cases and chat tabs diff --git a/frontend/src/utils/collabCursor.ts b/frontend/src/utils/collabCursor.ts index c0391a9d4c..310f719ac6 100644 --- a/frontend/src/utils/collabCursor.ts +++ b/frontend/src/utils/collabCursor.ts @@ -15,34 +15,45 @@ export interface Cursor { to: number; } -class TooltipWidget extends WidgetType { - private name = "John"; - private suffix = ""; +class CursorWidget extends WidgetType { + private username: string; + private colorClass: string; - constructor(name: string, color: number) { + constructor(username: string, color: number) { super(); - this.suffix = `${(color % 8) + 1}`; - this.name = name; + this.colorClass = `cm-cursor-color-${color}`; + this.username = username; } toDOM() { - const dom = document.createElement("div"); - dom.className = "cm-tooltip-none"; - - const cursor_tooltip = document.createElement("div"); - cursor_tooltip.className = `cm-tooltip-cursor cm-tooltip cm-tooltip-above cm-tooltip-${this.suffix}`; - cursor_tooltip.textContent = this.name; - - const cursor_tooltip_arrow = document.createElement("div"); - cursor_tooltip_arrow.className = "cm-tooltip-arrow"; - - cursor_tooltip.appendChild(cursor_tooltip_arrow); - dom.appendChild(cursor_tooltip); - return dom; - } - - ignoreEvent() { - return false; + const cursorRoot = document.createElement("div"); + cursorRoot.className = "cm-cursor-root"; + + const cursor = document.createElement("div"); + cursor.className = `cm-cursor-display ${this.colorClass}`; + cursorRoot.appendChild(cursor); + + const cursorLabel = document.createElement("div"); + cursorLabel.className = `cm-cursor-label ${this.colorClass}`; + cursorLabel.textContent = this.username; + cursorRoot.appendChild(cursorLabel); + + let labelTimeout = setTimeout(() => { + cursorLabel.style.display = "none"; + }, 2000); + + cursor.addEventListener("mouseenter", () => { + clearTimeout(labelTimeout); + cursorLabel.style.display = "block"; + }); + + cursor.addEventListener("mouseleave", () => { + labelTimeout = setTimeout(() => { + cursorLabel.style.display = "none"; + }, 2000); + }); + + return cursorRoot; } } @@ -56,35 +67,36 @@ const cursorStateField = StateField.define({ let cursorTransactions = prevCursorState.map(transaction.changes); for (const effect of transaction.effects) { if (effect.is(updateCursor)) { - const addUpdates = []; + const cursorUpdates = []; + if (!cursors.has(effect.value.uid)) { - cursors.set(effect.value.uid, cursors.size); + cursors.set(effect.value.uid, cursors.size + 1); } + if (effect.value.from !== effect.value.to) { // highlight selected text - addUpdates.push( + cursorUpdates.push( Decoration.mark({ - class: `cm-highlight-${(cursors.get(effect.value.uid)! % 8) + 1}`, + class: `cm-highlight-color-${cursors.get(effect.value.uid)!}`, uid: effect.value.uid, }).range(effect.value.from, effect.value.to) ); } - addUpdates.push( + cursorUpdates.push( Decoration.widget({ - widget: new TooltipWidget( + widget: new CursorWidget( effect.value.username, cursors.get(effect.value.uid)! ), - block: false, uid: effect.value.uid, - }).range(effect.value.to, effect.value.to) + }).range(effect.value.to) ); // ensure only the latest cursor position and/or selection is displayed cursorTransactions = cursorTransactions.update({ - add: addUpdates, - filter: (_from, _to, value) => value?.spec?.uid !== effect.value.uid, + add: cursorUpdates, + filter: (_from, _to, value) => value.spec.uid !== effect.value.uid, }); } } @@ -94,95 +106,39 @@ const cursorStateField = StateField.define({ }); const cursorBaseTheme = EditorView.baseTheme({ - ".cm-tooltip.cm-tooltip-cursor": { - color: "white", - border: "none", - padding: "2px 7px", - borderRadius: "4px", - position: "absolute", - marginTop: "-40px", - marginLeft: "-14px", - "& .cm-tooltip-arrow:after": { - borderTopColor: "transparent", - }, - zIndex: "1000000", - }, - ".cm-tooltip-none": { + ".cm-cursor-root": { + display: "inline-block", width: "0px", height: "0px", - display: "inline-block", - }, - ".cm-highlight-1": { - backgroundColor: "#6666BB55", }, - ".cm-highlight-2": { - backgroundColor: "#F76E6E55", - }, - ".cm-highlight-3": { - backgroundColor: "#0CDA6255", - }, - ".cm-highlight-4": { - backgroundColor: "#0CC5DA55", - }, - ".cm-highlight-5": { - backgroundColor: "#0C51DA55", - }, - ".cm-highlight-6": { - backgroundColor: "#980CDA55", - }, - ".cm-highlight-7": { - backgroundColor: "#DA0CBB55", - }, - ".cm-highlight-8": { - backgroundColor: "#DA800C55", - }, - ".cm-tooltip-1": { - backgroundColor: "#66b !important", - "& .cm-tooltip-arrow:before": { - borderTopColor: "#66b !important", - }, - }, - ".cm-tooltip-2": { - backgroundColor: "#F76E6E !important", - "& .cm-tooltip-arrow:before": { - borderTopColor: "#F76E6E !important", - }, - }, - ".cm-tooltip-3": { - backgroundColor: "#0CDA62 !important", - "& .cm-tooltip-arrow:before": { - borderTopColor: "#0CDA62 !important", - }, + ".cm-cursor-display": { + border: "none", + width: "0.5px", + height: "18.5px", + position: "absolute", + marginTop: "-14.5px", + marginLeft: "0px", }, - ".cm-tooltip-4": { - backgroundColor: "#0CC5DA !important", - "& .cm-tooltip-arrow:before": { - borderTopColor: "#0CC5DA !important", - }, + ".cm-cursor-label": { + color: "white", + borderRadius: "4px 4px 4px 0px", + padding: "2px 4px", + fontSize: "12px", + position: "absolute", + marginTop: "-35px", + marginLeft: "0px", }, - ".cm-tooltip-5": { - backgroundColor: "#0C51DA !important", - "& .cm-tooltip-arrow:before": { - borderTopColor: "#0C51DA !important", - }, + ".cm-cursor-color-1": { + backgroundColor: "#f6a1a1", }, - ".cm-tooltip-6": { - backgroundColor: "#980CDA !important", - "& .cm-tooltip-arrow:before": { - borderTopColor: "#980CDA !important", - }, + ".cm-cursor-color-2": { + backgroundColor: "#d6a3e8", }, - ".cm-tooltip-7": { - backgroundColor: "#DA0CBB !important", - "& .cm-tooltip-arrow:before": { - borderTopColor: "#DA0CBB !important", - }, + ".cm-highlight-color-1": { + backgroundColor: "rgba(246, 161, 161, 0.3)", }, - ".cm-tooltip-8": { - backgroundColor: "#DA800C !important", - "& .cm-tooltip-arrow:before": { - borderTopColor: "#DA800C !important", - }, + ".cm-highlight-color-2": { + backgroundColor: "rgba(214, 163, 232, 0.3)", }, }); diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index ced6e6551a..21192010cb 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -14,8 +14,12 @@ import { updateCursor, Cursor } from "./collabCursor"; enum CollabEvents { // Send + JOIN = "join", + LEAVE = "leave", + PUSH_UPDATES = "push_updates", PULL_UPDATES = "pull_updates", + INIT_DOCUMENT = "init_document", GET_DOCUMENT = "get_document", // Receive @@ -23,11 +27,16 @@ enum CollabEvents { GET_DOCUMENT_RESPONSE = "get_document_response", } -const collabSocket = io("http://localhost:3003"); +const COLLAB_SOCKET_URL = "http://localhost:3003"; +const collabSocket = io(COLLAB_SOCKET_URL, { + reconnectionAttempts: 3, + autoConnect: false, +}); const pushUpdates = ( version: number, - fullUpdates: readonly Update[] + fullUpdates: readonly Update[], + roomId: string ): Promise => { const updates = fullUpdates.map((update) => ({ clientID: update.clientID, // client who made the update @@ -40,6 +49,7 @@ const pushUpdates = ( CollabEvents.PUSH_UPDATES, version, JSON.stringify(updates), + roomId, () => resolve() ); }); @@ -54,10 +64,16 @@ const pullUpdates = (version: number): Promise => { }); }).then((updates) => updates.map((update) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const effects: StateEffect[] = []; update.effects?.forEach((effect) => { - if (effect.value?.uid && effect.value?.from) { + if ( + effect.value.uid && + effect.value.username && + effect.value.from && + effect.value.to + ) { const cursor: Cursor = { uid: effect.value.uid, username: effect.value.username, @@ -77,6 +93,23 @@ const pullUpdates = (version: number): Promise => { ); }; +export const join = (matchId: string | null) => { + collabSocket.connect(); + collabSocket.emit(CollabEvents.JOIN, matchId); +}; + +export const leave = (matchId: string | null) => { + collabSocket.emit(CollabEvents.LEAVE, matchId); + collabSocket.disconnect(); +}; + +export const initDocument = (template: string): Promise => { + return new Promise((resolve) => { + console.log("emit init document"); + collabSocket.emit(CollabEvents.INIT_DOCUMENT, template, () => resolve()); + }); +}; + export const getDocument = (): Promise<{ version: number; doc: Text }> => { return new Promise((resolve) => { collabSocket.emit(CollabEvents.GET_DOCUMENT); @@ -94,7 +127,11 @@ export const getDocument = (): Promise<{ version: number; doc: Text }> => { }; // handles push and pull updates -export const peerExtension = (startVersion: number, uid: string) => { +export const peerExtension = ( + startVersion: number, + uid: string, + roomId: string +) => { const plugin = ViewPlugin.fromClass( class { private pushingUpdates = false; // to ensure only one running push request @@ -117,7 +154,7 @@ export const peerExtension = (startVersion: number, uid: string) => { } this.pushingUpdates = true; const version = getSyncedVersion(this.view.state); - await pushUpdates(version, updates); + await pushUpdates(version, updates, roomId); this.pushingUpdates = false; // check if there are still updates to push (failed / new updates) From 2cacd12d766c61bc9df511e01cef14279e99d3ff Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Fri, 1 Nov 2024 20:43:29 +0800 Subject: [PATCH 05/16] Remove decoration for user's own cursor --- frontend/src/components/CodeEditor/index.tsx | 5 +- frontend/src/pages/CollabSandbox/index.tsx | 9 +- frontend/src/utils/collabCursor.ts | 92 +++++++++----------- 3 files changed, 50 insertions(+), 56 deletions(-) diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 90c0bb47c5..82ec8b48a7 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -83,8 +83,9 @@ const CodeEditor: React.FC = (props) => { return ( { - + ({ + flex: 1, + width: "100%", + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + })} + > (); -const cursors = new Map(); +const cursorStateField = (uid: string): StateField => { + return StateField.define({ + create: () => Decoration.none, + update: (prevCursorState, transaction) => { + let cursorTransactions = prevCursorState.map(transaction.changes); + for (const effect of transaction.effects) { + // check for partner's cursor updates + if (effect.is(updateCursor) && effect.value.uid !== uid) { + const cursorUpdates = []; + + if (effect.value.from !== effect.value.to) { + // highlight selected text + cursorUpdates.push( + Decoration.mark({ + class: "cm-highlight-color", + uid: effect.value.uid, + }).range(effect.value.from, effect.value.to) + ); + } -const cursorStateField = StateField.define({ - create: () => Decoration.none, - update: (prevCursorState, transaction) => { - let cursorTransactions = prevCursorState.map(transaction.changes); - for (const effect of transaction.effects) { - if (effect.is(updateCursor)) { - const cursorUpdates = []; - - if (!cursors.has(effect.value.uid)) { - cursors.set(effect.value.uid, cursors.size + 1); - } - - if (effect.value.from !== effect.value.to) { - // highlight selected text cursorUpdates.push( - Decoration.mark({ - class: `cm-highlight-color-${cursors.get(effect.value.uid)!}`, + Decoration.widget({ + widget: new CursorWidget(effect.value.username), uid: effect.value.uid, - }).range(effect.value.from, effect.value.to) + }).range(effect.value.to) ); - } - cursorUpdates.push( - Decoration.widget({ - widget: new CursorWidget( - effect.value.username, - cursors.get(effect.value.uid)! - ), - uid: effect.value.uid, - }).range(effect.value.to) - ); - - // ensure only the latest cursor position and/or selection is displayed - cursorTransactions = cursorTransactions.update({ - add: cursorUpdates, - filter: (_from, _to, value) => value.spec.uid !== effect.value.uid, - }); + // ensure only the latest cursor position and/or selection is displayed + cursorTransactions = cursorTransactions.update({ + add: cursorUpdates, + filter: (_from, _to, value) => value.spec.uid !== effect.value.uid, + }); + } } - } - return cursorTransactions; - }, - provide: (field) => EditorView.decorations.from(field), -}); + return cursorTransactions; + }, + provide: (field) => EditorView.decorations.from(field), + }); +}; const cursorBaseTheme = EditorView.baseTheme({ ".cm-cursor-root": { @@ -128,23 +120,17 @@ const cursorBaseTheme = EditorView.baseTheme({ marginTop: "-35px", marginLeft: "0px", }, - ".cm-cursor-color-1": { + ".cm-cursor-color": { backgroundColor: "#f6a1a1", }, - ".cm-cursor-color-2": { - backgroundColor: "#d6a3e8", - }, - ".cm-highlight-color-1": { + ".cm-highlight-color": { backgroundColor: "rgba(246, 161, 161, 0.3)", }, - ".cm-highlight-color-2": { - backgroundColor: "rgba(214, 163, 232, 0.3)", - }, }); export const cursorExtension = (uid: string, username: string) => { return [ - cursorStateField, // handles cursor positions and highlights + cursorStateField(uid), // handles cursor positions and highlights cursorBaseTheme, // provides cursor styling // detects cursor updates EditorView.updateListener.of((update) => { From 46be3f75b65d237cce58bf8095ea836f46c897a8 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sat, 2 Nov 2024 01:23:05 +0800 Subject: [PATCH 06/16] Store collab sessions by room id --- backend/README.md | 12 +- .../src/handlers/websocketHandler.ts | 112 +++++++++++++----- backend/user-service/README.md | 8 -- frontend/src/components/CodeEditor/index.tsx | 14 ++- frontend/src/pages/CollabSandbox/index.tsx | 22 +++- frontend/src/utils/collabSocket.ts | 56 +++++---- 6 files changed, 148 insertions(+), 76 deletions(-) diff --git a/backend/README.md b/backend/README.md index bb3dc6ba1c..ea15cc12e4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,11 +2,19 @@ > Before proceeding to each microservice for more instructions: -1. Set up cloud MongoDB if not using docker. We recommend this if you are just testing out each microservice separately to avoid needing to manually set up multiple instances of local MongoDB. Else, if you are using docker-compose.yml to run PeerPrep, check out the READMEs in the different backend microservices to set up the env for the local MongoDB instances. +1. Set up cloud MongoDB if you are not using Docker. We recommend this if you are just testing out each microservice separately to avoid needing to manually set up multiple instances of local MongoDB. Otherwise, if you are using `docker-compose.yml` to run PeerPrep, check out the READMEs in the different backend microservices to set up the `.env` files for the local MongoDB instances. 2. Set up Firebase. -3. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20. +3. For the microservices that use Redis, to view the contents stored: + + 1. Go to [http://localhost:5540](http://localhost:5540). + + 2. Click on "Add Redis Database". + + 3. Enter `host.docker.internal` as the Host. + +4. Follow the instructions [here](https://nodejs.org/en/download/package-manager) to set up Node v20. ## Setting-up cloud MongoDB (in production) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index c61e0dc02d..4f6a733ea6 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -7,7 +7,7 @@ import { rebaseUpdates, Update } from "@codemirror/collab"; enum CollabEvents { // Receive JOIN = "join", - CHANGE = "change", + // CHANGE = "change", LEAVE = "leave", DISCONNECT = "disconnect", @@ -18,9 +18,9 @@ enum CollabEvents { // Send ROOM_FULL = "room_full", - CONNECTED = "connected", + USER_CONNECTED = "user_connected", NEW_USER_CONNECTED = "new_user_connected", - CODE_CHANGE = "code_change", + // CODE_CHANGE = "code_change", PARTNER_LEFT = "partner_left", PARTNER_DISCONNECTED = "partner_disconnected", @@ -30,8 +30,16 @@ enum CollabEvents { const EXPIRY_TIME = 3600; +interface CollabSession { + updates: Update[]; // updates.length = current version + doc: Text; + pendingPullUpdatesRequests: ((updates: Update[]) => void)[]; +} + +const collabSessions = new Map(); + export const handleWebsocketCollabEvents = (socket: Socket) => { - socket.on(CollabEvents.JOIN, async ({ roomId }) => { + socket.on(CollabEvents.JOIN, async (roomId: string) => { if (!roomId) { return; } @@ -46,31 +54,43 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.data.roomId = roomId; // in case of disconnect, send the code to the user when he rejoins - const code = await redisClient.get(`collaboration:${roomId}`); - socket.emit(CollabEvents.CONNECTED, { code: code ? code : "" }); + const collabSession = await redisClient.get(`collaboration:${roomId}`); + if (collabSession) { + if (!collabSessions.has(roomId)) { + collabSessions.set(roomId, JSON.parse(collabSession) as CollabSession); + } + } else { + initCollabSession(roomId); + } + socket.emit(CollabEvents.USER_CONNECTED); // inform the other user that a new user has joined socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED); }); - socket.on(CollabEvents.CHANGE, async ({ roomId, code }) => { - if (!roomId || !code) { - return; - } + // socket.on(CollabEvents.CHANGE, async (roomId: string, code: string) => { + // if (!roomId || !code) { + // return; + // } - await redisClient.set(`collaboration:${roomId}`, code, { - EX: EXPIRY_TIME, - }); - socket.to(roomId).emit(CollabEvents.CODE_CHANGE, { code }); - }); + // await redisClient.set(`collaboration:${roomId}`, code, { + // EX: EXPIRY_TIME, + // }); + // socket.to(roomId).emit(CollabEvents.CODE_CHANGE, code); + // }); - socket.on(CollabEvents.LEAVE, ({ roomId }) => { + socket.on(CollabEvents.LEAVE, (roomId: string) => { if (!roomId) { return; } socket.leave(roomId); - socket.to(roomId).emit(CollabEvents.PARTNER_LEFT); + const room = io.sockets.adapter.rooms.get(roomId); + if (room?.size === 0) { + collabSessions.delete(roomId); + } else { + socket.to(roomId).emit(CollabEvents.PARTNER_LEFT); + } }); socket.on(CollabEvents.DISCONNECT, () => { @@ -86,22 +106,17 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { /* Code Editor Events */ // Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets -let updates: Update[] = []; // updates.length = current version -let doc = Text.of([""]); -let pendingPullUpdatesRequests: ((updates: Update[]) => void)[] = []; - const handleCodeEditorEvents = (socket: Socket) => { socket.on( CollabEvents.INIT_DOCUMENT, - (template: string, callback: () => void) => { - if (!doc.toString()) { - doc = Text.of([template]); - } + (roomId: string, template: string, callback: () => void) => { + initCollabSession(roomId, template); callback(); } ); - socket.on(CollabEvents.GET_DOCUMENT, () => { + socket.on(CollabEvents.GET_DOCUMENT, (roomId: string) => { + const { updates, doc } = initCollabSession(roomId); socket.emit( CollabEvents.GET_DOCUMENT_RESPONSE, updates.length, @@ -109,7 +124,8 @@ const handleCodeEditorEvents = (socket: Socket) => { ); }); - socket.on(CollabEvents.PULL_UPDATES, (version: number) => { + socket.on(CollabEvents.PULL_UPDATES, (roomId: string, version: number) => { + const { updates, pendingPullUpdatesRequests } = initCollabSession(roomId); if (version < updates.length) { // send the new updates socket.emit( @@ -131,11 +147,13 @@ const handleCodeEditorEvents = (socket: Socket) => { socket.on( CollabEvents.PUSH_UPDATES, async ( + roomId: string, version: number, newUpdates: string, - roomId: string, callback: () => void ) => { + const { updates, doc, pendingPullUpdatesRequests } = + initCollabSession(roomId); let docUpdates = JSON.parse(newUpdates) as readonly Update[]; try { @@ -152,11 +170,21 @@ const handleCodeEditorEvents = (socket: Socket) => { changes: changes, effects: update.effects, }); - doc = changes.apply(doc); - await redisClient.set(`collaboration:${roomId}`, doc.toString(), { - EX: EXPIRY_TIME, - }); + const updatedCollabSession = { + updates: updates, + doc: changes.apply(doc), + pendingPullUpdatesRequests: pendingPullUpdatesRequests, + }; + collabSessions.set(roomId, updatedCollabSession); + + await redisClient.set( + `collaboration:${roomId}`, + JSON.stringify(updatedCollabSession), + { + EX: EXPIRY_TIME, + } + ); } callback(); @@ -170,3 +198,23 @@ const handleCodeEditorEvents = (socket: Socket) => { } ); }; + +const initCollabSession = ( + roomId: string, + template?: string +): CollabSession => { + const collabSession = collabSessions.get(roomId); + if (!collabSession) { + collabSessions.set(roomId, { + updates: [], + doc: Text.of([template ? template : ""]), + pendingPullUpdatesRequests: [], + }); + } else if (template) { + collabSessions.set(roomId, { + ...collabSession, + doc: Text.of([template]), + }); + } + return collabSessions.get(roomId)!; +}; diff --git a/backend/user-service/README.md b/backend/user-service/README.md index 5bd2f732f3..0c04de5047 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -36,14 +36,6 @@ 5. A default admin account (`email: admin@gmail.com` and `password: Admin@123`) wil be created. If you wish to change the default credentials, update them in `.env`. Alternatively, you can also edit your credentials and user profile after you have created the default account. -6. To view the contents stored in Redis, - - 1. Go to [http://localhost:5540](http://localhost:5540). - - 2. Click on "Add Redis Database". - - 3. Enter `host.internal.docker` as the Host. - ## Running User Service Individually > Make sure you have the cloud MongoDB URI in your .env file and set NODE_ENV to production already. diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 82ec8b48a7..b628f5cbe6 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -8,7 +8,6 @@ import { getDocument, initDocument, peerExtension, - removeListeners, } from "../../utils/collabSocket"; import Loader from "../Loader"; import { cursorExtension } from "../../utils/collabCursor"; @@ -58,11 +57,16 @@ const CodeEditor: React.FC = (props) => { } const fetchDocument = async () => { + if (!roomId) { + return; + } + try { if (template) { - await initDocument(template); + await initDocument(roomId, template); } - const { version, doc } = await getDocument(); + + const { version, doc } = await getDocument(roomId); setCodeEditorState({ version: version, doc: doc.toString(), @@ -73,8 +77,6 @@ const CodeEditor: React.FC = (props) => { }; fetchDocument(); - - return () => removeListeners(); }, []); if (codeEditorState.version === null || codeEditorState.doc === null) { @@ -91,7 +93,7 @@ const CodeEditor: React.FC = (props) => { extensions={[ basicSetup(), languageSupport[language as keyof typeof languageSupport], - peerExtension(codeEditorState.version, uid, roomId), + peerExtension(roomId, codeEditorState.version, uid), cursorExtension(uid, username), EditorView.editable.of(!isReadOnly), EditorState.readOnly.of(isReadOnly), diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index 520d89c5b3..268f677f0b 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -24,6 +24,7 @@ import CodeEditor from "../../components/CodeEditor"; import { join, leave } from "../../utils/collabSocket"; const CollabSandbox: React.FC = () => { + const [connected, setConnected] = useState(false); const [showErrorScreen, setShowErrorScreen] = useState(false); const match = useMatch(); @@ -58,12 +59,23 @@ const CollabSandbox: React.FC = () => { } getQuestionById(questionId, dispatch); - // TODO - // use matchId as the room id in the collab service - console.log(matchId); - join(matchId); + if (!matchId || connected) { + return; + } + + const connectToCollabSession = async () => { + try { + await join(matchId); + setConnected(true); + } catch (error) { + console.error("Error connecting to collab session: ", error); + } + }; + + connectToCollabSession(); return () => leave(matchId); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -98,7 +110,7 @@ const CollabSandbox: React.FC = () => { ); } - if (!selectedQuestion) { + if (!selectedQuestion || !connected) { return ; } diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 21192010cb..c4958f7e2a 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -23,6 +23,8 @@ enum CollabEvents { GET_DOCUMENT = "get_document", // Receive + USER_CONNECTED = "user_connected", + PULL_UPDATES_RESPONSE = "pull_updates_response", GET_DOCUMENT_RESPONSE = "get_document_response", } @@ -34,9 +36,9 @@ const collabSocket = io(COLLAB_SOCKET_URL, { }); const pushUpdates = ( + roomId: string, version: number, - fullUpdates: readonly Update[], - roomId: string + fullUpdates: readonly Update[] ): Promise => { const updates = fullUpdates.map((update) => ({ clientID: update.clientID, // client who made the update @@ -47,17 +49,20 @@ const pushUpdates = ( return new Promise((resolve) => { collabSocket.emit( CollabEvents.PUSH_UPDATES, + roomId, version, JSON.stringify(updates), - roomId, () => resolve() ); }); }; -const pullUpdates = (version: number): Promise => { +const pullUpdates = ( + roomId: string, + version: number +): Promise => { return new Promise((resolve) => { - collabSocket.emit(CollabEvents.PULL_UPDATES, version); + collabSocket.emit(CollabEvents.PULL_UPDATES, roomId, version); collabSocket.once(CollabEvents.PULL_UPDATES_RESPONSE, (updates: string) => { resolve(JSON.parse(updates)); @@ -93,26 +98,36 @@ const pullUpdates = (version: number): Promise => { ); }; -export const join = (matchId: string | null) => { +export const join = (roomId: string): Promise => { collabSocket.connect(); - collabSocket.emit(CollabEvents.JOIN, matchId); + collabSocket.emit(CollabEvents.JOIN, roomId); + + return new Promise((resolve) => { + collabSocket.once(CollabEvents.USER_CONNECTED, () => resolve()); + }); }; -export const leave = (matchId: string | null) => { - collabSocket.emit(CollabEvents.LEAVE, matchId); +export const leave = (roomId: string) => { + collabSocket.emit(CollabEvents.LEAVE, roomId); collabSocket.disconnect(); }; -export const initDocument = (template: string): Promise => { +export const initDocument = ( + roomId: string, + template: string +): Promise => { return new Promise((resolve) => { - console.log("emit init document"); - collabSocket.emit(CollabEvents.INIT_DOCUMENT, template, () => resolve()); + collabSocket.emit(CollabEvents.INIT_DOCUMENT, roomId, template, () => + resolve() + ); }); }; -export const getDocument = (): Promise<{ version: number; doc: Text }> => { +export const getDocument = ( + roomId: string +): Promise<{ version: number; doc: Text }> => { return new Promise((resolve) => { - collabSocket.emit(CollabEvents.GET_DOCUMENT); + collabSocket.emit(CollabEvents.GET_DOCUMENT, roomId); collabSocket.once( CollabEvents.GET_DOCUMENT_RESPONSE, @@ -128,9 +143,9 @@ export const getDocument = (): Promise<{ version: number; doc: Text }> => { // handles push and pull updates export const peerExtension = ( + roomId: string, startVersion: number, - uid: string, - roomId: string + uid: string ) => { const plugin = ViewPlugin.fromClass( class { @@ -154,7 +169,7 @@ export const peerExtension = ( } this.pushingUpdates = true; const version = getSyncedVersion(this.view.state); - await pushUpdates(version, updates, roomId); + await pushUpdates(roomId, version, updates); this.pushingUpdates = false; // check if there are still updates to push (failed / new updates) @@ -166,7 +181,7 @@ export const peerExtension = ( async pull() { while (this.pullUpdates) { const version = getSyncedVersion(this.view.state); - const updates = await pullUpdates(version); // returns only if there are updates + const updates = await pullUpdates(roomId, version); // returns only if there are updates this.view.dispatch(receiveUpdates(this.view.state, updates)); } } @@ -187,8 +202,3 @@ export const peerExtension = ( plugin, ]; }; - -export const removeListeners = () => { - collabSocket.off(CollabEvents.PULL_UPDATES_RESPONSE); - collabSocket.off(CollabEvents.GET_DOCUMENT_RESPONSE); -}; From d67572abbd99bc5a35b3f628de24e8133ea9215a Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sun, 3 Nov 2024 02:27:30 +0800 Subject: [PATCH 07/16] Switch to yjs --- backend/collab-service/package-lock.json | 68 ++++++++++++++++- backend/collab-service/package.json | 4 +- .../src/handlers/websocketHandler.ts | 74 +++++++++++++++++++ backend/collab-service/src/server.ts | 8 +- frontend/package-lock.json | 1 + frontend/package.json | 1 + frontend/src/components/CodeEditor/index.tsx | 74 +++++++++++-------- frontend/src/utils/collabCursor.ts | 14 +++- frontend/src/utils/collabSocket.ts | 46 +++++++++++- 9 files changed, 249 insertions(+), 41 deletions(-) diff --git a/backend/collab-service/package-lock.json b/backend/collab-service/package-lock.json index 5d09b825e0..009a4e8cca 100644 --- a/backend/collab-service/package-lock.json +++ b/backend/collab-service/package-lock.json @@ -19,7 +19,9 @@ "redis": "^4.7.0", "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", - "yaml": "^2.6.0" + "y-protocols": "^1.0.6", + "yaml": "^2.6.0", + "yjs": "^13.6.20" }, "devDependencies": { "@eslint/js": "^9.13.0", @@ -4657,6 +4659,15 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -5500,6 +5511,26 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.98", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.98.tgz", + "integrity": "sha512-XteTiNO0qEXqqweWx+b21p/fBnNHUA1NwAtJNJek1oPrewEZs2uiT4gWivHKr9GqCjDPAhchz0UQO8NwU3bBNA==", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7225,6 +7256,25 @@ } } }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7283,6 +7333,22 @@ "node": ">=12" } }, + "node_modules/yjs": { + "version": "13.6.20", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.20.tgz", + "integrity": "sha512-Z2YZI+SYqK7XdWlloI3lhMiKnCdFCVC4PchpdO+mCYwtiTwncjUbnRK9R1JmkNfdmHyDXuWN3ibJAt0wsqTbLQ==", + "dependencies": { + "lib0": "^0.2.98" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/backend/collab-service/package.json b/backend/collab-service/package.json index dc43147ab2..5529171a82 100644 --- a/backend/collab-service/package.json +++ b/backend/collab-service/package.json @@ -24,7 +24,9 @@ "redis": "^4.7.0", "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", - "yaml": "^2.6.0" + "y-protocols": "^1.0.6", + "yaml": "^2.6.0", + "yjs": "^13.6.20" }, "devDependencies": { "@eslint/js": "^9.13.0", diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 4f6a733ea6..b5784082ed 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -3,6 +3,7 @@ import { io } from "../server"; import redisClient from "../config/redis"; import { ChangeSet, Text } from "@codemirror/state"; import { rebaseUpdates, Update } from "@codemirror/collab"; +import * as Y from "yjs"; enum CollabEvents { // Receive @@ -38,6 +39,79 @@ interface CollabSession { const collabSessions = new Map(); +const yCollabSessions = new Map(); + +export const handleYWebsocketCollabEvents = (socket: Socket) => { + socket.on(CollabEvents.JOIN, async (roomId: string) => { + if (!roomId) { + return; + } + + const room = io.sockets.adapter.rooms.get(roomId); + if (room && room.size >= 2) { + socket.emit(CollabEvents.ROOM_FULL); + return; + } + + socket.join(roomId); + socket.data.roomId = roomId; + + // in case of disconnect, send the code to the user when he rejoins + // const collabSession = await redisClient.get(`collaboration:${roomId}`); + // if (collabSession) { + // if (!yCollabSessions.has(roomId)) { + // yCollabSessions.set(roomId, JSON.parse(collabSession) as Y.Doc); + // } + // } else { + // const ydoc = new Y.Doc(); + // yCollabSessions.set(roomId, ydoc); + // } + if (!yCollabSessions.has(roomId)) { + const ydoc = new Y.Doc(); + yCollabSessions.set(roomId, ydoc); + } + socket.emit("sync", Y.encodeStateAsUpdate(yCollabSessions.get(roomId)!)); + socket.emit(CollabEvents.USER_CONNECTED); + + // inform the other user that a new user has joined + socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED); + }); + + socket.on("update", (roomId: string, update: Uint8Array) => { + let ydoc = yCollabSessions.get(roomId); + if (!ydoc) { + ydoc = new Y.Doc(); + } + Y.applyUpdate(ydoc, update); + + socket.to(roomId).emit("update", update); + }); + + socket.on( + "cursor_update", + ( + roomId: string, + cursor: { uid: string; username: string; from: number; to: number } + ) => { + socket.to(roomId).emit("cursor_update", cursor); + } + ); + + socket.on(CollabEvents.LEAVE, (roomId: string) => { + if (!roomId) { + return; + } + + socket.leave(roomId); + const room = io.sockets.adapter.rooms.get(roomId); + if (room?.size === 0) { + collabSessions.delete(roomId); + } else { + socket.to(roomId).emit(CollabEvents.PARTNER_LEFT); + } + }); +}; + export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on(CollabEvents.JOIN, async (roomId: string) => { if (!roomId) { diff --git a/backend/collab-service/src/server.ts b/backend/collab-service/src/server.ts index c1d11c7333..ccfea342ab 100644 --- a/backend/collab-service/src/server.ts +++ b/backend/collab-service/src/server.ts @@ -1,6 +1,9 @@ import http from "http"; import app, { allowedOrigins } from "./app.ts"; -import { handleWebsocketCollabEvents } from "./handlers/websocketHandler.ts"; +import { + handleWebsocketCollabEvents, + handleYWebsocketCollabEvents, +} from "./handlers/websocketHandler.ts"; import { Server, Socket } from "socket.io"; import { connectRedis } from "./config/redis.ts"; @@ -14,7 +17,8 @@ export const io = new Server(server, { }); io.on("connection", (socket: Socket) => { - handleWebsocketCollabEvents(socket); + // handleWebsocketCollabEvents(socket); + handleYWebsocketCollabEvents(socket); }); const PORT = process.env.SERVICE_PORT || 3003; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6ab181bd5..8bbbe4c62b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,6 +34,7 @@ "socket.io-client": "^4.8.0", "vite-plugin-svgr": "^4.2.0", "y-codemirror.next": "^0.3.5", + "y-protocols": "^1.0.6", "y-webrtc": "^10.3.0", "yjs": "^13.6.20" }, diff --git a/frontend/package.json b/frontend/package.json index 8629dd89f9..bdf32085d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "socket.io-client": "^4.8.0", "vite-plugin-svgr": "^4.2.0", "y-codemirror.next": "^0.3.5", + "y-protocols": "^1.0.6", "y-webrtc": "^10.3.0", "yjs": "^13.6.20" }, diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index b628f5cbe6..3cf4f694eb 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -5,12 +5,17 @@ import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; import { useEffect, useState } from "react"; import { + awareness, getDocument, initDocument, peerExtension, + receiveCursorUpdates, + removeCursorListener, + ytext, } from "../../utils/collabSocket"; import Loader from "../Loader"; import { cursorExtension } from "../../utils/collabCursor"; +import { yCollab } from "y-codemirror.next"; interface CodeEditorProps { uid: string; @@ -48,40 +53,44 @@ const CodeEditor: React.FC = (props) => { }); useEffect(() => { - if (isReadOnly) { - setCodeEditorState({ - version: 0, - doc: template, - }); - return; - } + return () => removeCursorListener(); + }, []); - const fetchDocument = async () => { - if (!roomId) { - return; - } + // useEffect(() => { + // if (isReadOnly) { + // setCodeEditorState({ + // version: 0, + // doc: template, + // }); + // return; + // } - try { - if (template) { - await initDocument(roomId, template); - } + // const fetchDocument = async () => { + // if (!roomId) { + // return; + // } - const { version, doc } = await getDocument(roomId); - setCodeEditorState({ - version: version, - doc: doc.toString(), - }); - } catch (error) { - console.error("Error fetching document: ", error); - } - }; + // try { + // if (template) { + // await initDocument(roomId, template); + // } - fetchDocument(); - }, []); + // const { version, doc } = await getDocument(roomId); + // setCodeEditorState({ + // version: version, + // doc: doc.toString(), + // }); + // } catch (error) { + // console.error("Error fetching document: ", error); + // } + // }; + + // fetchDocument(); + // }, []); - if (codeEditorState.version === null || codeEditorState.doc === null) { - return ; - } + // if (codeEditorState.version === null || codeEditorState.doc === null) { + // return ; + // } return ( = (props) => { extensions={[ basicSetup(), languageSupport[language as keyof typeof languageSupport], - peerExtension(roomId, codeEditorState.version, uid), - cursorExtension(uid, username), + yCollab(ytext, awareness), + // peerExtension(roomId, codeEditorState.version, uid), + cursorExtension(roomId, uid, username), EditorView.editable.of(!isReadOnly), EditorState.readOnly.of(isReadOnly), ]} - value={codeEditorState.doc} + // value={codeEditorState.doc} /> ); }; diff --git a/frontend/src/utils/collabCursor.ts b/frontend/src/utils/collabCursor.ts index 5c29b1a5e9..b2e435b473 100644 --- a/frontend/src/utils/collabCursor.ts +++ b/frontend/src/utils/collabCursor.ts @@ -5,6 +5,7 @@ import { WidgetType, } from "@codemirror/view"; import { StateField, StateEffect } from "@codemirror/state"; +import { receiveCursorUpdates, sendCursorUpdates } from "./collabSocket"; // Adapted from https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets @@ -65,6 +66,7 @@ const cursorStateField = (uid: string): StateField => { for (const effect of transaction.effects) { // check for partner's cursor updates if (effect.is(updateCursor) && effect.value.uid !== uid) { + // if (effect.is(updateCursor)) { const cursorUpdates = []; if (effect.value.from !== effect.value.to) { @@ -128,7 +130,11 @@ const cursorBaseTheme = EditorView.baseTheme({ }, }); -export const cursorExtension = (uid: string, username: string) => { +export const cursorExtension = ( + roomId: string, + uid: string, + username: string +) => { return [ cursorStateField(uid), // handles cursor positions and highlights cursorBaseTheme, // provides cursor styling @@ -143,11 +149,11 @@ export const cursorExtension = (uid: string, username: string) => { to: transaction.selection.ranges[0].to, }; - update.view.dispatch({ - effects: updateCursor.of(cursor), - }); + sendCursorUpdates(roomId, cursor); } }); + + receiveCursorUpdates(update.view); }), ]; }; diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index c4958f7e2a..165b11784a 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -9,6 +9,8 @@ import { } from "@codemirror/collab"; import { io } from "socket.io-client"; import { updateCursor, Cursor } from "./collabCursor"; +import * as Y from "yjs"; +import { Awareness } from "y-protocols/awareness"; // Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets @@ -98,12 +100,34 @@ const pullUpdates = ( ); }; +export const ydoc = new Y.Doc(); +export const ytext = ydoc.getText("codemirror"); +export const awareness = new Awareness(ydoc); + export const join = (roomId: string): Promise => { collabSocket.connect(); collabSocket.emit(CollabEvents.JOIN, roomId); + // Listen for local document changes and send to the server + ydoc.on("update", (update) => { + collabSocket.emit("update", roomId, update); + }); + + // Listen for document updates from the server + collabSocket.on("update", (update) => { + Y.applyUpdate(ydoc, new Uint8Array(update)); + }); + return new Promise((resolve) => { - collabSocket.once(CollabEvents.USER_CONNECTED, () => resolve()); + // Listen for initial document state + collabSocket.once("sync", (update) => { + try { + Y.applyUpdate(ydoc, new Uint8Array(update)); + } catch (error) { + console.error("Sync initial state error: ", error); + } + resolve(); + }); }); }; @@ -112,6 +136,26 @@ export const leave = (roomId: string) => { collabSocket.disconnect(); }; +export const sendCursorUpdates = (roomId: string, cursor: Cursor) => { + collabSocket.emit("cursor_update", roomId, cursor); +}; + +export const receiveCursorUpdates = (view: EditorView) => { + if (collabSocket.hasListeners("cursor_update")) { + return; + } + + collabSocket.on("cursor_update", (cursor: Cursor) => { + view.dispatch({ + effects: updateCursor.of(cursor), + }); + }); +}; + +export const removeCursorListener = () => { + collabSocket.off("cursor_update"); +}; + export const initDocument = ( roomId: string, template: string From d4c86e9109c31e3d7f1d5f0b24b6deb8a67a5b80 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sun, 3 Nov 2024 13:36:11 +0800 Subject: [PATCH 08/16] Create ydoc on join --- .../src/handlers/websocketHandler.ts | 421 +++++++++--------- backend/collab-service/src/server.ts | 8 +- frontend/src/components/CodeEditor/index.tsx | 82 +--- frontend/src/pages/CollabSandbox/index.tsx | 17 +- frontend/src/utils/collabSocket.ts | 221 ++------- 5 files changed, 273 insertions(+), 476 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index b5784082ed..ef966ac2b9 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -1,9 +1,9 @@ import { Socket } from "socket.io"; import { io } from "../server"; import redisClient from "../config/redis"; -import { ChangeSet, Text } from "@codemirror/state"; -import { rebaseUpdates, Update } from "@codemirror/collab"; -import * as Y from "yjs"; +// import { ChangeSet, Text } from "@codemirror/state"; +// import { rebaseUpdates, Update } from "@codemirror/collab"; +import { Doc, Text, applyUpdate, encodeStateAsUpdate } from "yjs"; enum CollabEvents { // Receive @@ -11,6 +11,8 @@ enum CollabEvents { // CHANGE = "change", LEAVE = "leave", DISCONNECT = "disconnect", + UPDATE_REQUEST = "update_request", + UPDATE_CURSOR_REQUEST = "update_cursor_request", PUSH_UPDATES = "push_updates", PULL_UPDATES = "pull_updates", @@ -24,6 +26,9 @@ enum CollabEvents { // CODE_CHANGE = "code_change", PARTNER_LEFT = "partner_left", PARTNER_DISCONNECTED = "partner_disconnected", + SYNC = "sync", + UPDATE = "update", + UPDATE_CURSOR = "update_cursor", PULL_UPDATES_RESPONSE = "pull_updates_response", GET_DOCUMENT_RESPONSE = "get_document_response", @@ -31,17 +36,17 @@ enum CollabEvents { const EXPIRY_TIME = 3600; -interface CollabSession { - updates: Update[]; // updates.length = current version - doc: Text; - pendingPullUpdatesRequests: ((updates: Update[]) => void)[]; -} +// interface CollabSession { +// updates: Update[]; // updates.length = current version +// doc: Text; +// pendingPullUpdatesRequests: ((updates: Update[]) => void)[]; +// } -const collabSessions = new Map(); +// const collabSessions = new Map(); -const yCollabSessions = new Map(); +const collabSessions = new Map(); -export const handleYWebsocketCollabEvents = (socket: Socket) => { +export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on(CollabEvents.JOIN, async (roomId: string) => { if (!roomId) { return; @@ -66,34 +71,40 @@ export const handleYWebsocketCollabEvents = (socket: Socket) => { // const ydoc = new Y.Doc(); // yCollabSessions.set(roomId, ydoc); // } - if (!yCollabSessions.has(roomId)) { - const ydoc = new Y.Doc(); - yCollabSessions.set(roomId, ydoc); + if (!collabSessions.has(roomId)) { + const doc = new Doc(); + collabSessions.set(roomId, doc); } - socket.emit("sync", Y.encodeStateAsUpdate(yCollabSessions.get(roomId)!)); + socket.emit( + CollabEvents.SYNC, + encodeStateAsUpdate(collabSessions.get(roomId)!) + ); socket.emit(CollabEvents.USER_CONNECTED); // inform the other user that a new user has joined socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED); }); - socket.on("update", (roomId: string, update: Uint8Array) => { - let ydoc = yCollabSessions.get(roomId); - if (!ydoc) { - ydoc = new Y.Doc(); - } - Y.applyUpdate(ydoc, update); + socket.on( + CollabEvents.UPDATE_REQUEST, + (roomId: string, update: Uint8Array) => { + let doc = collabSessions.get(roomId); + if (!doc) { + doc = new Doc(); + } + applyUpdate(doc, update); - socket.to(roomId).emit("update", update); - }); + socket.to(roomId).emit(CollabEvents.UPDATE, update); + } + ); socket.on( - "cursor_update", + CollabEvents.UPDATE_CURSOR_REQUEST, ( roomId: string, cursor: { uid: string; username: string; from: number; to: number } ) => { - socket.to(roomId).emit("cursor_update", cursor); + socket.to(roomId).emit(CollabEvents.UPDATE_CURSOR, cursor); } ); @@ -112,183 +123,183 @@ export const handleYWebsocketCollabEvents = (socket: Socket) => { }); }; -export const handleWebsocketCollabEvents = (socket: Socket) => { - socket.on(CollabEvents.JOIN, async (roomId: string) => { - if (!roomId) { - return; - } - - const room = io.sockets.adapter.rooms.get(roomId); - if (room && room.size >= 2) { - socket.emit(CollabEvents.ROOM_FULL); - return; - } - - socket.join(roomId); - socket.data.roomId = roomId; - - // in case of disconnect, send the code to the user when he rejoins - const collabSession = await redisClient.get(`collaboration:${roomId}`); - if (collabSession) { - if (!collabSessions.has(roomId)) { - collabSessions.set(roomId, JSON.parse(collabSession) as CollabSession); - } - } else { - initCollabSession(roomId); - } - socket.emit(CollabEvents.USER_CONNECTED); - - // inform the other user that a new user has joined - socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED); - }); - - // socket.on(CollabEvents.CHANGE, async (roomId: string, code: string) => { - // if (!roomId || !code) { - // return; - // } - - // await redisClient.set(`collaboration:${roomId}`, code, { - // EX: EXPIRY_TIME, - // }); - // socket.to(roomId).emit(CollabEvents.CODE_CHANGE, code); - // }); - - socket.on(CollabEvents.LEAVE, (roomId: string) => { - if (!roomId) { - return; - } - - socket.leave(roomId); - const room = io.sockets.adapter.rooms.get(roomId); - if (room?.size === 0) { - collabSessions.delete(roomId); - } else { - socket.to(roomId).emit(CollabEvents.PARTNER_LEFT); - } - }); - - socket.on(CollabEvents.DISCONNECT, () => { - const { roomId } = socket.data; - if (roomId) { - socket.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED); - } - }); - - handleCodeEditorEvents(socket); -}; - -/* Code Editor Events */ -// Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets - -const handleCodeEditorEvents = (socket: Socket) => { - socket.on( - CollabEvents.INIT_DOCUMENT, - (roomId: string, template: string, callback: () => void) => { - initCollabSession(roomId, template); - callback(); - } - ); - - socket.on(CollabEvents.GET_DOCUMENT, (roomId: string) => { - const { updates, doc } = initCollabSession(roomId); - socket.emit( - CollabEvents.GET_DOCUMENT_RESPONSE, - updates.length, - doc.toString() - ); - }); - - socket.on(CollabEvents.PULL_UPDATES, (roomId: string, version: number) => { - const { updates, pendingPullUpdatesRequests } = initCollabSession(roomId); - if (version < updates.length) { - // send the new updates - socket.emit( - CollabEvents.PULL_UPDATES_RESPONSE, - JSON.stringify(updates.slice(version)) - ); - } else { - // wait until there are new updates to send - pendingPullUpdatesRequests.push((updates) => { - socket.emit( - CollabEvents.PULL_UPDATES_RESPONSE, - JSON.stringify(updates.slice(version)) - ); - }); - } - }); - - // received new updates, notify any pending pullUpdates requests - socket.on( - CollabEvents.PUSH_UPDATES, - async ( - roomId: string, - version: number, - newUpdates: string, - callback: () => void - ) => { - const { updates, doc, pendingPullUpdatesRequests } = - initCollabSession(roomId); - let docUpdates = JSON.parse(newUpdates) as readonly Update[]; - - try { - // If the given version is the latest version, apply the new updates. - // Else, rebase updates first. - if (version < updates.length) { - docUpdates = rebaseUpdates(docUpdates, updates.slice(version)); - } - - for (const update of docUpdates) { - const changes = ChangeSet.fromJSON(update.changes); - updates.push({ - clientID: update.clientID, - changes: changes, - effects: update.effects, - }); - - const updatedCollabSession = { - updates: updates, - doc: changes.apply(doc), - pendingPullUpdatesRequests: pendingPullUpdatesRequests, - }; - collabSessions.set(roomId, updatedCollabSession); - - await redisClient.set( - `collaboration:${roomId}`, - JSON.stringify(updatedCollabSession), - { - EX: EXPIRY_TIME, - } - ); - } - callback(); - - while (pendingPullUpdatesRequests.length) { - pendingPullUpdatesRequests.pop()!(updates); - } - } catch (error) { - console.error(error); - callback(); - } - } - ); -}; - -const initCollabSession = ( - roomId: string, - template?: string -): CollabSession => { - const collabSession = collabSessions.get(roomId); - if (!collabSession) { - collabSessions.set(roomId, { - updates: [], - doc: Text.of([template ? template : ""]), - pendingPullUpdatesRequests: [], - }); - } else if (template) { - collabSessions.set(roomId, { - ...collabSession, - doc: Text.of([template]), - }); - } - return collabSessions.get(roomId)!; -}; +// export const handleWebsocketCollabEvents = (socket: Socket) => { +// socket.on(CollabEvents.JOIN, async (roomId: string) => { +// if (!roomId) { +// return; +// } + +// const room = io.sockets.adapter.rooms.get(roomId); +// if (room && room.size >= 2) { +// socket.emit(CollabEvents.ROOM_FULL); +// return; +// } + +// socket.join(roomId); +// socket.data.roomId = roomId; + +// // in case of disconnect, send the code to the user when he rejoins +// const collabSession = await redisClient.get(`collaboration:${roomId}`); +// if (collabSession) { +// if (!collabSessions.has(roomId)) { +// collabSessions.set(roomId, JSON.parse(collabSession) as CollabSession); +// } +// } else { +// initCollabSession(roomId); +// } +// socket.emit(CollabEvents.USER_CONNECTED); + +// // inform the other user that a new user has joined +// socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED); +// }); + +// // socket.on(CollabEvents.CHANGE, async (roomId: string, code: string) => { +// // if (!roomId || !code) { +// // return; +// // } + +// // await redisClient.set(`collaboration:${roomId}`, code, { +// // EX: EXPIRY_TIME, +// // }); +// // socket.to(roomId).emit(CollabEvents.CODE_CHANGE, code); +// // }); + +// socket.on(CollabEvents.LEAVE, (roomId: string) => { +// if (!roomId) { +// return; +// } + +// socket.leave(roomId); +// const room = io.sockets.adapter.rooms.get(roomId); +// if (room?.size === 0) { +// collabSessions.delete(roomId); +// } else { +// socket.to(roomId).emit(CollabEvents.PARTNER_LEFT); +// } +// }); + +// socket.on(CollabEvents.DISCONNECT, () => { +// const { roomId } = socket.data; +// if (roomId) { +// socket.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED); +// } +// }); + +// handleCodeEditorEvents(socket); +// }; + +// /* Code Editor Events */ +// // Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets + +// const handleCodeEditorEvents = (socket: Socket) => { +// socket.on( +// CollabEvents.INIT_DOCUMENT, +// (roomId: string, template: string, callback: () => void) => { +// initCollabSession(roomId, template); +// callback(); +// } +// ); + +// socket.on(CollabEvents.GET_DOCUMENT, (roomId: string) => { +// const { updates, doc } = initCollabSession(roomId); +// socket.emit( +// CollabEvents.GET_DOCUMENT_RESPONSE, +// updates.length, +// doc.toString() +// ); +// }); + +// socket.on(CollabEvents.PULL_UPDATES, (roomId: string, version: number) => { +// const { updates, pendingPullUpdatesRequests } = initCollabSession(roomId); +// if (version < updates.length) { +// // send the new updates +// socket.emit( +// CollabEvents.PULL_UPDATES_RESPONSE, +// JSON.stringify(updates.slice(version)) +// ); +// } else { +// // wait until there are new updates to send +// pendingPullUpdatesRequests.push((updates) => { +// socket.emit( +// CollabEvents.PULL_UPDATES_RESPONSE, +// JSON.stringify(updates.slice(version)) +// ); +// }); +// } +// }); + +// // received new updates, notify any pending pullUpdates requests +// socket.on( +// CollabEvents.PUSH_UPDATES, +// async ( +// roomId: string, +// version: number, +// newUpdates: string, +// callback: () => void +// ) => { +// const { updates, doc, pendingPullUpdatesRequests } = +// initCollabSession(roomId); +// let docUpdates = JSON.parse(newUpdates) as readonly Update[]; + +// try { +// // If the given version is the latest version, apply the new updates. +// // Else, rebase updates first. +// if (version < updates.length) { +// docUpdates = rebaseUpdates(docUpdates, updates.slice(version)); +// } + +// for (const update of docUpdates) { +// const changes = ChangeSet.fromJSON(update.changes); +// updates.push({ +// clientID: update.clientID, +// changes: changes, +// effects: update.effects, +// }); + +// const updatedCollabSession = { +// updates: updates, +// doc: changes.apply(doc), +// pendingPullUpdatesRequests: pendingPullUpdatesRequests, +// }; +// collabSessions.set(roomId, updatedCollabSession); + +// await redisClient.set( +// `collaboration:${roomId}`, +// JSON.stringify(updatedCollabSession), +// { +// EX: EXPIRY_TIME, +// } +// ); +// } +// callback(); + +// while (pendingPullUpdatesRequests.length) { +// pendingPullUpdatesRequests.pop()!(updates); +// } +// } catch (error) { +// console.error(error); +// callback(); +// } +// } +// ); +// }; + +// const initCollabSession = ( +// roomId: string, +// template?: string +// ): CollabSession => { +// const collabSession = collabSessions.get(roomId); +// if (!collabSession) { +// collabSessions.set(roomId, { +// updates: [], +// doc: Text.of([template ? template : ""]), +// pendingPullUpdatesRequests: [], +// }); +// } else if (template) { +// collabSessions.set(roomId, { +// ...collabSession, +// doc: Text.of([template]), +// }); +// } +// return collabSessions.get(roomId)!; +// }; diff --git a/backend/collab-service/src/server.ts b/backend/collab-service/src/server.ts index ccfea342ab..c1d11c7333 100644 --- a/backend/collab-service/src/server.ts +++ b/backend/collab-service/src/server.ts @@ -1,9 +1,6 @@ import http from "http"; import app, { allowedOrigins } from "./app.ts"; -import { - handleWebsocketCollabEvents, - handleYWebsocketCollabEvents, -} from "./handlers/websocketHandler.ts"; +import { handleWebsocketCollabEvents } from "./handlers/websocketHandler.ts"; import { Server, Socket } from "socket.io"; import { connectRedis } from "./config/redis.ts"; @@ -17,8 +14,7 @@ export const io = new Server(server, { }); io.on("connection", (socket: Socket) => { - // handleWebsocketCollabEvents(socket); - handleYWebsocketCollabEvents(socket); + handleWebsocketCollabEvents(socket); }); const PORT = process.env.SERVICE_PORT || 3003; diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 3cf4f694eb..df1e97838d 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -3,34 +3,23 @@ import { langs } from "@uiw/codemirror-extensions-langs"; import { basicSetup } from "@uiw/codemirror-extensions-basic-setup"; import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; -import { useEffect, useState } from "react"; -import { - awareness, - getDocument, - initDocument, - peerExtension, - receiveCursorUpdates, - removeCursorListener, - ytext, -} from "../../utils/collabSocket"; -import Loader from "../Loader"; +import { useEffect } from "react"; +import { removeCursorListener } from "../../utils/collabSocket"; import { cursorExtension } from "../../utils/collabCursor"; import { yCollab } from "y-codemirror.next"; +import { Text } from "yjs"; +import { Awareness } from "y-protocols/awareness"; interface CodeEditorProps { - uid: string; - username: string; + editorState?: { text: Text; awareness: Awareness }; + uid?: string; + username?: string; language: string; template?: string; roomId?: string; isReadOnly?: boolean; } -type CodeEditorState = { - version: number | null; - doc: string | null; -}; - const languageSupport = { Python: langs.python(), Java: langs.java(), @@ -39,59 +28,19 @@ const languageSupport = { const CodeEditor: React.FC = (props) => { const { - uid, - username, + editorState, + uid = "", + username = "", language, template = "", roomId = "", isReadOnly = false, } = props; - const [codeEditorState, setCodeEditorState] = useState({ - version: null, - doc: null, - }); - useEffect(() => { return () => removeCursorListener(); }, []); - // useEffect(() => { - // if (isReadOnly) { - // setCodeEditorState({ - // version: 0, - // doc: template, - // }); - // return; - // } - - // const fetchDocument = async () => { - // if (!roomId) { - // return; - // } - - // try { - // if (template) { - // await initDocument(roomId, template); - // } - - // const { version, doc } = await getDocument(roomId); - // setCodeEditorState({ - // version: version, - // doc: doc.toString(), - // }); - // } catch (error) { - // console.error("Error fetching document: ", error); - // } - // }; - - // fetchDocument(); - // }, []); - - // if (codeEditorState.version === null || codeEditorState.doc === null) { - // return ; - // } - return ( = (props) => { extensions={[ basicSetup(), languageSupport[language as keyof typeof languageSupport], - yCollab(ytext, awareness), - // peerExtension(roomId, codeEditorState.version, uid), - cursorExtension(roomId, uid, username), + ...(!isReadOnly && editorState + ? [ + yCollab(editorState.text, editorState.awareness), + cursorExtension(roomId, uid, username), + ] + : []), EditorView.editable.of(!isReadOnly), EditorState.readOnly.of(isReadOnly), ]} - // value={codeEditorState.doc} + value={isReadOnly ? template : undefined} /> ); }; diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index 268f677f0b..690118c329 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -22,10 +22,16 @@ import QuestionDetailComponent from "../../components/QuestionDetail"; import { Navigate } from "react-router-dom"; import CodeEditor from "../../components/CodeEditor"; import { join, leave } from "../../utils/collabSocket"; +import { Text } from "yjs"; +import { Awareness } from "y-protocols/awareness"; const CollabSandbox: React.FC = () => { - const [connected, setConnected] = useState(false); + // const [connected, setConnected] = useState(false); const [showErrorScreen, setShowErrorScreen] = useState(false); + const [editorState, setEditorState] = useState<{ + text: Text; + awareness: Awareness; + } | null>(null); const match = useMatch(); if (!match) { @@ -59,14 +65,14 @@ const CollabSandbox: React.FC = () => { } getQuestionById(questionId, dispatch); - if (!matchId || connected) { + if (!matchId || editorState) { return; } const connectToCollabSession = async () => { try { - await join(matchId); - setConnected(true); + const { text, awareness } = await join(matchId); + setEditorState({ text, awareness }); } catch (error) { console.error("Error connecting to collab session: ", error); } @@ -110,7 +116,7 @@ const CollabSandbox: React.FC = () => { ); } - if (!selectedQuestion || !connected) { + if (!selectedQuestion || !editorState) { return ; } @@ -185,6 +191,7 @@ const CollabSandbox: React.FC = () => { })} > => { - const updates = fullUpdates.map((update) => ({ - clientID: update.clientID, // client who made the update - changes: update.changes.toJSON(), // document updates - effects: update.effects, // cursor updates - })); - - return new Promise((resolve) => { - collabSocket.emit( - CollabEvents.PUSH_UPDATES, - roomId, - version, - JSON.stringify(updates), - () => resolve() - ); - }); -}; - -const pullUpdates = ( - roomId: string, - version: number -): Promise => { - return new Promise((resolve) => { - collabSocket.emit(CollabEvents.PULL_UPDATES, roomId, version); - - collabSocket.once(CollabEvents.PULL_UPDATES_RESPONSE, (updates: string) => { - resolve(JSON.parse(updates)); - }); - }).then((updates) => - updates.map((update) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const effects: StateEffect[] = []; - - update.effects?.forEach((effect) => { - if ( - effect.value.uid && - effect.value.username && - effect.value.from && - effect.value.to - ) { - const cursor: Cursor = { - uid: effect.value.uid, - username: effect.value.username, - from: effect.value.from, - to: effect.value.to, - }; - effects.push(updateCursor.of(cursor)); - } - }); - - return { - clientID: update.clientID, - changes: ChangeSet.fromJSON(update.changes), - effects: effects, - }; - }) - ); -}; - -export const ydoc = new Y.Doc(); -export const ytext = ydoc.getText("codemirror"); -export const awareness = new Awareness(ydoc); - -export const join = (roomId: string): Promise => { +export const join = ( + roomId: string +): Promise<{ text: Text; awareness: Awareness }> => { collabSocket.connect(); collabSocket.emit(CollabEvents.JOIN, roomId); - // Listen for local document changes and send to the server - ydoc.on("update", (update) => { - collabSocket.emit("update", roomId, update); + const doc = new Doc(); + const text = doc.getText("codemirror"); + const awareness = new Awareness(doc); + + doc.on(CollabEvents.UPDATE, (update) => { + collabSocket.emit(CollabEvents.UPDATE_REQUEST, roomId, update); }); - // Listen for document updates from the server - collabSocket.on("update", (update) => { - Y.applyUpdate(ydoc, new Uint8Array(update)); + collabSocket.on(CollabEvents.UPDATE, (update) => { + applyUpdate(doc, new Uint8Array(update)); }); return new Promise((resolve) => { - // Listen for initial document state - collabSocket.once("sync", (update) => { - try { - Y.applyUpdate(ydoc, new Uint8Array(update)); - } catch (error) { - console.error("Sync initial state error: ", error); - } - resolve(); + collabSocket.once(CollabEvents.SYNC, (update) => { + applyUpdate(doc, new Uint8Array(update)); + resolve({ text: text, awareness: awareness }); }); }); }; @@ -137,15 +59,15 @@ export const leave = (roomId: string) => { }; export const sendCursorUpdates = (roomId: string, cursor: Cursor) => { - collabSocket.emit("cursor_update", roomId, cursor); + collabSocket.emit(CollabEvents.UPDATE_CURSOR_REQUEST, roomId, cursor); }; export const receiveCursorUpdates = (view: EditorView) => { - if (collabSocket.hasListeners("cursor_update")) { + if (collabSocket.hasListeners(CollabEvents.UPDATE_CURSOR)) { return; } - collabSocket.on("cursor_update", (cursor: Cursor) => { + collabSocket.on(CollabEvents.UPDATE_CURSOR, (cursor: Cursor) => { view.dispatch({ effects: updateCursor.of(cursor), }); @@ -153,96 +75,5 @@ export const receiveCursorUpdates = (view: EditorView) => { }; export const removeCursorListener = () => { - collabSocket.off("cursor_update"); -}; - -export const initDocument = ( - roomId: string, - template: string -): Promise => { - return new Promise((resolve) => { - collabSocket.emit(CollabEvents.INIT_DOCUMENT, roomId, template, () => - resolve() - ); - }); -}; - -export const getDocument = ( - roomId: string -): Promise<{ version: number; doc: Text }> => { - return new Promise((resolve) => { - collabSocket.emit(CollabEvents.GET_DOCUMENT, roomId); - - collabSocket.once( - CollabEvents.GET_DOCUMENT_RESPONSE, - (version: number, doc: string) => { - resolve({ - version: version, - doc: Text.of(doc.split("\n")), - }); - } - ); - }); -}; - -// handles push and pull updates -export const peerExtension = ( - roomId: string, - startVersion: number, - uid: string -) => { - const plugin = ViewPlugin.fromClass( - class { - private pushingUpdates = false; // to ensure only one running push request - private pullUpdates = true; - - constructor(private view: EditorView) { - this.pull(); - } - - update(update: ViewUpdate) { - if (update.docChanged || update.transactions.length) { - this.push(); - } - } - - async push() { - const updates = sendableUpdates(this.view.state); - if (this.pushingUpdates || !updates.length) { - return; - } - this.pushingUpdates = true; - const version = getSyncedVersion(this.view.state); - await pushUpdates(roomId, version, updates); - this.pushingUpdates = false; - - // check if there are still updates to push (failed / new updates) - if (sendableUpdates(this.view.state).length) { - setTimeout(() => this.push(), 100); - } - } - - async pull() { - while (this.pullUpdates) { - const version = getSyncedVersion(this.view.state); - const updates = await pullUpdates(roomId, version); // returns only if there are updates - this.view.dispatch(receiveUpdates(this.view.state, updates)); - } - } - - destroy() { - this.pullUpdates = false; - } - } - ); - - return [ - collab({ - startVersion: startVersion, - clientID: uid, - sharedEffects: (transaction) => - transaction.effects.filter((effect) => effect.is(updateCursor)), - }), - plugin, - ]; + collabSocket.off(CollabEvents.UPDATE_CURSOR); }; From 87e07dd4cf82217a72d3fec7b1e80f4e15890eaf Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sun, 3 Nov 2024 21:42:54 +0800 Subject: [PATCH 09/16] Allow editor to be initialized with template --- .../src/handlers/websocketHandler.ts | 294 ++++-------------- frontend/src/components/CodeEditor/index.tsx | 15 +- frontend/src/pages/CollabSandbox/index.tsx | 16 +- frontend/src/utils/collabCursor.ts | 6 +- frontend/src/utils/collabSocket.ts | 52 ++-- 5 files changed, 119 insertions(+), 264 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index ef966ac2b9..58b8c30839 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -1,59 +1,52 @@ import { Socket } from "socket.io"; import { io } from "../server"; import redisClient from "../config/redis"; -// import { ChangeSet, Text } from "@codemirror/state"; -// import { rebaseUpdates, Update } from "@codemirror/collab"; import { Doc, Text, applyUpdate, encodeStateAsUpdate } from "yjs"; enum CollabEvents { // Receive JOIN = "join", - // CHANGE = "change", LEAVE = "leave", DISCONNECT = "disconnect", + INIT_DOCUMENT = "init_document", UPDATE_REQUEST = "update_request", UPDATE_CURSOR_REQUEST = "update_cursor_request", - PUSH_UPDATES = "push_updates", - PULL_UPDATES = "pull_updates", - INIT_DOCUMENT = "init_document", - GET_DOCUMENT = "get_document", - // Send ROOM_FULL = "room_full", USER_CONNECTED = "user_connected", NEW_USER_CONNECTED = "new_user_connected", - // CODE_CHANGE = "code_change", PARTNER_LEFT = "partner_left", PARTNER_DISCONNECTED = "partner_disconnected", SYNC = "sync", UPDATE = "update", UPDATE_CURSOR = "update_cursor", - - PULL_UPDATES_RESPONSE = "pull_updates_response", - GET_DOCUMENT_RESPONSE = "get_document_response", } -const EXPIRY_TIME = 3600; - // interface CollabSession { -// updates: Update[]; // updates.length = current version -// doc: Text; -// pendingPullUpdatesRequests: ((updates: Update[]) => void)[]; +// doc: Doc; +// areBothUsersConnected: boolean; // } -// const collabSessions = new Map(); +const EXPIRY_TIME = 3600; const collabSessions = new Map(); +const roomReadiness = new Map(); + +const CONNECTION_DELAY = 3000; // time window to allow for page re-renders / refresh +const userConnections = new Map(); export const handleWebsocketCollabEvents = (socket: Socket) => { - socket.on(CollabEvents.JOIN, async (roomId: string) => { - if (!roomId) { + socket.on(CollabEvents.JOIN, async (uid: string, roomId: string) => { + const connectionKey = `${uid}:${roomId}`; + if (userConnections.has(connectionKey)) { + clearTimeout(userConnections.get(connectionKey)!); return; } + userConnections.set(connectionKey, null); const room = io.sockets.adapter.rooms.get(roomId); - if (room && room.size >= 2) { + if (room && room?.size >= 2) { socket.emit(CollabEvents.ROOM_FULL); return; } @@ -61,28 +54,40 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.join(roomId); socket.data.roomId = roomId; - // in case of disconnect, send the code to the user when he rejoins - // const collabSession = await redisClient.get(`collaboration:${roomId}`); - // if (collabSession) { - // if (!yCollabSessions.has(roomId)) { - // yCollabSessions.set(roomId, JSON.parse(collabSession) as Y.Doc); - // } - // } else { - // const ydoc = new Y.Doc(); - // yCollabSessions.set(roomId, ydoc); - // } - if (!collabSessions.has(roomId)) { + if ( + io.sockets.adapter.rooms.get(roomId)?.size === 2 && + !collabSessions.has(roomId) + ) { + console.log("create collab session: ", roomId); + const doc = new Doc(); + doc.on(CollabEvents.UPDATE, (update) => { + console.log("server doc updated: ", roomId); + io.to(roomId).emit(CollabEvents.UPDATE, update); + }); + collabSessions.set(roomId, doc); + roomReadiness.set(roomId, false); + + io.to(roomId).emit(CollabEvents.SYNC); + } + }); + + socket.on(CollabEvents.INIT_DOCUMENT, (roomId: string, template: string) => { + let doc = collabSessions.get(roomId); + if (!doc) { + doc = new Doc(); } - socket.emit( - CollabEvents.SYNC, - encodeStateAsUpdate(collabSessions.get(roomId)!) - ); - socket.emit(CollabEvents.USER_CONNECTED); - // inform the other user that a new user has joined - socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED); + const isPartnerReady = roomReadiness.get(roomId); + console.log("partner ready: ", isPartnerReady); + console.log("doc: ", doc.getText().length); + if (isPartnerReady && doc.getText().length === 0) { + console.log("insert template"); + doc.getText().insert(0, template); + } else { + roomReadiness.set(roomId, true); + } }); socket.on( @@ -94,7 +99,8 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { } applyUpdate(doc, update); - socket.to(roomId).emit(CollabEvents.UPDATE, update); + // socket.to(roomId).emit(CollabEvents.UPDATE, update); + // socket.to(roomId).emit(CollabEvents.UPDATE, encodeStateAsUpdate(doc)); } ); @@ -108,198 +114,28 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { } ); - socket.on(CollabEvents.LEAVE, (roomId: string) => { - if (!roomId) { + socket.on(CollabEvents.LEAVE, (uid: string, roomId: string) => { + const connectionKey = `${uid}:${roomId}`; + if (!userConnections.has(connectionKey)) { return; } - socket.leave(roomId); - const room = io.sockets.adapter.rooms.get(roomId); - if (room?.size === 0) { - collabSessions.delete(roomId); - } else { - socket.to(roomId).emit(CollabEvents.PARTNER_LEFT); - } - }); -}; - -// export const handleWebsocketCollabEvents = (socket: Socket) => { -// socket.on(CollabEvents.JOIN, async (roomId: string) => { -// if (!roomId) { -// return; -// } + clearTimeout(userConnections.get(connectionKey)!); -// const room = io.sockets.adapter.rooms.get(roomId); -// if (room && room.size >= 2) { -// socket.emit(CollabEvents.ROOM_FULL); -// return; -// } + const connectionTimeout = setTimeout(() => { + userConnections.delete(connectionKey); + socket.leave(roomId); + socket.disconnect(); -// socket.join(roomId); -// socket.data.roomId = roomId; - -// // in case of disconnect, send the code to the user when he rejoins -// const collabSession = await redisClient.get(`collaboration:${roomId}`); -// if (collabSession) { -// if (!collabSessions.has(roomId)) { -// collabSessions.set(roomId, JSON.parse(collabSession) as CollabSession); -// } -// } else { -// initCollabSession(roomId); -// } -// socket.emit(CollabEvents.USER_CONNECTED); - -// // inform the other user that a new user has joined -// socket.to(roomId).emit(CollabEvents.NEW_USER_CONNECTED); -// }); - -// // socket.on(CollabEvents.CHANGE, async (roomId: string, code: string) => { -// // if (!roomId || !code) { -// // return; -// // } - -// // await redisClient.set(`collaboration:${roomId}`, code, { -// // EX: EXPIRY_TIME, -// // }); -// // socket.to(roomId).emit(CollabEvents.CODE_CHANGE, code); -// // }); - -// socket.on(CollabEvents.LEAVE, (roomId: string) => { -// if (!roomId) { -// return; -// } - -// socket.leave(roomId); -// const room = io.sockets.adapter.rooms.get(roomId); -// if (room?.size === 0) { -// collabSessions.delete(roomId); -// } else { -// socket.to(roomId).emit(CollabEvents.PARTNER_LEFT); -// } -// }); - -// socket.on(CollabEvents.DISCONNECT, () => { -// const { roomId } = socket.data; -// if (roomId) { -// socket.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED); -// } -// }); - -// handleCodeEditorEvents(socket); -// }; - -// /* Code Editor Events */ -// // Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets - -// const handleCodeEditorEvents = (socket: Socket) => { -// socket.on( -// CollabEvents.INIT_DOCUMENT, -// (roomId: string, template: string, callback: () => void) => { -// initCollabSession(roomId, template); -// callback(); -// } -// ); - -// socket.on(CollabEvents.GET_DOCUMENT, (roomId: string) => { -// const { updates, doc } = initCollabSession(roomId); -// socket.emit( -// CollabEvents.GET_DOCUMENT_RESPONSE, -// updates.length, -// doc.toString() -// ); -// }); - -// socket.on(CollabEvents.PULL_UPDATES, (roomId: string, version: number) => { -// const { updates, pendingPullUpdatesRequests } = initCollabSession(roomId); -// if (version < updates.length) { -// // send the new updates -// socket.emit( -// CollabEvents.PULL_UPDATES_RESPONSE, -// JSON.stringify(updates.slice(version)) -// ); -// } else { -// // wait until there are new updates to send -// pendingPullUpdatesRequests.push((updates) => { -// socket.emit( -// CollabEvents.PULL_UPDATES_RESPONSE, -// JSON.stringify(updates.slice(version)) -// ); -// }); -// } -// }); - -// // received new updates, notify any pending pullUpdates requests -// socket.on( -// CollabEvents.PUSH_UPDATES, -// async ( -// roomId: string, -// version: number, -// newUpdates: string, -// callback: () => void -// ) => { -// const { updates, doc, pendingPullUpdatesRequests } = -// initCollabSession(roomId); -// let docUpdates = JSON.parse(newUpdates) as readonly Update[]; - -// try { -// // If the given version is the latest version, apply the new updates. -// // Else, rebase updates first. -// if (version < updates.length) { -// docUpdates = rebaseUpdates(docUpdates, updates.slice(version)); -// } - -// for (const update of docUpdates) { -// const changes = ChangeSet.fromJSON(update.changes); -// updates.push({ -// clientID: update.clientID, -// changes: changes, -// effects: update.effects, -// }); - -// const updatedCollabSession = { -// updates: updates, -// doc: changes.apply(doc), -// pendingPullUpdatesRequests: pendingPullUpdatesRequests, -// }; -// collabSessions.set(roomId, updatedCollabSession); - -// await redisClient.set( -// `collaboration:${roomId}`, -// JSON.stringify(updatedCollabSession), -// { -// EX: EXPIRY_TIME, -// } -// ); -// } -// callback(); - -// while (pendingPullUpdatesRequests.length) { -// pendingPullUpdatesRequests.pop()!(updates); -// } -// } catch (error) { -// console.error(error); -// callback(); -// } -// } -// ); -// }; + const room = io.sockets.adapter.rooms.get(roomId); + if (!room || room.size === 0) { + console.log("delete collab session: ", roomId); + collabSessions.get(roomId)?.destroy(); + collabSessions.delete(roomId); + roomReadiness.delete(roomId); + } + }, CONNECTION_DELAY); -// const initCollabSession = ( -// roomId: string, -// template?: string -// ): CollabSession => { -// const collabSession = collabSessions.get(roomId); -// if (!collabSession) { -// collabSessions.set(roomId, { -// updates: [], -// doc: Text.of([template ? template : ""]), -// pendingPullUpdatesRequests: [], -// }); -// } else if (template) { -// collabSessions.set(roomId, { -// ...collabSession, -// doc: Text.of([template]), -// }); -// } -// return collabSessions.get(roomId)!; -// }; + userConnections.set(connectionKey, connectionTimeout); + }); +}; diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index df1e97838d..17228c6952 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -3,8 +3,8 @@ import { langs } from "@uiw/codemirror-extensions-langs"; import { basicSetup } from "@uiw/codemirror-extensions-basic-setup"; import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; -import { useEffect } from "react"; -import { removeCursorListener } from "../../utils/collabSocket"; +import { useEffect, useRef } from "react"; +import { initDocument, removeCursorListener } from "../../utils/collabSocket"; import { cursorExtension } from "../../utils/collabCursor"; import { yCollab } from "y-codemirror.next"; import { Text } from "yjs"; @@ -37,8 +37,17 @@ const CodeEditor: React.FC = (props) => { isReadOnly = false, } = props; + const effectRan = useRef(false); + useEffect(() => { - return () => removeCursorListener(); + if (!effectRan.current) { + initDocument(roomId, "code template"); + } + + return () => { + effectRan.current = true; + removeCursorListener(); + }; }, []); return ( diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index 690118c329..f2970c43d9 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -21,17 +21,11 @@ import reducer, { import QuestionDetailComponent from "../../components/QuestionDetail"; import { Navigate } from "react-router-dom"; import CodeEditor from "../../components/CodeEditor"; -import { join, leave } from "../../utils/collabSocket"; -import { Text } from "yjs"; -import { Awareness } from "y-protocols/awareness"; +import { CollabData, join, leave } from "../../utils/collabSocket"; const CollabSandbox: React.FC = () => { - // const [connected, setConnected] = useState(false); const [showErrorScreen, setShowErrorScreen] = useState(false); - const [editorState, setEditorState] = useState<{ - text: Text; - awareness: Awareness; - } | null>(null); + const [editorState, setEditorState] = useState(null); const match = useMatch(); if (!match) { @@ -65,13 +59,13 @@ const CollabSandbox: React.FC = () => { } getQuestionById(questionId, dispatch); - if (!matchId || editorState) { + if (!matchUser || !matchId) { return; } const connectToCollabSession = async () => { try { - const { text, awareness } = await join(matchId); + const { text, awareness } = await join(matchUser.id, matchId); setEditorState({ text, awareness }); } catch (error) { console.error("Error connecting to collab session: ", error); @@ -80,7 +74,7 @@ const CollabSandbox: React.FC = () => { connectToCollabSession(); - return () => leave(matchId); + return () => leave(matchUser.id, matchId); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/src/utils/collabCursor.ts b/frontend/src/utils/collabCursor.ts index b2e435b473..2cfa18b1da 100644 --- a/frontend/src/utils/collabCursor.ts +++ b/frontend/src/utils/collabCursor.ts @@ -5,7 +5,7 @@ import { WidgetType, } from "@codemirror/view"; import { StateField, StateEffect } from "@codemirror/state"; -import { receiveCursorUpdates, sendCursorUpdates } from "./collabSocket"; +import { receiveCursorUpdate, sendCursorUpdate } from "./collabSocket"; // Adapted from https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets @@ -149,11 +149,11 @@ export const cursorExtension = ( to: transaction.selection.ranges[0].to, }; - sendCursorUpdates(roomId, cursor); + sendCursorUpdate(roomId, cursor); } }); - receiveCursorUpdates(update.view); + receiveCursorUpdate(update.view); }), ]; }; diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 1a74f82754..8ab1b18abf 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -1,68 +1,84 @@ import { EditorView } from "@codemirror/view"; -// import { Text } from "@codemirror/state"; import { io } from "socket.io-client"; import { updateCursor, Cursor } from "./collabCursor"; import { Doc, Text, applyUpdate } from "yjs"; import { Awareness } from "y-protocols/awareness"; -// Adapted from https://codemirror.net/examples/collab/ and https://github.com/BjornTheProgrammer/react-codemirror-collab-sockets - enum CollabEvents { // Send JOIN = "join", LEAVE = "leave", + INIT_DOCUMENT = "init_document", UPDATE_REQUEST = "update_request", UPDATE_CURSOR_REQUEST = "update_cursor_request", // Receive - USER_CONNECTED = "user_connected", + // USER_CONNECTED = "user_connected", SYNC = "sync", UPDATE = "update", UPDATE_CURSOR = "update_cursor", } +export type CollabData = { + text: Text; + awareness: Awareness; +}; + const COLLAB_SOCKET_URL = "http://localhost:3003"; const collabSocket = io(COLLAB_SOCKET_URL, { reconnectionAttempts: 3, autoConnect: false, }); -export const join = ( - roomId: string -): Promise<{ text: Text; awareness: Awareness }> => { +let doc: Doc; +let text: Text; +let awareness: Awareness; + +export const join = (uid: string, roomId: string): Promise => { collabSocket.connect(); - collabSocket.emit(CollabEvents.JOIN, roomId); - const doc = new Doc(); - const text = doc.getText("codemirror"); - const awareness = new Awareness(doc); + doc = new Doc(); + text = doc.getText(); + awareness = new Awareness(doc); doc.on(CollabEvents.UPDATE, (update) => { + console.log("client sent update"); collabSocket.emit(CollabEvents.UPDATE_REQUEST, roomId, update); }); collabSocket.on(CollabEvents.UPDATE, (update) => { + console.log("client received update"); applyUpdate(doc, new Uint8Array(update)); }); + collabSocket.emit(CollabEvents.JOIN, uid, roomId); + console.log("join: ", roomId); + return new Promise((resolve) => { - collabSocket.once(CollabEvents.SYNC, (update) => { - applyUpdate(doc, new Uint8Array(update)); + // resolve({ text: text, awareness: awareness }); + collabSocket.once(CollabEvents.SYNC, () => { + console.log("sync"); resolve({ text: text, awareness: awareness }); }); }); }; -export const leave = (roomId: string) => { - collabSocket.emit(CollabEvents.LEAVE, roomId); - collabSocket.disconnect(); +export const initDocument = (roomId: string, template: string) => { + collabSocket.emit(CollabEvents.INIT_DOCUMENT, roomId, template); +}; + +export const leave = (uid: string, roomId: string) => { + console.log("leave: ", roomId); + collabSocket.off(CollabEvents.UPDATE); + collabSocket.emit(CollabEvents.LEAVE, uid, roomId); + doc.destroy(); }; -export const sendCursorUpdates = (roomId: string, cursor: Cursor) => { +export const sendCursorUpdate = (roomId: string, cursor: Cursor) => { collabSocket.emit(CollabEvents.UPDATE_CURSOR_REQUEST, roomId, cursor); }; -export const receiveCursorUpdates = (view: EditorView) => { +export const receiveCursorUpdate = (view: EditorView) => { if (collabSocket.hasListeners(CollabEvents.UPDATE_CURSOR)) { return; } From 3edb8fe3e1163cbcb190f20b47c79c3dd4b14e14 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sun, 3 Nov 2024 23:38:21 +0800 Subject: [PATCH 10/16] Upgrade to yjs v2 --- .../src/handlers/websocketHandler.ts | 116 ++++++++++-------- frontend/src/pages/CollabSandbox/index.tsx | 18 ++- frontend/src/utils/collabSocket.ts | 32 ++--- 3 files changed, 95 insertions(+), 71 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 58b8c30839..4a47d90ffa 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -1,7 +1,7 @@ import { Socket } from "socket.io"; import { io } from "../server"; import redisClient from "../config/redis"; -import { Doc, Text, applyUpdate, encodeStateAsUpdate } from "yjs"; +import { Doc, applyUpdateV2, encodeStateAsUpdateV2 } from "yjs"; enum CollabEvents { // Receive @@ -13,28 +13,17 @@ enum CollabEvents { UPDATE_CURSOR_REQUEST = "update_cursor_request", // Send - ROOM_FULL = "room_full", - USER_CONNECTED = "user_connected", - NEW_USER_CONNECTED = "new_user_connected", - PARTNER_LEFT = "partner_left", - PARTNER_DISCONNECTED = "partner_disconnected", - SYNC = "sync", - UPDATE = "update", + ROOM_READY = "room_ready", + UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", } -// interface CollabSession { -// doc: Doc; -// areBothUsersConnected: boolean; -// } - const EXPIRY_TIME = 3600; - -const collabSessions = new Map(); -const roomReadiness = new Map(); - const CONNECTION_DELAY = 3000; // time window to allow for page re-renders / refresh + const userConnections = new Map(); +const collabSessions = new Map(); +const partnerReadiness = new Map(); export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on(CollabEvents.JOIN, async (uid: string, roomId: string) => { @@ -47,7 +36,7 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { const room = io.sockets.adapter.rooms.get(roomId); if (room && room?.size >= 2) { - socket.emit(CollabEvents.ROOM_FULL); + socket.emit(CollabEvents.ROOM_READY, false); return; } @@ -58,49 +47,27 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { io.sockets.adapter.rooms.get(roomId)?.size === 2 && !collabSessions.has(roomId) ) { - console.log("create collab session: ", roomId); - - const doc = new Doc(); - doc.on(CollabEvents.UPDATE, (update) => { - console.log("server doc updated: ", roomId); - io.to(roomId).emit(CollabEvents.UPDATE, update); - }); - - collabSessions.set(roomId, doc); - roomReadiness.set(roomId, false); - - io.to(roomId).emit(CollabEvents.SYNC); + createCollabSession(roomId); + io.to(roomId).emit(CollabEvents.ROOM_READY, true); } }); socket.on(CollabEvents.INIT_DOCUMENT, (roomId: string, template: string) => { - let doc = collabSessions.get(roomId); - if (!doc) { - doc = new Doc(); - } + const doc = getDocument(roomId); + const isPartnerReady = partnerReadiness.get(roomId); - const isPartnerReady = roomReadiness.get(roomId); - console.log("partner ready: ", isPartnerReady); - console.log("doc: ", doc.getText().length); if (isPartnerReady && doc.getText().length === 0) { - console.log("insert template"); doc.getText().insert(0, template); } else { - roomReadiness.set(roomId, true); + partnerReadiness.set(roomId, true); } }); socket.on( CollabEvents.UPDATE_REQUEST, (roomId: string, update: Uint8Array) => { - let doc = collabSessions.get(roomId); - if (!doc) { - doc = new Doc(); - } - applyUpdate(doc, update); - - // socket.to(roomId).emit(CollabEvents.UPDATE, update); - // socket.to(roomId).emit(CollabEvents.UPDATE, encodeStateAsUpdate(doc)); + const doc = getDocument(roomId); + applyUpdateV2(doc, new Uint8Array(update)); } ); @@ -129,13 +96,60 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { const room = io.sockets.adapter.rooms.get(roomId); if (!room || room.size === 0) { - console.log("delete collab session: ", roomId); - collabSessions.get(roomId)?.destroy(); - collabSessions.delete(roomId); - roomReadiness.delete(roomId); + removeCollabSession(roomId); } }, CONNECTION_DELAY); userConnections.set(connectionKey, connectionTimeout); }); }; + +const createCollabSession = (roomId: string) => { + console.log("set up collab session: ", roomId); + const doc = new Doc(); + doc.on(CollabEvents.UPDATE, (update) => { + console.log("server doc updated: ", roomId); + // await saveDocument(roomId, doc); + io.to(roomId).emit(CollabEvents.UPDATE, update); + }); + + collabSessions.set(roomId, doc); + partnerReadiness.set(roomId, false); +}; + +const removeCollabSession = (roomId: string) => { + console.log("delete collab session: ", roomId); + collabSessions.get(roomId)?.destroy(); + collabSessions.delete(roomId); + partnerReadiness.delete(roomId); +}; + +// const saveDocument = async (roomId: string, doc: Doc) => { +// const decodedDoc = new TextDecoder().decode(encodeStateAsUpdateV2(doc)); +// await redisClient.set(`collaboration:${roomId}`, decodedDoc, { +// EX: EXPIRY_TIME, +// }); +// }; + +const getDocument = (roomId: string) => { + let doc = collabSessions.get(roomId); + if (!doc) { + console.log("no document in collabSessions"); + doc = new Doc(); + // const redisData = await redisClient.get(`collaboration:${roomId}`); + // if (redisData) { + // console.log("use redis document"); + // const update = new TextEncoder().encode(redisData); + // applyUpdateV2(doc, new Uint8Array(update)); + // } + + // doc.on(CollabEvents.UPDATE, async (update) => { + // console.log("server doc updated: ", roomId); + // await saveDocument(roomId, doc!); + // io.to(roomId).emit(CollabEvents.UPDATE, update); + // }); + // collabSessions.set(roomId, doc); + } + + return doc; +}; diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index f2970c43d9..b1bd57a2f3 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -21,11 +21,13 @@ import reducer, { import QuestionDetailComponent from "../../components/QuestionDetail"; import { Navigate } from "react-router-dom"; import CodeEditor from "../../components/CodeEditor"; -import { CollabData, join, leave } from "../../utils/collabSocket"; +import { CollabSessionData, join, leave } from "../../utils/collabSocket"; const CollabSandbox: React.FC = () => { const [showErrorScreen, setShowErrorScreen] = useState(false); - const [editorState, setEditorState] = useState(null); + const [editorState, setEditorState] = useState( + null + ); const match = useMatch(); if (!match) { @@ -65,8 +67,12 @@ const CollabSandbox: React.FC = () => { const connectToCollabSession = async () => { try { - const { text, awareness } = await join(matchUser.id, matchId); - setEditorState({ text, awareness }); + const editorState = await join(matchUser.id, matchId); + if (editorState.ready) { + setEditorState(editorState); + } else { + setShowErrorScreen(true); + } } catch (error) { console.error("Error connecting to collab session: ", error); } @@ -104,8 +110,8 @@ const CollabSandbox: React.FC = () => { if (showErrorScreen) { return ( ); } diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 8ab1b18abf..8a21a32a93 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -1,7 +1,7 @@ import { EditorView } from "@codemirror/view"; import { io } from "socket.io-client"; import { updateCursor, Cursor } from "./collabCursor"; -import { Doc, Text, applyUpdate } from "yjs"; +import { Doc, Text, applyUpdateV2 } from "yjs"; import { Awareness } from "y-protocols/awareness"; enum CollabEvents { @@ -13,13 +13,13 @@ enum CollabEvents { UPDATE_CURSOR_REQUEST = "update_cursor_request", // Receive - // USER_CONNECTED = "user_connected", - SYNC = "sync", - UPDATE = "update", + ROOM_READY = "room_ready", + UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", } -export type CollabData = { +export type CollabSessionData = { + ready: boolean; text: Text; awareness: Awareness; }; @@ -34,31 +34,35 @@ let doc: Doc; let text: Text; let awareness: Awareness; -export const join = (uid: string, roomId: string): Promise => { +export const join = ( + uid: string, + roomId: string +): Promise => { collabSocket.connect(); doc = new Doc(); text = doc.getText(); awareness = new Awareness(doc); - doc.on(CollabEvents.UPDATE, (update) => { - console.log("client sent update"); - collabSocket.emit(CollabEvents.UPDATE_REQUEST, roomId, update); + doc.on(CollabEvents.UPDATE, (update, origin) => { + if (origin != uid) { + console.log("client sent update"); + collabSocket.emit(CollabEvents.UPDATE_REQUEST, roomId, update); + } }); collabSocket.on(CollabEvents.UPDATE, (update) => { console.log("client received update"); - applyUpdate(doc, new Uint8Array(update)); + applyUpdateV2(doc, new Uint8Array(update), uid); }); collabSocket.emit(CollabEvents.JOIN, uid, roomId); console.log("join: ", roomId); return new Promise((resolve) => { - // resolve({ text: text, awareness: awareness }); - collabSocket.once(CollabEvents.SYNC, () => { - console.log("sync"); - resolve({ text: text, awareness: awareness }); + collabSocket.once(CollabEvents.ROOM_READY, (ready: boolean) => { + console.log("room ready: ", ready); + resolve({ ready: ready, text: text, awareness: awareness }); }); }); }; From e94ec373057aa8c9a7d55afb6ca179eaf0d52754 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Mon, 4 Nov 2024 01:23:24 +0800 Subject: [PATCH 11/16] Save code to redis --- .../src/handlers/websocketHandler.ts | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 4a47d90ffa..14f4495993 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -66,8 +66,12 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on( CollabEvents.UPDATE_REQUEST, (roomId: string, update: Uint8Array) => { - const doc = getDocument(roomId); - applyUpdateV2(doc, new Uint8Array(update)); + const doc = collabSessions.get(roomId); + if (doc) { + applyUpdateV2(doc, new Uint8Array(update)); + } else { + // TODO: error handling + } } ); @@ -106,14 +110,7 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { const createCollabSession = (roomId: string) => { console.log("set up collab session: ", roomId); - const doc = new Doc(); - doc.on(CollabEvents.UPDATE, (update) => { - console.log("server doc updated: ", roomId); - // await saveDocument(roomId, doc); - io.to(roomId).emit(CollabEvents.UPDATE, update); - }); - - collabSessions.set(roomId, doc); + getDocument(roomId); partnerReadiness.set(roomId, false); }; @@ -124,32 +121,35 @@ const removeCollabSession = (roomId: string) => { partnerReadiness.delete(roomId); }; -// const saveDocument = async (roomId: string, doc: Doc) => { -// const decodedDoc = new TextDecoder().decode(encodeStateAsUpdateV2(doc)); -// await redisClient.set(`collaboration:${roomId}`, decodedDoc, { -// EX: EXPIRY_TIME, -// }); -// }; - const getDocument = (roomId: string) => { let doc = collabSessions.get(roomId); if (!doc) { - console.log("no document in collabSessions"); doc = new Doc(); - // const redisData = await redisClient.get(`collaboration:${roomId}`); - // if (redisData) { - // console.log("use redis document"); - // const update = new TextEncoder().encode(redisData); - // applyUpdateV2(doc, new Uint8Array(update)); - // } - - // doc.on(CollabEvents.UPDATE, async (update) => { - // console.log("server doc updated: ", roomId); - // await saveDocument(roomId, doc!); - // io.to(roomId).emit(CollabEvents.UPDATE, update); - // }); - // collabSessions.set(roomId, doc); + doc.on(CollabEvents.UPDATE, async (update) => { + console.log("server doc updated: ", roomId); + saveDocument(roomId, doc!); + io.to(roomId).emit(CollabEvents.UPDATE, update); + }); + collabSessions.set(roomId, doc); } return doc; }; + +const saveDocument = async (roomId: string, doc: Doc) => { + const docState = encodeStateAsUpdateV2(doc); + const docAsString = Buffer.from(docState).toString("base64"); + await redisClient.set(`collaboration:${roomId}`, docAsString, { + EX: EXPIRY_TIME, + }); +}; + +// const getDocumentFromStore = async (roomId: string) => { +// const doc = getDocument(roomId); +// const storeData = await redisClient.get(`collaboration:${roomId}`); +// if (storeData) { +// const update = Buffer.from(storeData, "base64"); +// applyUpdateV2(doc, new Uint8Array(update)); +// } +// return !!storeData; +// }; From 2268430c385f81372b4cb6797bfc078c38f47284 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Mon, 4 Nov 2024 04:19:28 +0800 Subject: [PATCH 12/16] Partial handling of reconnection --- .../src/handlers/websocketHandler.ts | 36 ++++++++++----- frontend/src/components/CodeEditor/index.tsx | 3 +- frontend/src/utils/collabSocket.ts | 46 +++++++++++++++---- 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 14f4495993..89298a80b7 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -11,6 +11,7 @@ enum CollabEvents { INIT_DOCUMENT = "init_document", UPDATE_REQUEST = "update_request", UPDATE_CURSOR_REQUEST = "update_cursor_request", + RECONNECT_REQUEST = "reconnect_request", // Send ROOM_READY = "room_ready", @@ -106,6 +107,27 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { userConnections.set(connectionKey, connectionTimeout); }); + + socket.on(CollabEvents.RECONNECT_REQUEST, async (roomId: string) => { + // TODO: Handle recconnection + socket.join(roomId); + + const doc = getDocument(roomId); + const storeData = await redisClient.get(`collaboration:${roomId}`); + + if (storeData) { + const tempDoc = new Doc(); + const update = Buffer.from(storeData, "base64"); + applyUpdateV2(tempDoc, new Uint8Array(update)); + const tempText = tempDoc.getText().toString(); + + const text = doc.getText(); + doc.transact(() => { + text.delete(0, text.length); + text.insert(0, tempText); + }); + } + }); }; const createCollabSession = (roomId: string) => { @@ -125,10 +147,10 @@ const getDocument = (roomId: string) => { let doc = collabSessions.get(roomId); if (!doc) { doc = new Doc(); - doc.on(CollabEvents.UPDATE, async (update) => { + doc.on(CollabEvents.UPDATE, (_update) => { console.log("server doc updated: ", roomId); saveDocument(roomId, doc!); - io.to(roomId).emit(CollabEvents.UPDATE, update); + io.to(roomId).emit(CollabEvents.UPDATE, encodeStateAsUpdateV2(doc!)); }); collabSessions.set(roomId, doc); } @@ -143,13 +165,3 @@ const saveDocument = async (roomId: string, doc: Doc) => { EX: EXPIRY_TIME, }); }; - -// const getDocumentFromStore = async (roomId: string) => { -// const doc = getDocument(roomId); -// const storeData = await redisClient.get(`collaboration:${roomId}`); -// if (storeData) { -// const update = Buffer.from(storeData, "base64"); -// applyUpdateV2(doc, new Uint8Array(update)); -// } -// return !!storeData; -// }; diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 17228c6952..166d12c90b 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -4,7 +4,7 @@ import { basicSetup } from "@uiw/codemirror-extensions-basic-setup"; import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; import { useEffect, useRef } from "react"; -import { initDocument, removeCursorListener } from "../../utils/collabSocket"; +import { initDocument } from "../../utils/collabSocket"; import { cursorExtension } from "../../utils/collabCursor"; import { yCollab } from "y-codemirror.next"; import { Text } from "yjs"; @@ -46,7 +46,6 @@ const CodeEditor: React.FC = (props) => { return () => { effectRan.current = true; - removeCursorListener(); }; }, []); diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 8a21a32a93..8785efb7c3 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -11,11 +11,17 @@ enum CollabEvents { INIT_DOCUMENT = "init_document", UPDATE_REQUEST = "update_request", UPDATE_CURSOR_REQUEST = "update_cursor_request", + RECONNECT_REQUEST = "reconnect_request", // Receive ROOM_READY = "room_ready", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", + SOCKET_DISCONNECT = "disconnect", + SOCKET_CLIENT_DISCONNECT = "io client disconnect", + SOCKET_SERVER_DISCONNECT = "io server disconnect", + SOCKET_RECONNECT_SUCCESS = "reconnect", + SOCKET_RECONNECT_FAILED = "reconnect_failed", } export type CollabSessionData = { @@ -39,6 +45,7 @@ export const join = ( roomId: string ): Promise => { collabSocket.connect(); + initConnectionStatusListeners(roomId); doc = new Doc(); text = doc.getText(); @@ -46,22 +53,18 @@ export const join = ( doc.on(CollabEvents.UPDATE, (update, origin) => { if (origin != uid) { - console.log("client sent update"); collabSocket.emit(CollabEvents.UPDATE_REQUEST, roomId, update); } }); collabSocket.on(CollabEvents.UPDATE, (update) => { - console.log("client received update"); applyUpdateV2(doc, new Uint8Array(update), uid); }); collabSocket.emit(CollabEvents.JOIN, uid, roomId); - console.log("join: ", roomId); return new Promise((resolve) => { collabSocket.once(CollabEvents.ROOM_READY, (ready: boolean) => { - console.log("room ready: ", ready); resolve({ ready: ready, text: text, awareness: awareness }); }); }); @@ -72,8 +75,9 @@ export const initDocument = (roomId: string, template: string) => { }; export const leave = (uid: string, roomId: string) => { - console.log("leave: ", roomId); - collabSocket.off(CollabEvents.UPDATE); + collabSocket.removeAllListeners(); + collabSocket.io.removeListener(CollabEvents.SOCKET_RECONNECT_SUCCESS); + collabSocket.io.removeListener(CollabEvents.SOCKET_RECONNECT_FAILED); collabSocket.emit(CollabEvents.LEAVE, uid, roomId); doc.destroy(); }; @@ -94,6 +98,32 @@ export const receiveCursorUpdate = (view: EditorView) => { }); }; -export const removeCursorListener = () => { - collabSocket.off(CollabEvents.UPDATE_CURSOR); +export const reconnectRequest = (roomId: string) => { + collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); +}; + +const initConnectionStatusListeners = (roomId: string) => { + if (!collabSocket.hasListeners(CollabEvents.SOCKET_DISCONNECT)) { + collabSocket.on(CollabEvents.SOCKET_DISCONNECT, (reason) => { + if ( + reason !== CollabEvents.SOCKET_CLIENT_DISCONNECT && + reason !== CollabEvents.SOCKET_SERVER_DISCONNECT + ) { + // TODO: Handle socket disconnection + } + }); + } + + if (!collabSocket.io.hasListeners(CollabEvents.SOCKET_RECONNECT_SUCCESS)) { + collabSocket.io.on(CollabEvents.SOCKET_RECONNECT_SUCCESS, () => { + console.log("reconnect request"); + collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); + }); + } + + if (!collabSocket.io.hasListeners(CollabEvents.SOCKET_RECONNECT_FAILED)) { + collabSocket.io.on(CollabEvents.SOCKET_RECONNECT_FAILED, () => { + console.log("reconnect failed"); + }); + } }; From 65101182ff009c8c23cc9d8500f961e099ad259e Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Mon, 4 Nov 2024 10:21:53 +0800 Subject: [PATCH 13/16] Clean up --- backend/collab-service/src/handlers/websocketHandler.ts | 5 ++--- frontend/src/components/CodeEditor/index.tsx | 6 +++++- frontend/src/pages/Matched/index.tsx | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 89298a80b7..062cd1526d 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -17,6 +17,8 @@ enum CollabEvents { ROOM_READY = "room_ready", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", + // PARTNER_LEFT = "partner_left", + // PARTNER_DISCONNECTED = "partner_disconnected", } const EXPIRY_TIME = 3600; @@ -131,13 +133,11 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { }; const createCollabSession = (roomId: string) => { - console.log("set up collab session: ", roomId); getDocument(roomId); partnerReadiness.set(roomId, false); }; const removeCollabSession = (roomId: string) => { - console.log("delete collab session: ", roomId); collabSessions.get(roomId)?.destroy(); collabSessions.delete(roomId); partnerReadiness.delete(roomId); @@ -148,7 +148,6 @@ const getDocument = (roomId: string) => { if (!doc) { doc = new Doc(); doc.on(CollabEvents.UPDATE, (_update) => { - console.log("server doc updated: ", roomId); saveDocument(roomId, doc!); io.to(roomId).emit(CollabEvents.UPDATE, encodeStateAsUpdateV2(doc!)); }); diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 166d12c90b..8bab6cb8a5 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -40,8 +40,12 @@ const CodeEditor: React.FC = (props) => { const effectRan = useRef(false); useEffect(() => { + if (isReadOnly) { + return; + } + if (!effectRan.current) { - initDocument(roomId, "code template"); + initDocument(roomId, template); } return () => { diff --git a/frontend/src/pages/Matched/index.tsx b/frontend/src/pages/Matched/index.tsx index c545ac2f84..d801014df1 100644 --- a/frontend/src/pages/Matched/index.tsx +++ b/frontend/src/pages/Matched/index.tsx @@ -20,7 +20,7 @@ import { Navigate } from "react-router-dom"; import Loader from "../../components/Loader"; import { CheckCircleOutlineRounded } from "@mui/icons-material"; -const acceptanceTimeout = 10; +const acceptanceTimeout = 15; const Matched: React.FC = () => { const match = useMatch(); From d59076fddda00c876f182175df763ae933a2f423 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Mon, 4 Nov 2024 11:05:19 +0800 Subject: [PATCH 14/16] Fix conflict errors --- frontend/src/components/Chat/index.tsx | 2 +- frontend/src/components/CodeEditor/index.tsx | 1 + frontend/src/contexts/MatchContext.tsx | 8 ++++++-- frontend/src/pages/CollabSandbox/index.tsx | 8 ++++---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Chat/index.tsx b/frontend/src/components/Chat/index.tsx index 8c5da246c3..4b62f7a337 100644 --- a/frontend/src/components/Chat/index.tsx +++ b/frontend/src/components/Chat/index.tsx @@ -71,7 +71,7 @@ const Chat: React.FC = ({ isActive }) => { }, []); useEffect(() => { - // initliase listerner for incoming messages + // initialize listener for incoming messages communicationSocket.on( CommunicationEvents.USER_JOINED, (message: Message) => { diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 8bab6cb8a5..66796165e0 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -69,6 +69,7 @@ const CodeEditor: React.FC = (props) => { cursorExtension(roomId, uid, username), ] : []), + EditorView.lineWrapping, EditorView.editable.of(!isReadOnly), EditorState.readOnly.of(isReadOnly), ]} diff --git a/frontend/src/contexts/MatchContext.tsx b/frontend/src/contexts/MatchContext.tsx index bac689821c..7210002499 100644 --- a/frontend/src/contexts/MatchContext.tsx +++ b/frontend/src/contexts/MatchContext.tsx @@ -81,12 +81,12 @@ type MatchContextType = { matchingTimeout: () => void; matchOfferTimeout: () => void; verifyMatchStatus: () => void; + getMatchId: () => string | null; handleEndSessionClick: () => void; handleRejectEndSession: () => void; handleConfirmEndSession: () => void; matchUser: MatchUser | null; matchCriteria: MatchCriteria | null; - matchId: string | null; partner: MatchUser | null; matchPending: boolean; loading: boolean; @@ -489,6 +489,10 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { ); }; + const getMatchId = () => { + return matchId; + }; + const handleEndSessionClick = () => { setIsEndSessionModalOpen(true); }; @@ -513,12 +517,12 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { matchingTimeout, matchOfferTimeout, verifyMatchStatus, + getMatchId, handleEndSessionClick, handleRejectEndSession, handleConfirmEndSession, matchUser, matchCriteria, - matchId, partner, matchPending, loading, diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index d4b7289ae3..cd57822547 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -66,12 +66,12 @@ const CollabSandbox: React.FC = () => { const { verifyMatchStatus, + getMatchId, handleRejectEndSession, handleConfirmEndSession, matchUser, partner, matchCriteria, - matchId, loading, isEndSessionModalOpen, questionId, @@ -93,6 +93,7 @@ const CollabSandbox: React.FC = () => { } getQuestionById(questionId, dispatch); + const matchId = getMatchId(); if (!matchUser || !matchId) { return; } @@ -135,7 +136,7 @@ const CollabSandbox: React.FC = () => { return ; } - if (!matchUser || !partner || !matchCriteria || !matchId) { + if (!matchUser || !partner || !matchCriteria || !getMatchId()) { return ; } @@ -222,7 +223,6 @@ const CollabSandbox: React.FC = () => { flex: 1, width: "100%", paddingTop: theme.spacing(2), - paddingBottom: theme.spacing(2), })} > { ? selectedQuestion.cTemplate : "" } - roomId={matchId} + roomId={getMatchId()!} /> Date: Mon, 4 Nov 2024 15:55:27 +0800 Subject: [PATCH 15/16] Integrate code editor for qns history --- .../CollabSessionControls/index.tsx | 2 + frontend/src/pages/CollabSandbox/index.tsx | 2 + .../src/pages/QuestionHistoryDetail/index.tsx | 239 +++++++++++------- frontend/src/utils/collabSocket.ts | 7 + 4 files changed, 152 insertions(+), 98 deletions(-) diff --git a/frontend/src/components/CollabSessionControls/index.tsx b/frontend/src/components/CollabSessionControls/index.tsx index 3f261e0077..cbd3fd1299 100644 --- a/frontend/src/components/CollabSessionControls/index.tsx +++ b/frontend/src/components/CollabSessionControls/index.tsx @@ -8,6 +8,7 @@ import { extractMinutesFromTime, extractSecondsFromTime, } from "../../utils/sessionTime"; +import { getDocumentContent } from "../../utils/collabSocket"; const CollabSessionControls: React.FC = () => { const [time, setTime] = useState(0); @@ -45,6 +46,7 @@ const CollabSessionControls: React.FC = () => { time )} mins ${extractSecondsFromTime(time)} secs` ); + console.log(`Code: ${getDocumentContent()}`); }} // TODO: implement submit function with time taken pop-up > Submit diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index cd57822547..c1a72e3906 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -222,7 +222,9 @@ const CollabSandbox: React.FC = () => { sx={(theme) => ({ flex: 1, width: "100%", + maxHeight: "50vh", paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), })} > { const { qnHistoryId } = useParams<{ qnHistoryId: string }>(); - const [qnhistState, qnhistDispatch] = useReducer(qnHistoryReducer, initialQHState); + const [qnhistState, qnhistDispatch] = useReducer( + qnHistoryReducer, + initialQHState + ); const [qnState, qnDispatch] = useReducer(reducer, initialState); const [loading, setLoading] = useState(true); const navigate = useNavigate(); @@ -30,11 +48,14 @@ const QuestionHistoryDetail: React.FC = () => { const { user } = auth; - const tableHeaders = ["Status", "Date submitted", "Time taken", "Partner"] + const tableHeaders = ["Status", "Date submitted", "Time taken", "Partner"]; useEffect(() => { if (!qnHistoryId) { - setSelectedQnHistoryError("Unable to fetch question history.", qnhistDispatch); + setSelectedQnHistoryError( + "Unable to fetch question history.", + qnhistDispatch + ); return; } @@ -47,8 +68,7 @@ const QuestionHistoryDetail: React.FC = () => { getQuestionById(qnhistState.selectedQnHistory.questionId, qnDispatch); } setTimeout(() => setLoading(false), 500); - }, [qnhistState]) - + }, [qnhistState]); const getPartnerId = (userIds: string[], currUserId: string): string => { if (currUserId == userIds[0]) { @@ -56,12 +76,12 @@ const QuestionHistoryDetail: React.FC = () => { } else { return userIds[0]; } - } + }; if (loading) { return ; } - + if (!qnhistState.selectedQnHistory) { if (qnhistState.selectedQnHistoryError) { return ( @@ -75,116 +95,139 @@ const QuestionHistoryDetail: React.FC = () => { } } - const partnerId = user && qnhistState.selectedQnHistory && getPartnerId(qnhistState.selectedQnHistory.userIds, user.id); + const partnerId = + user && + qnhistState.selectedQnHistory && + getPartnerId(qnhistState.selectedQnHistory.userIds, user.id); return ( - navigate(`/profile/${user?.id}`)}> + navigate(`/profile/${user?.id}`)} + > - Latest submission details - { user && qnhistState.selectedQnHistory && - - ({ - "& .MuiTableCell-root": { padding: theme.spacing(1.2) }, - whiteSpace: "nowrap", - })} - > - - - {tableHeaders.map((header) => ( - - - {header} + + Latest submission details + + {user && qnhistState.selectedQnHistory && ( + +
({ + "& .MuiTableCell-root": { padding: theme.spacing(1.2) }, + whiteSpace: "nowrap", + })} + > + + + {tableHeaders.map((header) => ( + + + {header} + + + ))} + + + + + + + {qnhistState.selectedQnHistory.submissionStatus} - ))} - - - - - - - {qnhistState.selectedQnHistory.submissionStatus} - - - - - {convertDateString(qnhistState.selectedQnHistory.dateAttempted)} - - - - + {convertDateString( + qnhistState.selectedQnHistory.dateAttempted + )} + + + - {`${qnhistState.selectedQnHistory.timeTaken} mins`} - - - - + {`${qnhistState.selectedQnHistory.timeTaken} mins`} + + + navigate(`/profile/${partnerId}`)} > - {"Go to partner profile"} - - - - -
-
- } + navigate(`/profile/${partnerId}`)} + > + {"Go to partner profile"} + + + + + + + )} ({ flex: 1, marginRight: theme.spacing(2) })}> - {qnState.selectedQuestion - ? - : - } + {qnState.selectedQuestion ? ( + + ) : ( + + )} ({ flex: 1, marginLeft: theme.spacing(2) })}> - Code editor + ({ + flex: 1, + width: "100%", + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + })} + > + + diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 8785efb7c3..1b0f035ac1 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -127,3 +127,10 @@ const initConnectionStatusListeners = (roomId: string) => { }); } }; + +export const getDocumentContent = () => { + if (!doc.isDestroyed) { + return text.toString(); + } + return ""; +}; From 14b02992f914612e5a52950a2768641320960fae Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Mon, 4 Nov 2024 23:01:11 +0800 Subject: [PATCH 16/16] Fix code template initialization --- .../src/handlers/websocketHandler.ts | 6 +++- frontend/src/components/CodeEditor/index.tsx | 34 +++++++++++-------- frontend/src/utils/collabCursor.ts | 2 +- frontend/src/utils/collabSocket.ts | 10 +++++- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 062cd1526d..1c98b93305 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -15,6 +15,7 @@ enum CollabEvents { // Send ROOM_READY = "room_ready", + DOCUMENT_READY = "document_ready", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", // PARTNER_LEFT = "partner_left", @@ -60,7 +61,10 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { const isPartnerReady = partnerReadiness.get(roomId); if (isPartnerReady && doc.getText().length === 0) { - doc.getText().insert(0, template); + doc.transact(() => { + doc.getText().insert(0, template); + }); + io.to(roomId).emit(CollabEvents.DOCUMENT_READY); } else { partnerReadiness.set(roomId, true); } diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 66796165e0..77f4910a00 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -1,9 +1,9 @@ -import CodeMirror from "@uiw/react-codemirror"; +import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { langs } from "@uiw/codemirror-extensions-langs"; import { basicSetup } from "@uiw/codemirror-extensions-basic-setup"; import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { initDocument } from "../../utils/collabSocket"; import { cursorExtension } from "../../utils/collabCursor"; import { yCollab } from "y-codemirror.next"; @@ -37,24 +37,30 @@ const CodeEditor: React.FC = (props) => { isReadOnly = false, } = props; - const effectRan = useRef(false); + const [isEditorReady, setIsEditorReady] = useState(false); + const [isDocumentLoaded, setIsDocumentLoaded] = useState(false); - useEffect(() => { - if (isReadOnly) { - return; + const onEditorReady = (editor: ReactCodeMirrorRef) => { + if (!isEditorReady && editor?.editor && editor?.state && editor?.view) { + setIsEditorReady(true); } + }; - if (!effectRan.current) { - initDocument(roomId, template); + useEffect(() => { + if (isReadOnly || !isEditorReady) { + return; } - return () => { - effectRan.current = true; + const loadTemplate = async () => { + await initDocument(uid, roomId, template); + setIsDocumentLoaded(true); }; - }, []); + loadTemplate(); + }, [isReadOnly, isEditorReady]); return ( = (props) => { ] : []), EditorView.lineWrapping, - EditorView.editable.of(!isReadOnly), - EditorState.readOnly.of(isReadOnly), + EditorView.editable.of(!isReadOnly && isDocumentLoaded), + EditorState.readOnly.of(isReadOnly || !isDocumentLoaded), ]} - value={isReadOnly ? template : undefined} + value={isReadOnly ? template : template ? "Loading code template..." : ""} /> ); }; diff --git a/frontend/src/utils/collabCursor.ts b/frontend/src/utils/collabCursor.ts index 2cfa18b1da..5de6bbf76c 100644 --- a/frontend/src/utils/collabCursor.ts +++ b/frontend/src/utils/collabCursor.ts @@ -66,7 +66,6 @@ const cursorStateField = (uid: string): StateField => { for (const effect of transaction.effects) { // check for partner's cursor updates if (effect.is(updateCursor) && effect.value.uid !== uid) { - // if (effect.is(updateCursor)) { const cursorUpdates = []; if (effect.value.from !== effect.value.to) { @@ -121,6 +120,7 @@ const cursorBaseTheme = EditorView.baseTheme({ position: "absolute", marginTop: "-35px", marginLeft: "0px", + whiteSpace: "nowrap", }, ".cm-cursor-color": { backgroundColor: "#f6a1a1", diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 1b0f035ac1..f52bc24819 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -15,6 +15,7 @@ enum CollabEvents { // Receive ROOM_READY = "room_ready", + DOCUMENT_READY = "document_ready", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", SOCKET_DISCONNECT = "disconnect", @@ -70,8 +71,15 @@ export const join = ( }); }; -export const initDocument = (roomId: string, template: string) => { +export const initDocument = (uid: string, roomId: string, template: string) => { collabSocket.emit(CollabEvents.INIT_DOCUMENT, roomId, template); + + return new Promise((resolve) => { + collabSocket.once(CollabEvents.UPDATE, (update) => { + applyUpdateV2(doc, new Uint8Array(update), uid); + resolve(); + }); + }); }; export const leave = (uid: string, roomId: string) => {