├── wb-frontend ├── public │ ├── favicon.ico │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── apple-icon-precomposed.png │ ├── browserconfig.xml │ └── manifest.json ├── vercel.json ├── vite.config.js ├── .gitignore ├── src │ ├── main.jsx │ ├── index.css │ ├── App.jsx │ ├── assets │ │ ├── github.svg │ │ ├── gandalf-noshadow.svg │ │ └── gandalf.svg │ ├── stores │ │ ├── uiStore.js │ │ └── whiteboardStore.js │ ├── App.css │ ├── utils │ │ ├── smoothing.js │ │ └── shapeUtils.js │ ├── components │ │ ├── uiElements.jsx │ │ ├── StrokeGen.jsx │ │ ├── Gemini.jsx │ │ ├── HandTracking.jsx │ │ ├── Toolbar.jsx │ │ ├── TopMenu.jsx │ │ ├── Canvas.jsx │ │ └── AdvancedFeatures.jsx │ └── routes │ │ ├── Room.jsx │ │ └── Home.jsx ├── README.md ├── eslint.config.js ├── package.json └── index.html ├── wb-backend ├── package.json ├── dockerfile ├── ws-server.js └── package-lock.json ├── README.md ├── instructions.md └── .gitignore /wb-frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/favicon.ico -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon.png -------------------------------------------------------------------------------- /wb-frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /wb-frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /wb-frontend/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/favicon-96x96.png -------------------------------------------------------------------------------- /wb-frontend/public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /wb-frontend/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { "source": "/(.*)", "destination": "/index.html" } 4 | ] 5 | } -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /wb-frontend/public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /wb-frontend/public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /wb-frontend/public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /wb-frontend/public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/android-icon-36x36.png -------------------------------------------------------------------------------- /wb-frontend/public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/android-icon-48x48.png -------------------------------------------------------------------------------- /wb-frontend/public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/android-icon-72x72.png -------------------------------------------------------------------------------- /wb-frontend/public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/android-icon-96x96.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /wb-frontend/public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/android-icon-144x144.png -------------------------------------------------------------------------------- /wb-frontend/public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/android-icon-192x192.png -------------------------------------------------------------------------------- /wb-frontend/public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronthekiehn/gandalf/HEAD/wb-frontend/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /wb-frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from '@tailwindcss/vite' 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tailwindcss()], 8 | }) 9 | -------------------------------------------------------------------------------- /wb-frontend/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /wb-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@google/generative-ai": "^0.24.0", 4 | "canvas": "^3.1.0", 5 | "cors": "^2.8.5", 6 | "dotenv": "^16.4.7", 7 | "http": "^0.0.1-security", 8 | "mime-types": "^3.0.1", 9 | "ws": "^8.18.1", 10 | "y-websocket": "^1.3.12" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /wb-frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /wb-frontend/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.jsx' 5 | import { BrowserRouter } from 'react-router-dom' 6 | 7 | ReactDOM.createRoot(document.getElementById('root')).render( 8 | 9 | 10 | 11 | 12 | , 13 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GANDALF 2 | A magic whiteboard. 3 | Visit [gandalf.design](https://gandalf.design) to try it out. 4 | 5 | ## Features 6 | - Hand tracking 7 | - Live multiplayer 8 | - AI drawing 9 | - Shape Recognition 10 | - Looks sexy 11 | 12 | ## Future Potential Features 13 | - [ ] Infinite Canvas 14 | - [ ] Textboxes (w/ AI chat) 15 | - [ ] Images 16 | - [ ] Live chat 17 | - [ ] Private rooms 18 | - [ ] Public rooms 19 | - [ ] Games (like skribblio or tic-tac-toe) 20 | - [ ] Login / persistent whiteboards 21 | -------------------------------------------------------------------------------- /wb-frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @custom-variant dark (&:where(.dark, .dark *)); 4 | 5 | *{ 6 | font-family: "Inclusive Sans", sans-serif; 7 | font-optical-sizing: auto; 8 | font-weight: 400; 9 | font-style: normal; 10 | } 11 | 12 | html, body { 13 | overscroll-behavior: none; /* prevents scroll chaining */ 14 | overflow: hidden; /* prevents page scroll */ 15 | touch-action: none; /* disables gestures like panning/zooming */ 16 | height: 100%; 17 | margin: 0; 18 | } -------------------------------------------------------------------------------- /wb-backend/dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /app 4 | 5 | 6 | # Install dependencies needed for canvas 7 | RUN apt-get update && apt-get install -y \ 8 | python3 \ 9 | python3-pip \ 10 | build-essential \ 11 | libcairo2-dev \ 12 | libpango1.0-dev \ 13 | libjpeg-dev \ 14 | libgif-dev \ 15 | librsvg2-dev 16 | 17 | # Copy package files 18 | COPY package*.json ./ 19 | 20 | # Install dependencies 21 | RUN npm install 22 | 23 | # Copy the rest of the application 24 | COPY . . 25 | 26 | # Set the command to run your app 27 | CMD ["node", "ws-server.js"] -------------------------------------------------------------------------------- /wb-frontend/src/App.jsx: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | import Room from './routes/Room'; 4 | import Home from './routes/Home'; 5 | import { useEffect } from 'react'; 6 | import useUIStore from './stores/uiStore'; 7 | 8 | function App() { 9 | const darkMode = useUIStore((state) => state.darkMode); 10 | 11 | useEffect(() => { 12 | const html = document.documentElement; 13 | if (darkMode) { 14 | html.classList.add('dark'); 15 | } else { 16 | html.classList.remove('dark'); 17 | } 18 | }, [darkMode]); 19 | 20 | return ( 21 | 22 | } /> 23 | } /> 24 | 25 | ); 26 | } 27 | 28 | export default App; -------------------------------------------------------------------------------- /wb-frontend/src/assets/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /wb-frontend/src/stores/uiStore.js: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | const useUIStore = create((set) => ({ 4 | darkMode: JSON.parse(localStorage.getItem("darkMode")) || false, 5 | useHandTracking: false, 6 | settingsOpen: false, 7 | toggleSettings: () => set((state) => ({ settingsOpen: !state.settingsOpen })), 8 | setSettings: (value) => set({ settingsOpen: value }), 9 | 10 | toggleDarkMode: () => set((state) => { 11 | const newMode = !state.darkMode; 12 | localStorage.setItem('darkMode', JSON.stringify(newMode)); 13 | return { darkMode: newMode }; 14 | }), 15 | toggleHandTracking: () => set((state) => { 16 | const newValue = !state.useHandTracking; 17 | return { useHandTracking: newValue }; 18 | }), 19 | setHandTracking: (value) => set({ useHandTracking: value }), 20 | })); 21 | 22 | export default useUIStore; -------------------------------------------------------------------------------- /wb-frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. 13 | -------------------------------------------------------------------------------- /wb-frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /wb-frontend/src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | transition: all 300ms, color 0ms; 3 | } 4 | 5 | 6 | video { 7 | position: absolute; 8 | visibility: hidden; 9 | } 10 | 11 | .fade-in-fast{ 12 | animation: fade-in 0.1s ease-in-out; 13 | } 14 | .fade-in { 15 | animation: fade-in 0.3s ease-in-out; 16 | } 17 | 18 | .slide-up { 19 | animation: slide-up 0.1s ease-in-out, fade-in 0.1s ease-in-out; 20 | } 21 | 22 | 23 | .slide-left { 24 | animation: slide-left 0.1s ease-in-out, fade-in 0.1s ease-in-out; 25 | } 26 | 27 | @keyframes slide-left { 28 | from { 29 | transform: translateX(5px); 30 | } 31 | to { 32 | transform: translateY(0); 33 | } 34 | } 35 | 36 | @keyframes slide-up { 37 | from { 38 | transform: translateY(5px); 39 | } 40 | to { 41 | transform: translateY(0); 42 | } 43 | } 44 | 45 | @keyframes fade-in { 46 | from { 47 | opacity: 0; 48 | } 49 | to { 50 | opacity: 1; 51 | } 52 | } -------------------------------------------------------------------------------- /wb-frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | 6 | export default [ 7 | { ignores: ['dist'] }, 8 | { 9 | files: ['**/*.{js,jsx}'], 10 | languageOptions: { 11 | ecmaVersion: 2020, 12 | globals: globals.browser, 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | ecmaFeatures: { jsx: true }, 16 | sourceType: 'module', 17 | }, 18 | }, 19 | plugins: { 20 | 'react-hooks': reactHooks, 21 | 'react-refresh': reactRefresh, 22 | }, 23 | rules: { 24 | ...js.configs.recommended.rules, 25 | ...reactHooks.configs.recommended.rules, 26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 27 | 'react-refresh/only-export-components': [ 28 | 'warn', 29 | { allowConstantExport: true }, 30 | ], 31 | }, 32 | }, 33 | ] 34 | -------------------------------------------------------------------------------- /wb-frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wb-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@mediapipe/tasks-vision": "^0.10.0", 14 | "@tailwindcss/postcss": "^4.0.17", 15 | "@tailwindcss/vite": "^4.0.17", 16 | "lucide-react": "^0.485.0", 17 | "react": "^19.0.0", 18 | "react-dom": "^19.0.0", 19 | "react-router-dom": "^7.4.1", 20 | "tailwindcss": "^4.0.17", 21 | "ws": "^8.18.1", 22 | "y-websocket": "^2.1.0", 23 | "yjs": "^13.6.24", 24 | "zustand": "^5.0.3" 25 | }, 26 | "devDependencies": { 27 | "@eslint/js": "^9.21.0", 28 | "@types/react": "^19.0.10", 29 | "@types/react-dom": "^19.0.4", 30 | "@vitejs/plugin-react": "^4.3.4", 31 | "eslint": "^9.21.0", 32 | "eslint-plugin-react-hooks": "^5.1.0", 33 | "eslint-plugin-react-refresh": "^0.4.19", 34 | "globals": "^15.15.0", 35 | "vite": "^6.2.5" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /wb-frontend/src/utils/smoothing.js: -------------------------------------------------------------------------------- 1 | export const drawingSmoothing = (lastPoint, newPoint, params) => { 2 | const distance = Math.sqrt( 3 | Math.pow(lastPoint.x - newPoint.x, 2) + Math.pow(lastPoint.y - newPoint.y, 2) 4 | ); 5 | 6 | if (distance < params.drawingDeadzone) { 7 | return lastPoint; 8 | } 9 | 10 | const maxDistance = 100; 11 | const speedFactor = Math.min(distance / maxDistance, 1); 12 | 13 | return { 14 | x: lastPoint.x + (newPoint.x - lastPoint.x) * speedFactor, 15 | y: lastPoint.y + (newPoint.y - lastPoint.y) * speedFactor, 16 | }; 17 | }; 18 | 19 | export const cursorSmoothing = (history, newPoint, params) => { 20 | if (params.cursorHistorySize < 1) { 21 | return newPoint; 22 | } 23 | if (history.length === 0) { 24 | history.push(newPoint); 25 | return newPoint; 26 | } 27 | 28 | const lastPoint = history[history.length - 1]; 29 | const newAvg = [...history, newPoint]; 30 | const smoothedPoint = newAvg.reduce( 31 | (acc, pos) => ({ 32 | x: acc.x + pos.x, 33 | y: acc.y + pos.y, 34 | }), 35 | { x: 0, y: 0 } 36 | ); 37 | 38 | smoothedPoint.x /= newAvg.length; 39 | smoothedPoint.y /= newAvg.length; 40 | 41 | if (Math.abs(smoothedPoint.x - lastPoint.x) < params.cursorDeadzone) { 42 | smoothedPoint.x = lastPoint.x; 43 | } 44 | if (Math.abs(smoothedPoint.y - lastPoint.y) < params.cursorDeadzone) { 45 | smoothedPoint.y = lastPoint.y; 46 | } 47 | 48 | while (history.length >= params.cursorHistorySize) { 49 | history.shift(); 50 | } 51 | 52 | history.push(smoothedPoint); 53 | 54 | return smoothedPoint; 55 | }; -------------------------------------------------------------------------------- /wb-frontend/src/assets/gandalf-noshadow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /instructions.md: -------------------------------------------------------------------------------- 1 | Great breakdown! Here’s how we can structure the development process: 2 | 3 | --- 4 | 5 | ## **🔹 Phase 1: Core Whiteboard with Hand Tracking** 6 | ### ✅ **Tech Stack** 7 | - **Frontend**: React + Vite (fast dev loop) 8 | - **Hand Tracking**: TensorFlow.js + MediaPipe Hands 9 | - **Real-time Collaboration**: Yjs + `y-websocket` server 10 | 11 | ### **📌 Steps** 12 | 1. **Set up a basic React whiteboard** 13 | - Canvas-based drawing with mouse/touch (before adding gestures). 14 | - Store strokes in a Yjs shared document (`yArray`). 15 | 16 | 2. **Integrate TensorFlow.js for hand tracking** 17 | - Detect hand landmarks (`index finger tip`, `wrist`, etc.). 18 | - Map hand position to cursor movement. 19 | - Detect simple gestures (pinch = draw, open palm = stop, etc.). 20 | 21 | 3. **Enable real-time drawing with Yjs** 22 | - Sync strokes across clients in the same `room`. 23 | - Use the Awareness API to show live cursors. 24 | 25 | --- 26 | 27 | ## **🔹 Phase 2: Enhancing Collaboration** 28 | ### ✅ **Tech Stack** 29 | - **Frontend**: Keep React 30 | - **Real-time**: Yjs with WebSockets (backend via Node.js) 31 | - **Persistent Storage**: SQLite or Postgres for saved whiteboards 32 | 33 | ### **📌 Features** 34 | 1. **Persistent Whiteboards (Cloud Storage)** 35 | - Users can save & load whiteboards via an API. 36 | - Store drawings as serialized Yjs documents in a database. 37 | 38 | 2. **Room-based Access (Google Docs-Style)** 39 | - Users create/join rooms with short codes. 40 | - Future: Auth system for private rooms. 41 | 42 | 3. **Hand Gesture Enhancements** 43 | - Multi-finger gestures (pinch to zoom, swipe to erase). 44 | - More fine-tuned hand control. 45 | 46 | --- 47 | 48 | ## **🔹 Phase 3: Scaling & Extra Features** 49 | ### ✅ **Tech Stack Enhancements** 50 | - **Database**: Move to Postgres if needed. 51 | - **Deployment**: Host WebSocket server on Fly.io/Oracle Cloud. 52 | 53 | ### **📌 Advanced Features** 54 | 1. **Export Whiteboards as Images/PDFs** 55 | 2. **Undo/Redo (Time Travel with Yjs Snapshots)** 56 | 3. **Voice Chat for Collaboration** 57 | 4. **Mobile Support (gesture-friendly UI)** 58 | 59 | --- 60 | 61 | ### **Next Steps?** 62 | We can start with **hand tracking + simple real-time sync** and iterate from there. Which part do you want to tackle first? 🚀 -------------------------------------------------------------------------------- /wb-frontend/src/components/uiElements.jsx: -------------------------------------------------------------------------------- 1 | import useUIStore from "../stores/uiStore" 2 | import { Sun, Moon } from "lucide-react" 3 | import { useState, useRef } from 'react' 4 | 5 | export const DarkModeToggle = () => { 6 | const darkMode = useUIStore((state) => state.darkMode); 7 | const toggleDarkMode = useUIStore((state) => state.toggleDarkMode); 8 | return ( 9 | 15 | ); 16 | }; 17 | 18 | export const Tooltip = ({ children, direction = "top", content, cn='' }) => { 19 | const [visible, setVisible] = useState(false); 20 | const timeoutRef = useRef(null); 21 | 22 | const getTooltipPosition = () => { 23 | switch (direction) { 24 | case "top": 25 | return "bottom-full mb-3 left-1/2 -translate-x-1/2"; 26 | case "bottom": 27 | return "top-full mt-2 left-1/2 -translate-x-1/2"; 28 | case "left": 29 | return "right-full mr-5 top-1/2 -translate-y-1/2"; 30 | case "right": 31 | return "left-full ml-2 top-1/2 -translate-y-1/2"; 32 | default: 33 | return "bottom-full mb-2 left-1/2 -translate-x-1/2"; 34 | } 35 | }; 36 | 37 | const handleMouseEnter = () => { 38 | timeoutRef.current = setTimeout(() => { 39 | setVisible(true); 40 | }, 750); 41 | }; 42 | 43 | const handleMouseLeave = () => { 44 | // Clear the timeout and hide the tooltip 45 | clearTimeout(timeoutRef.current); 46 | setVisible(false); 47 | }; 48 | 49 | const handleClick = () => { 50 | setVisible(false); 51 | }; 52 | 53 | return ( 54 |
60 | {children} 61 | {visible && ( 62 |
65 | {content} 66 |
67 | )} 68 |
69 | ); 70 | }; -------------------------------------------------------------------------------- /wb-frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | GANDALF 40 | 41 | 42 | 43 | 50 | 51 |
52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | 139 | .DS_Store 140 | 141 | generated_images/ -------------------------------------------------------------------------------- /wb-frontend/src/routes/Room.jsx: -------------------------------------------------------------------------------- 1 | import { useParams, useNavigate } from 'react-router-dom'; 2 | import { useEffect } from 'react'; 3 | import Canvas from '../components/Canvas'; 4 | import TopMenu from '../components/TopMenu'; 5 | import useWhiteboardStore from '../stores/whiteboardStore'; 6 | import { Settings } from 'lucide-react'; 7 | import { Tooltip } from '../components/uiElements'; 8 | import useUIStore from '../stores/uiStore'; 9 | 10 | const API = (import.meta.env.MODE === 'development') ? 'http://localhost:1234' : 'https://ws.ronkiehn.dev'; 11 | 12 | export default function Room() { 13 | const { roomId } = useParams(); 14 | const navigate = useNavigate(); 15 | const activeUsers = useWhiteboardStore((state) => state.activeUsers); 16 | const settingsOpen = useUIStore((state) => state.settingsOpen); 17 | const toggleSettings = useUIStore((state) => state.toggleSettings); 18 | 19 | useEffect(() => { 20 | // Validate room on mount 21 | async function validateRoom() { 22 | try { 23 | const response = await fetch(`${API}/check-room?roomCode=${roomId}`); 24 | const { exists } = await response.json(); 25 | if (!exists) { 26 | navigate('/', { replace: true }); 27 | } 28 | } catch (error) { 29 | console.error('Error validating room:', error); 30 | navigate('/', { replace: true }); 31 | } 32 | } 33 | validateRoom(); 34 | 35 | // Cleanup when leaving room 36 | return () => { 37 | useWhiteboardStore.getState().cleanupYjs(); 38 | }; 39 | }, [roomId]); 40 | 41 | return ( 42 |
43 |
44 | 45 |

46 | Gandalf.design/{roomId} 47 | 48 |

49 |
50 |
51 | {activeUsers.map((user) => ( 52 | 53 |

55 | {user.userName?.[0] || '?'} 56 |

57 |
58 | ))} 59 |
60 | 61 | 65 | 66 | {settingsOpen && } 67 |
68 |
69 |
70 |
71 | 72 |
73 |
74 |
75 | ); 76 | } -------------------------------------------------------------------------------- /wb-frontend/src/assets/gandalf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /wb-frontend/src/routes/Home.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { DarkModeToggle } from '../components/uiElements'; 4 | import gandalf from '../assets/gandalf-noshadow.svg'; 5 | import github from '../assets/github.svg'; 6 | 7 | const API = (import.meta.env.MODE === 'development') ? 'http://localhost:1234' : 'https://ws.ronkiehn.dev'; 8 | 9 | export default function Home() { 10 | const [createMode, setCreateMode] = useState(true); 11 | const [error, setError] = useState(null); 12 | const navigate = useNavigate(); 13 | 14 | const checkAndJoinRoom = async (code) => { 15 | try { 16 | const response = await fetch(`${API}/check-room?roomCode=${code}`, { 17 | method: 'GET', 18 | }); 19 | if (response.ok) { 20 | const { exists } = await response.json(); 21 | if (exists) { 22 | navigate(`/${code}`); 23 | } else { 24 | setError('Invalid room code. Please try again.'); 25 | } 26 | } else { 27 | setError('Error checking room code'); 28 | } 29 | } catch (error) { 30 | console.error('Error finding room:', error); 31 | setError('Error finding room: ' + error.message); 32 | } 33 | }; 34 | 35 | const createRoom = async () => { 36 | try { 37 | const response = await fetch(`${API}/create-room`, { 38 | method: 'GET', 39 | }); 40 | const { roomCode } = await response.json(); 41 | navigate(`/${roomCode}`); 42 | } catch (error) { 43 | console.error('Error creating room:', error); 44 | setError('Error creating room: ' + error.message); 45 | } 46 | }; 47 | 48 | return ( 49 |
50 |
51 |
52 | 53 |
54 | 55 |

Gandalf

56 |

57 | a magic whiteboard Gandalf Logo 58 |

59 | 60 |
63 | 64 |
setCreateMode(!createMode)}> 66 |
69 | 70 |
71 |
74 | Create 75 |
76 |
79 | Join 80 |
81 |
82 |
83 | 84 | 85 | {createMode ? ( 86 | 91 | ) : ( 92 | e.key === 'Enter' && checkAndJoinRoom(e.target.value)} 97 | /> 98 | )} 99 | 100 | {error && ( 101 |
102 | {error} 103 |
104 | )} 105 |
106 | 107 |
108 | 115 |
116 | ); 117 | } -------------------------------------------------------------------------------- /wb-frontend/src/components/StrokeGen.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, use } from 'react' 2 | import useWhiteboardStore from '../stores/whiteboardStore'; 3 | import useUIStore from '../stores/uiStore'; 4 | import { Tooltip } from './uiElements'; 5 | 6 | const StrokeGen = () => { 7 | const { bgCanvas, getStrokesForExport, importGeneratedStrokes } = useWhiteboardStore(); 8 | const { settingsOpen, setSettings } = useUIStore(); 9 | const [error, setError] = useState(null); 10 | const [showPrompt, setShowPrompt] = useState(false); 11 | const [prompt, setPrompt] = useState(''); 12 | const [isGenerating, setIsGenerating] = useState(false); 13 | const [cooldownTime, setCooldownTime] = useState(0); 14 | const [isMobile, setIsMobile] = useState(window.innerWidth <= 640); 15 | const API = (import.meta.env.MODE === 'development') ? 'http://localhost:1234' : 'https://ws.ronkiehn.dev'; 16 | 17 | useEffect(() => { 18 | const handleResize = () => setIsMobile(window.innerWidth <= 640); 19 | window.addEventListener('resize', handleResize); 20 | return () => window.removeEventListener('resize', handleResize); 21 | }, []); 22 | 23 | useEffect(() => { 24 | if (isMobile && settingsOpen) { 25 | setShowPrompt(false); 26 | } 27 | }, [settingsOpen, isMobile]); 28 | 29 | useEffect(() => { 30 | if (cooldownTime > 0) { 31 | const timer = setInterval(() => { 32 | setCooldownTime(time => Math.max(0, time - 1)); 33 | }, 1000); 34 | return () => clearInterval(timer); 35 | } 36 | }, [cooldownTime]); 37 | 38 | const handlePromptToggle = () => { 39 | if (isMobile && showPrompt === false) { 40 | setSettings(false); 41 | } 42 | setShowPrompt(!showPrompt); 43 | }; 44 | 45 | const generateStrokes = async () => { 46 | if (!bgCanvas || !prompt || cooldownTime > 0) return; 47 | setIsGenerating(true); 48 | setError(null); 49 | 50 | try { 51 | const canvasData = getStrokesForExport(); 52 | const requestData = { 53 | strokes: canvasData.strokes, 54 | userPrompt: prompt, 55 | canvasWidth: canvasData.canvasWidth, 56 | canvasHeight: canvasData.canvasHeight 57 | }; 58 | 59 | const response = await fetch(`${API}/generate-strokes`, { 60 | method: 'POST', 61 | headers: { 'Content-Type': 'application/json' }, 62 | body: JSON.stringify(requestData) 63 | }); 64 | 65 | if (!response.ok) { 66 | const errorData = await response.json(); 67 | if (response.status === 429) { 68 | throw new Error(errorData.error || 'Please wait between generations'); 69 | } 70 | throw new Error(errorData.error || 'Server error'); 71 | } 72 | 73 | const result = await response.json(); 74 | const finalStrokes = JSON.parse(result.newStrokes); 75 | importGeneratedStrokes(finalStrokes); 76 | setPrompt(''); 77 | setCooldownTime(10); // Set 10 second cooldown 78 | 79 | } catch (error) { 80 | if (error.message.includes('Unexpected token')) { 81 | setError('Invalid response from gemini. Maybe make your prompt more specific?'); 82 | 83 | } else { 84 | setError(`Error generating: ${error.message}`); 85 | } 86 | console.error('Error generating strokes:', error); 87 | } finally { 88 | setIsGenerating(false); 89 | } 90 | }; 91 | 92 | return ( 93 |
94 | 95 | 103 | 104 | 105 | {showPrompt && ( 106 |
107 | {error && ( 108 |
109 | {error} 110 |
111 | )} 112 | 113 |
116 | setPrompt(e.target.value)} 120 | autoFocus 121 | placeholder="Add some birds..." 122 | className="w-full p-2 border text-sm rounded dark:bg-neutral-900 dark:text-white focus:outline-none border-none" 123 | disabled={isGenerating} 124 | /> 125 | 133 | 134 | 135 |
136 |
137 | )} 138 | 139 |
140 | ); 141 | }; 142 | 143 | export default StrokeGen; -------------------------------------------------------------------------------- /wb-frontend/src/components/Gemini.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { X, Download } from 'lucide-react' 3 | import useWhiteboardStore from '../stores/whiteboardStore'; 4 | import { Tooltip } from './uiElements'; 5 | 6 | const Gemini = () => { 7 | const [generatedImages, setGeneratedImages] = useState([]); 8 | const [isGenerating, setIsGenerating] = useState(false); 9 | const { bgCanvas, getStrokesForExport } = useWhiteboardStore(); 10 | const [error, setError] = useState(null); 11 | const API = (import.meta.env.MODE === 'development') ? 'http://localhost:1234' : 'https://ws.ronkiehn.dev'; 12 | 13 | // Cleanup URLs only on unmount 14 | useEffect(() => { 15 | return () => { 16 | generatedImages.forEach(img => { 17 | if (img.objectUrl) URL.revokeObjectURL(img.objectUrl); 18 | }); 19 | }; 20 | }, []); // Remove dependency array 21 | 22 | const generateImage = async () => { 23 | if (!bgCanvas) return; 24 | setIsGenerating(true); 25 | 26 | try { 27 | const canvasData = getStrokesForExport(); 28 | const requestData = { 29 | strokes: canvasData.strokes, 30 | canvasWidth: canvasData.canvasWidth, 31 | canvasHeight: canvasData.canvasHeight 32 | }; 33 | 34 | const response = await fetch(`${API}/generate`, { 35 | method: 'POST', 36 | headers: { 'Content-Type': 'application/json' }, 37 | body: JSON.stringify(requestData) 38 | }); 39 | 40 | if (!response.ok) { 41 | const errorData = await response.json(); 42 | throw new Error(`Server error: ${errorData.message || response.statusText}`); 43 | } 44 | 45 | const result = await response.json(); 46 | 47 | if (result.images?.length) { 48 | const newImages = await Promise.all(result.images.map(async img => { 49 | const blob = await fetch(`data:${img.mimeType};base64,${img.data}`).then(res => res.blob()); 50 | const objectUrl = URL.createObjectURL(blob); 51 | return { 52 | id: crypto.randomUUID(), // Add unique id 53 | objectUrl, 54 | alt: 'AI Generated artwork', 55 | timestamp: Date.now() 56 | }; 57 | })); 58 | setGeneratedImages(prev => [...prev, ...newImages]); 59 | } 60 | } catch (error) { 61 | setError(`Error generating image: ${error.message}`); 62 | console.error('Error generating image:', error); 63 | } finally { 64 | setIsGenerating(false); 65 | } 66 | }; 67 | 68 | const deleteGeneratedImage = (id) => { 69 | setGeneratedImages(prev => { 70 | const imageToDelete = prev.find(img => img.id === id); 71 | if (imageToDelete?.objectUrl) { 72 | URL.revokeObjectURL(imageToDelete.objectUrl); 73 | } 74 | return prev.filter(img => img.id !== id); 75 | }); 76 | }; 77 | 78 | return ( 79 |
80 | 81 | 88 | 89 | {error && ( 90 | 91 | {error} 92 | 93 | )} 94 | {generatedImages.length > 0 && ( 95 |
96 |

Generated Images

97 |
98 | {generatedImages.map((img) => ( 99 |
100 | 107 | {img.alt} { 112 | try { 113 | window.open(img.objectUrl, '_blank'); 114 | } catch (error) { 115 | console.error('Failed to open image:', error); 116 | } 117 | }} 118 | /> 119 | 125 | 126 | 127 |
128 | ))} 129 |
130 |
131 | )} 132 |
133 | ); 134 | }; 135 | 136 | export default Gemini; -------------------------------------------------------------------------------- /wb-frontend/src/components/HandTracking.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { HandLandmarker, FilesetResolver } from '@mediapipe/tasks-vision'; 3 | import useWhiteboardStore from '../stores/whiteboardStore'; 4 | 5 | const HandTracking = ({ onHandUpdate }) => { 6 | const videoRef = useRef(null); 7 | const handLandmarkerRef = useRef(null); 8 | const streamRef = useRef(null); 9 | const [isLoading, setIsLoading] = useState(true); 10 | const requestRef = useRef(null); 11 | const lastVideoTimeRef = useRef(-1); 12 | const fistStartTimeRef = useRef(null); 13 | const FIST_CLEAR_DELAY = 1500; // 1 second 14 | const pinchDist = useWhiteboardStore((state) => state.pinchDist); 15 | const store = useWhiteboardStore(); 16 | 17 | useEffect(() => { 18 | async function initializeHandLandmarker() { 19 | try { 20 | setIsLoading(true); 21 | 22 | const vision = await FilesetResolver.forVisionTasks( 23 | "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/wasm" 24 | ); 25 | 26 | handLandmarkerRef.current = await HandLandmarker.createFromOptions(vision, { 27 | baseOptions: { 28 | modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`, 29 | delegate: "GPU" 30 | }, 31 | runningMode: "VIDEO", 32 | numHands: 1 33 | }); 34 | 35 | setIsLoading(false); 36 | console.log('Hand landmarker initialized successfully'); 37 | } catch (error) { 38 | console.error('Error initializing hand landmarker:', error); 39 | setIsLoading(false); 40 | } 41 | } 42 | 43 | initializeHandLandmarker(); 44 | 45 | return () => { 46 | if (requestRef.current) { 47 | cancelAnimationFrame(requestRef.current); 48 | } 49 | }; 50 | }, []); 51 | 52 | useEffect(() => { 53 | if (isLoading || !handLandmarkerRef.current) return; 54 | 55 | async function setupWebcam() { 56 | try { 57 | if (!videoRef.current) return; 58 | 59 | const stream = await navigator.mediaDevices.getUserMedia({ 60 | video: { 61 | width: 640, 62 | height: 480, 63 | facingMode: 'user' 64 | } 65 | }); 66 | 67 | streamRef.current = stream; 68 | videoRef.current.srcObject = stream; 69 | await videoRef.current.play(); 70 | requestRef.current = requestAnimationFrame(detectHands); 71 | } catch (error) { 72 | console.error('Error accessing webcam:', error); 73 | } 74 | } 75 | 76 | setupWebcam(); 77 | 78 | return () => { 79 | if (streamRef.current) { 80 | streamRef.current.getTracks().forEach(track => track.stop()); 81 | streamRef.current = null; 82 | } 83 | if (videoRef.current) { 84 | videoRef.current.srcObject = null; 85 | } 86 | if (requestRef.current) { 87 | cancelAnimationFrame(requestRef.current); 88 | } 89 | }; 90 | }, [isLoading]); 91 | 92 | const detectHands = async () => { 93 | if (!videoRef.current || !handLandmarkerRef.current) return; 94 | 95 | if (lastVideoTimeRef.current !== videoRef.current.currentTime) { 96 | lastVideoTimeRef.current = videoRef.current.currentTime; 97 | 98 | const startTimeMs = performance.now(); 99 | try { 100 | const results = handLandmarkerRef.current.detectForVideo(videoRef.current, startTimeMs); 101 | 102 | if (results.landmarks && results.landmarks.length > 0) { 103 | const landmarks = results.landmarks[0]; 104 | const handedness = results.handednesses[0][0]; 105 | const indexTip = landmarks[8]; 106 | const thumbTip = landmarks[4]; 107 | const pinkyTip = landmarks[20]; 108 | const ringTip = landmarks[16]; 109 | const middleTip = landmarks[12]; 110 | const wrist = landmarks[0]; 111 | 112 | if (indexTip && thumbTip && wrist && pinkyTip && ringTip && middleTip) { 113 | const pinch_distance = Math.sqrt( 114 | Math.pow(indexTip.x - thumbTip.x, 2) + 115 | Math.pow(indexTip.y - thumbTip.y, 2) + 116 | Math.pow(indexTip.z - thumbTip.z, 2) 117 | ); 118 | 119 | const fist_distance = Math.sqrt( 120 | Math.pow(indexTip.x - wrist.x, 2) + 121 | Math.pow(indexTip.y - wrist.y, 2) + 122 | Math.pow(indexTip.z - wrist.z, 2) 123 | ); 124 | 125 | const isFist = fist_distance < 0.2; 126 | 127 | if (isFist && !fistStartTimeRef.current) { 128 | fistStartTimeRef.current = Date.now(); 129 | } else if (!isFist && fistStartTimeRef.current) { 130 | store.setClearProgress(0); 131 | fistStartTimeRef.current = null; 132 | } 133 | 134 | if (fistStartTimeRef.current) { 135 | const progress = Math.min((Date.now() - fistStartTimeRef.current) / FIST_CLEAR_DELAY, 1); 136 | store.setClearProgress(progress); 137 | if (progress >= 1) { 138 | store.clearCanvas(); 139 | fistStartTimeRef.current = null; 140 | store.setClearProgress(0); 141 | } 142 | } 143 | 144 | onHandUpdate({ 145 | position: { 146 | x: indexTip.x * videoRef.current.videoWidth, 147 | y: indexTip.y * videoRef.current.videoHeight 148 | }, 149 | isPinching: pinch_distance < pinchDist, 150 | }); 151 | } 152 | } 153 | } catch (error) { 154 | console.error('Error in hand detection:', error); 155 | } 156 | } 157 | 158 | requestRef.current = requestAnimationFrame(detectHands); 159 | }; 160 | 161 | return ( 162 |
163 |
176 | ); 177 | }; 178 | 179 | export default HandTracking; -------------------------------------------------------------------------------- /wb-frontend/src/components/Toolbar.jsx: -------------------------------------------------------------------------------- 1 | import { Mouse, Hand, Eraser, X, Triangle } from 'lucide-react'; 2 | import { useRef, useEffect, useState } from 'react'; 3 | import useWhiteboardStore from '../stores/whiteboardStore'; 4 | import useUIStore from '../stores/uiStore'; 5 | import { Tooltip } from './uiElements'; 6 | import StrokeGen from './StrokeGen'; 7 | 8 | const Toolbar = () => { 9 | const CLEAR_DELAY = 1000; // 1 second 10 | const store = useWhiteboardStore(); 11 | const { useHandTracking, toggleHandTracking, darkMode } = useUIStore(); 12 | const colors = ['black', 'red', 'blue', 'green']; 13 | const clearTimeoutRef = useRef(null); 14 | const clearStartTimeRef = useRef(0); 15 | const [showTutorial, setShowTutorial] = useState(false); 16 | 17 | useEffect(() => { 18 | // Check if user has seen the tutorial before 19 | const hasSeenTutorial = localStorage.getItem('hasSeenHandTrackingTutorial'); 20 | if (!hasSeenTutorial) { 21 | setShowTutorial(true); 22 | } 23 | }, []); 24 | 25 | const handleClearMouseDown = () => { 26 | clearStartTimeRef.current = Date.now(); 27 | clearTimeoutRef.current = setInterval(() => { 28 | const progress = Math.min((Date.now() - clearStartTimeRef.current) / CLEAR_DELAY, 1); 29 | store.setClearProgress(progress); 30 | if (progress >= 1) { 31 | store.clearCanvas(); 32 | handleClearMouseUp(); 33 | } 34 | }, 10); 35 | }; 36 | 37 | const handleClearMouseUp = () => { 38 | if (clearTimeoutRef.current) { 39 | clearInterval(clearTimeoutRef.current); 40 | clearTimeoutRef.current = null; 41 | } 42 | store.setClearProgress(0); 43 | }; 44 | 45 | const handleTrackingToggle = () => { 46 | toggleHandTracking(); 47 | setShowTutorial(false); 48 | localStorage.setItem('hasSeenHandTrackingTutorial', 'true'); 49 | }; 50 | 51 | return ( 52 |
55 |
56 | 57 | 64 | 65 | {showTutorial && ( 66 |
67 |
68 | Click here to toggle hand tracking. Pinch to draw! 69 |
73 |
74 |
75 |
76 | )} 77 |
78 | 79 | 80 | 81 |
82 | {colors.map((color) => ( 83 |
99 |
100 | 101 | 102 | 111 | 112 | 113 | 114 | 122 | 123 | 124 | 125 |
126 | store.setPenSize(parseInt(e.target.value) * 2)} 132 | className={`w-full h-2 rounded-lg appearance-none cursor-pointer 133 | [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 134 | [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full 135 | [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:cursor-pointer 136 | [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 137 | [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border 138 | [&::-moz-range-thumb]:cursor-pointer 139 | ${darkMode 140 | ? '[&::-webkit-slider-thumb]:bg-neutral-900 [&::-webkit-slider-thumb]:border-white [&::-moz-range-thumb]:bg-neutral-300 bg-neutral-700' 141 | : '[&::-webkit-slider-thumb]:bg-neutral-100 [&::-webkit-slider-thumb]:border-black [&::-moz-range-thumb]:bg-black bg-neutral-200' 142 | }`} 143 | style={{ 144 | background: darkMode 145 | ? `linear-gradient(to right, #ffffff 0%, #ffffff ${(store.penSize / 32) * 100}%, #4B5563 ${(store.penSize / 32) * 100}%, #4B5563 100%)` 146 | : `linear-gradient(to right, #000000 0%, #000000 ${(store.penSize / 32) * 100}%, #ccc ${(store.penSize / 32) * 100}%, #ccc 100%)` 147 | }} 148 | /> 149 |
150 |
151 | 152 | 163 | 164 |
165 | ); 166 | }; 167 | 168 | export default Toolbar; 169 | -------------------------------------------------------------------------------- /wb-frontend/src/utils/shapeUtils.js: -------------------------------------------------------------------------------- 1 | // entirely vibe coded by my boy alex 2 | export const findTriangleVertices = (points) => { 3 | let vertices = []; 4 | let maxArea = 0; 5 | 6 | for (let i = 0; i < points.length; i++) { 7 | for (let j = i + 1; j < points.length; j++) { 8 | for (let k = j + 1; k < points.length; k++) { 9 | const area = getTriangleArea(points[i], points[j], points[k]); 10 | if (area > maxArea) { 11 | maxArea = area; 12 | vertices = [points[i], points[j], points[k]]; 13 | } 14 | } 15 | } 16 | } 17 | return vertices; 18 | }; 19 | 20 | const getTriangleArea = (p1, p2, p3) => { 21 | return Math.abs((p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y)) / 2; 22 | }; 23 | 24 | const getDistanceToLine = (point, lineStart, lineEnd) => { 25 | const numerator = Math.abs( 26 | (lineEnd.y - lineStart.y) * point.x - 27 | (lineEnd.x - lineStart.x) * point.y + 28 | lineEnd.x * lineStart.y - 29 | lineEnd.y * lineStart.x 30 | ); 31 | const denominator = Math.sqrt( 32 | Math.pow(lineEnd.y - lineStart.y, 2) + 33 | Math.pow(lineEnd.x - lineStart.x, 2) 34 | ); 35 | return numerator / denominator; 36 | }; 37 | 38 | export const detectShape = (points) => { 39 | if (points.length < 2) return null; 40 | 41 | const xs = points.map(p => p.x); 42 | const ys = points.map(p => p.y); 43 | const minX = Math.min(...xs); 44 | const maxX = Math.max(...xs); 45 | const minY = Math.min(...ys); 46 | const maxY = Math.max(...ys); 47 | const width = maxX - minX; 48 | const height = maxY - minY; 49 | const centerX = (minX + maxX) / 2; 50 | const centerY = (minY + maxY) / 2; 51 | 52 | // Calculate scores for each shape 53 | const scores = { 54 | line: calculateLineScore(points), 55 | rectangle: calculateRectangleScore(points, minX, maxX, minY, maxY), 56 | circle: calculateCircleScore(points, centerX, centerY, width, height), 57 | triangle: calculateTriangleScore(points) 58 | }; 59 | 60 | // Find the shape with highest score 61 | const bestShape = Object.entries(scores).reduce((a, b) => a[1] > b[1] ? a : b)[0]; 62 | 63 | switch (bestShape) { 64 | case 'line': { 65 | const [start, end] = findLineEndpoints(points); 66 | return { 67 | type: 'line', 68 | points: [start, end] 69 | }; 70 | } 71 | case 'rectangle': { 72 | const aspectRatio = width / height; 73 | if (Math.abs(aspectRatio - 1) < 0.25) { 74 | return { 75 | type: 'square', 76 | points: generateRectPoints(minX, minY, Math.max(width, height), Math.max(width, height)) 77 | }; 78 | } 79 | return { 80 | type: 'rectangle', 81 | points: generateRectPoints(minX, minY, width, height) 82 | }; 83 | } 84 | case 'circle': 85 | const avgRadius = Math.min(width, height) / 2; 86 | return { 87 | type: 'circle', 88 | points: generateCirclePoints({ x: centerX, y: centerY }, avgRadius) 89 | }; 90 | case 'triangle': { 91 | const vertices = findTriangleVertices(points); 92 | return { 93 | type: 'triangle', 94 | points: [...vertices, vertices[0]] 95 | }; 96 | } 97 | } 98 | }; 99 | 100 | const calculateRectangleScore = (points, minX, maxX, minY, maxY) => { 101 | const [start, end] = findLineEndpoints(points); 102 | const lineLength = Math.sqrt( 103 | Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2) 104 | ); 105 | 106 | // If points are very line-like, penalize rectangle score 107 | const width = maxX - minX; 108 | const height = maxY - minY; 109 | const aspectRatio = Math.max(width / height, height / width); 110 | if (aspectRatio > 4) return 0.1; // Heavy penalty for very elongated shapes 111 | 112 | const edgeDistances = points.map(point => { 113 | const distanceX = Math.min(Math.abs(point.x - minX), Math.abs(point.x - maxX)); 114 | const distanceY = Math.min(Math.abs(point.y - minY), Math.abs(point.y - maxY)); 115 | return Math.min(distanceX, distanceY); 116 | }); 117 | return 1 / (1 + Math.average(edgeDistances)); 118 | }; 119 | 120 | const findStraightSections = (points) => { 121 | const sections = []; 122 | let currentSection = [points[0]]; 123 | 124 | for (let i = 1; i < points.length - 1; i++) { 125 | const prev = points[i - 1]; 126 | const curr = points[i]; 127 | const next = points[i + 1]; 128 | 129 | // Check if current point is in line with prev and next 130 | const d = getDistanceToLine(curr, prev, next); 131 | if (d < 3) { // Threshold for "straightness" 132 | currentSection.push(curr); 133 | } else { 134 | if (currentSection.length > 3) { // Min points for a straight section 135 | sections.push([...currentSection]); 136 | } 137 | currentSection = [curr]; 138 | } 139 | } 140 | 141 | if (currentSection.length > 3) { 142 | sections.push(currentSection); 143 | } 144 | 145 | return sections; 146 | }; 147 | 148 | const calculateCircleScore = (points, centerX, centerY, width, height) => { 149 | const avgRadiusX = width / 2; 150 | const avgRadiusY = height / 2; 151 | 152 | // Find straight sections 153 | const straightSections = findStraightSections(points); 154 | const totalPoints = points.length; 155 | const pointsInStraightSections = straightSections.reduce((sum, section) => sum + section.length, 0); 156 | const straightRatio = pointsInStraightSections / totalPoints; 157 | 158 | // If more than 10% of points are in straight sections, heavily penalize circle score 159 | if (straightRatio > 0.10) { 160 | return 0.1; 161 | } 162 | 163 | const radiusDeviations = points.map(point => { 164 | const distanceFromCenterX = (point.x - centerX) / avgRadiusX; 165 | const distanceFromCenterY = (point.y - centerY) / avgRadiusY; 166 | const distanceFromEllipse = Math.sqrt( 167 | Math.pow(distanceFromCenterX, 2) + Math.pow(distanceFromCenterY, 2) 168 | ); 169 | return Math.abs(distanceFromEllipse - 1); 170 | }); 171 | 172 | // Lower score for very elongated ellipses 173 | const shapeRatio = Math.max(width / height, height / width); 174 | const ratioPenalty = shapeRatio > 2 ? 0.5 : 1; 175 | 176 | return (1 / (1 + Math.average(radiusDeviations))) * ratioPenalty; 177 | }; 178 | 179 | const calculateTriangleScore = (points) => { 180 | const [start, end] = findLineEndpoints(points); 181 | const lineLength = Math.sqrt( 182 | Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2) 183 | ); 184 | 185 | // If points are very line-like, penalize triangle score 186 | const vertices = findTriangleVertices(points); 187 | let area; 188 | try { 189 | area = getTriangleArea(...vertices); 190 | } catch (error) { 191 | console.error('Error calculating triangle area:', error); 192 | return 0; 193 | } 194 | 195 | if (area < lineLength * 2) return 0.1; // Penalize thin triangles 196 | 197 | const distances = points.map(point => { 198 | return Math.min(...vertices.map((v1, i) => { 199 | const v2 = vertices[(i + 1) % 3]; 200 | return getDistanceToLine(point, v1, v2); 201 | })); 202 | }); 203 | return 1 / (1 + Math.average(distances)); 204 | }; 205 | 206 | const calculateLineScore = (points) => { 207 | if (points.length < 2) return 0; 208 | 209 | const [start, end] = findLineEndpoints(points); 210 | const distances = points.map(point => getDistanceToLine(point, start, end)); 211 | 212 | // Calculate line length 213 | const lineLength = Math.sqrt( 214 | Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2) 215 | ); 216 | 217 | // Calculate straightness (how well points follow the line) 218 | const avgDistance = Math.average(distances); 219 | const straightness = 1 / (1 + avgDistance); 220 | 221 | // Bonus for longer lines with few deviations 222 | const lengthBonus = Math.min(lineLength / 100, 2); 223 | 224 | return straightness * lengthBonus * 2; // Multiply by 2 to give lines preference 225 | }; 226 | 227 | const findLineEndpoints = (points) => { 228 | let maxDistance = 0; 229 | let endpoints = [points[0], points[1]]; 230 | 231 | // Find the two points that are furthest apart 232 | for (let i = 0; i < points.length; i++) { 233 | for (let j = i + 1; j < points.length; j++) { 234 | const distance = Math.sqrt( 235 | Math.pow(points[i].x - points[j].x, 2) + 236 | Math.pow(points[i].y - points[j].y, 2) 237 | ); 238 | if (distance > maxDistance) { 239 | maxDistance = distance; 240 | endpoints = [points[i], points[j]]; 241 | } 242 | } 243 | } 244 | return endpoints; 245 | }; 246 | 247 | // Add Math.average helper 248 | Math.average = arr => arr.reduce((a, b) => a + b) / arr.length; 249 | 250 | const generateCirclePoints = (center, radius) => { 251 | const points = []; 252 | for (let i = 0; i <= 360; i += 10) { 253 | const angle = (i * Math.PI) / 180; 254 | points.push({ 255 | x: center.x + radius * Math.cos(angle), 256 | y: center.y + radius * Math.sin(angle) 257 | }); 258 | } 259 | return points; 260 | }; 261 | 262 | const generateRectPoints = (x, y, w, h) => [ 263 | { x, y }, 264 | { x: x + w, y }, 265 | { x: x + w, y: y + h }, 266 | { x, y: y + h }, 267 | { x, y } 268 | ]; 269 | -------------------------------------------------------------------------------- /wb-frontend/src/components/TopMenu.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import useWhiteboardStore from '../stores/whiteboardStore'; 3 | import { DarkModeToggle, Tooltip } from './uiElements'; 4 | import Gemini from './Gemini'; 5 | 6 | 7 | const defaultValues = { 8 | cursorHistorySize: { min: 0, max: 5, default: 1 }, 9 | cursorDeadzone: { min: 0, max: 999, default: 2 }, 10 | drawingDeadzone: { min: 0, max: 999, default: 5 }, 11 | pinchDist: { min: 0, max: 2, default: 0.07 }, 12 | fistToClear: true 13 | }; 14 | 15 | const TopMenu = () => { 16 | const { bgCanvas, userName, setUserName, smoothingParams, setSmoothingParams, pinchDist, setPinchDist, fistToClear, setFistToClear } = useWhiteboardStore(); 17 | const [localUserName, setLocalUserName] = useState(userName); 18 | 19 | //everything to do with smoothing 20 | const [localInputs, setLocalInputs] = useState({ 21 | cursorHistorySize: smoothingParams.cursorHistorySize, 22 | cursorDeadzone: smoothingParams.cursorDeadzone, 23 | drawingDeadzone: smoothingParams.drawingDeadzone, 24 | pinchDist: pinchDist, 25 | fistToClear: fistToClear 26 | }); 27 | 28 | const hasChanges = () => { 29 | return localInputs.cursorHistorySize !== smoothingParams.cursorHistorySize || 30 | localInputs.cursorDeadzone !== smoothingParams.cursorDeadzone || 31 | localInputs.drawingDeadzone !== smoothingParams.drawingDeadzone || 32 | localInputs.pinchDist !== pinchDist || 33 | localInputs.fistToClear !== fistToClear; 34 | }; 35 | 36 | const hasChangesFromDefault = () => { 37 | return smoothingParams.cursorHistorySize !== defaultValues.cursorHistorySize.default || 38 | smoothingParams.cursorDeadzone !== defaultValues.cursorDeadzone.default || 39 | smoothingParams.drawingDeadzone !== defaultValues.drawingDeadzone.default || 40 | pinchDist !== defaultValues.pinchDist.default || 41 | fistToClear !== defaultValues.fistToClear; 42 | }; 43 | 44 | const validateValue = (value, param) => { 45 | const parsed = parseFloat(value); 46 | if (isNaN(parsed)) return defaultValues[param].default; 47 | return Math.min(Math.max(parsed, defaultValues[param].min), defaultValues[param].max); 48 | }; 49 | 50 | const handleSave = () => { 51 | const validated = { 52 | cursorHistorySize: validateValue(localInputs.cursorHistorySize, 'cursorHistorySize'), 53 | cursorDeadzone: validateValue(localInputs.cursorDeadzone, 'cursorDeadzone'), 54 | drawingDeadzone: validateValue(localInputs.drawingDeadzone, 'drawingDeadzone') 55 | }; 56 | 57 | setSmoothingParams(validated); 58 | setPinchDist(validateValue(localInputs.pinchDist, 'pinchDist')); 59 | setFistToClear(localInputs.fistToClear); 60 | 61 | setLocalInputs({ 62 | ...validated, 63 | pinchDist: validateValue(localInputs.pinchDist, 'pinchDist'), 64 | fistToClear: localInputs.fistToClear 65 | }); 66 | }; 67 | 68 | const handleReset = () => { 69 | setLocalInputs({ 70 | cursorHistorySize: defaultValues.cursorHistorySize.default, 71 | cursorDeadzone: defaultValues.cursorDeadzone.default, 72 | drawingDeadzone: defaultValues.drawingDeadzone.default, 73 | pinchDist: defaultValues.pinchDist.default, 74 | fistToClear: defaultValues.fistToClear 75 | }); 76 | setSmoothingParams({ 77 | cursorHistorySize: defaultValues.cursorHistorySize.default, 78 | cursorDeadzone: defaultValues.cursorDeadzone.default, 79 | drawingDeadzone: defaultValues.drawingDeadzone.default 80 | }); 81 | setPinchDist(defaultValues.pinchDist.default); 82 | setFistToClear(defaultValues.fistToClear); 83 | } 84 | 85 | const exportAsPNG = () => { 86 | const link = document.createElement('a'); 87 | link.href = bgCanvas.toDataURL('image/png'); 88 | link.download = `gandalf-${Date.now()}.png`; 89 | link.click(); 90 | } 91 | 92 | return ( 93 |
98 |
99 | 100 | { 105 | const newName = e.target.value; 106 | setLocalUserName(newName); 107 | setUserName(newName); 108 | }} 109 | className="text-center p-2 mx-4 border rounded shadow-sm 110 | bg-white text-black border-neutral-800 111 | dark:bg-neutral-800 dark:text-white dark:border-neutral-300 112 | dark:shadow-neutral-600" 113 | placeholder="name" 114 | /> 115 | 116 | 117 |
118 | Switch Theme 119 | 120 |
121 |
122 | 128 | 129 |
130 |
131 |

Handtracking Options

132 |
133 | 134 | 144 | 145 | 146 | 156 | 157 | 158 | 168 | 169 | 170 | 181 | 182 | 183 | 193 | 194 |
195 |
196 | 207 | 218 |
219 |
220 |
221 |

Experimental Features

222 |
223 | 224 |
225 |
226 |
227 | ); 228 | }; 229 | 230 | export default TopMenu; -------------------------------------------------------------------------------- /wb-frontend/src/components/Canvas.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import HandTracking from './HandTracking'; 3 | import Toolbar from './Toolbar'; 4 | import useWhiteboardStore from '../stores/whiteboardStore'; 5 | import useUIStore from '../stores/uiStore'; 6 | import { cursorSmoothing } from '../utils/smoothing'; 7 | 8 | const Canvas = ({ roomCode }) => { 9 | const store = useWhiteboardStore(); 10 | const clearProgress = useWhiteboardStore((state) => state.clearProgress); 11 | 12 | // Initialize Y.js connection with store's username 13 | useEffect(() => { 14 | store.initializeYjs(roomCode, store.userName); 15 | return () => store.cleanupYjs(); 16 | }, [roomCode, store.userName]); 17 | 18 | const canvasRef = useRef(null); 19 | const bgCanvasRef = useRef(null); 20 | 21 | const linewidthRef = useRef(3); 22 | const [ctx, setCtx] = useState(null); 23 | const { useHandTracking, darkMode } = useUIStore(); 24 | const [isHandReady, setIsHandReady] = useState(false); 25 | const prevPinchState = useRef(false); 26 | const cursorHistoryRef = useRef([]); 27 | const wasClickingRef = useRef(false); 28 | 29 | useEffect(() => { 30 | const canvas = document.createElement('canvas'); 31 | canvas.width = document.documentElement.clientWidth; 32 | canvas.height = document.documentElement.clientHeight - 48; 33 | store.setBgCanvas(canvas); 34 | bgCanvasRef.current = canvas; 35 | }, []); 36 | 37 | const redrawAllStrokes = (store, bgCanvas) => { 38 | const { yStrokes, ydoc } = store.getYjsResources(); 39 | if (yStrokes && bgCanvas) { 40 | // Clear canvas first 41 | store.clearBgCanvas(); 42 | 43 | // Redraw all strokes 44 | yStrokes.toArray().forEach((strokeData) => { 45 | const stroke = Array.isArray(strokeData) ? strokeData[0] : strokeData; 46 | if (stroke && stroke.points) { 47 | if (stroke.clientID === ydoc.clientID) { 48 | // If it's our stroke, ensure it's in localStrokes 49 | store.localStrokes.set(stroke.id, stroke); 50 | } 51 | store.drawStrokeOnBg(stroke); 52 | } 53 | }); 54 | } 55 | }; 56 | 57 | useEffect(() => { 58 | const setupCanvas = (canvas, context) => { 59 | const width = document.documentElement.clientWidth; 60 | const height = document.documentElement.clientHeight - 48; 61 | const dpr = window.devicePixelRatio || 1; 62 | 63 | // Reset any previous transforms 64 | context.setTransform(1, 0, 0, 1, 0, 0); 65 | 66 | canvas.width = width * dpr; 67 | canvas.height = height * dpr; 68 | canvas.style.width = `${width}px`; 69 | canvas.style.height = `${height}px`; 70 | 71 | // Set up drawing settings 72 | context.lineJoin = 'round'; 73 | context.lineCap = 'round'; 74 | context.lineWidth = linewidthRef.current; 75 | context.strokeStyle = store.penColor; 76 | }; 77 | 78 | const canvas = canvasRef.current; 79 | const bgCanvas = bgCanvasRef.current; 80 | const context = canvas.getContext('2d'); 81 | const bgContext = bgCanvas.getContext('2d'); 82 | 83 | const resizeCanvas = () => { 84 | setupCanvas(canvas, context); 85 | setupCanvas(bgCanvas, bgContext); 86 | redrawAllStrokes(store, bgCanvas); 87 | }; 88 | 89 | resizeCanvas(); 90 | window.addEventListener('resize', resizeCanvas); 91 | setCtx(context); 92 | 93 | return () => { 94 | window.removeEventListener('resize', resizeCanvas); 95 | }; 96 | }, []); 97 | 98 | useEffect(() => { 99 | redrawAllStrokes(store, bgCanvasRef.current); 100 | }, [darkMode]); 101 | 102 | useEffect(() => { 103 | if (!useHandTracking) return; 104 | cursorPositionRef.current = store.cursorPosition; 105 | }, [store.cursorPosition]); 106 | 107 | useEffect(() => { 108 | if (!ctx || !bgCanvasRef.current) return; 109 | 110 | const renderCanvas = () => { 111 | store.renderCanvas(ctx); 112 | }; 113 | 114 | const animationFrame = requestAnimationFrame(function loop() { 115 | renderCanvas(); 116 | requestAnimationFrame(loop); 117 | }); 118 | 119 | store.setupCanvasSync(); 120 | 121 | return () => { 122 | cancelAnimationFrame(animationFrame); 123 | }; 124 | }, [ctx, useHandTracking]); 125 | 126 | // Add this new effect to sync showCursor with handTracking 127 | useEffect(() => { 128 | store.setShowCursor(useHandTracking); 129 | }, [useHandTracking]); 130 | 131 | // Add this ref to track latest state 132 | const currentLineRef = useRef(store.currentLine); 133 | const cursorPositionRef = useRef(store.cursorPosition); 134 | 135 | // Update ref when state changes 136 | useEffect(() => { 137 | currentLineRef.current = store.currentLine; 138 | }, [store.currentLine]); 139 | 140 | const startDrawing = (e) => { 141 | if (useHandTracking) return; 142 | const point = getPointerPosition(e); 143 | store.startLine(point); 144 | }; 145 | 146 | const draw = (e) => { 147 | if (!store.isDrawing || useHandTracking) return; 148 | const point = getPointerPosition(e); 149 | store.updateLine(point); 150 | }; 151 | 152 | const endDrawing = () => { 153 | if (!store.isDrawing || useHandTracking) return; 154 | if (store.currentLine && store.currentLine.points.length > 0) { 155 | store.completeLine(); 156 | } 157 | }; 158 | 159 | const getPointerPosition = (e) => { 160 | const canvas = canvasRef.current; 161 | const rect = canvas.getBoundingClientRect(); 162 | const x = (e.clientX || e.touches?.[0]?.clientX || 0) - rect.left; 163 | const y = (e.clientY || e.touches?.[0]?.clientY || 0) - rect.top; 164 | return { x, y }; 165 | }; 166 | 167 | const smoothCursorPosition = (newPosition) => { 168 | const { smoothingParams } = useWhiteboardStore.getState(); 169 | return cursorSmoothing(cursorHistoryRef.current, newPosition, smoothingParams); 170 | }; 171 | 172 | const handleHandUpdate = (handData) => { 173 | if (!handData || !canvasRef.current) return; 174 | setIsHandReady(true); 175 | 176 | const canvas = canvasRef.current; 177 | const dpr = window.devicePixelRatio || 1; 178 | const rect = canvas.getBoundingClientRect(); 179 | 180 | const scaleX = (canvas.width / dpr) / 640; 181 | const scaleY = (canvas.height / dpr) / 480; 182 | 183 | const rawX = (canvas.width / dpr) - handData.position.x * scaleX; 184 | const rawY = handData.position.y * scaleY; 185 | 186 | const x = rawX - rect.left; 187 | const y = rawY - rect.top; 188 | 189 | const smoothedPosition = smoothCursorPosition({ x, y }); 190 | 191 | store.updateCursorPosition(smoothedPosition); 192 | 193 | const isPinching = handData.isPinching; 194 | const isClicking = false; 195 | 196 | if (!isClicking && wasClickingRef.current) { 197 | store.cycleColor(); 198 | } 199 | wasClickingRef.current = isClicking; 200 | 201 | if (isPinching && !prevPinchState.current) { 202 | store.startLine(smoothedPosition); 203 | } else if (isPinching && prevPinchState.current) { 204 | store.updateLine({ ...smoothedPosition, fromHandTracking: true }); 205 | } else if (!isPinching && prevPinchState.current) { 206 | if (currentLineRef.current && currentLineRef.current.points.length > 0) { 207 | store.completeLine(); 208 | } 209 | } 210 | 211 | prevPinchState.current = isPinching; 212 | }; 213 | 214 | useEffect(() => { 215 | const handleMouseMove = (e) => { 216 | if (!useHandTracking) { 217 | const rect = canvasRef.current.getBoundingClientRect(); 218 | const x = e.clientX - rect.left; 219 | const y = e.clientY - rect.top; 220 | 221 | // Update awareness with mouse position 222 | store.updateAwareness({ 223 | cursor: { x, y }, 224 | isDrawing: store.isDrawing, 225 | user: { 226 | id: store.clientID, 227 | name: store.userName, 228 | color: store.penColor, 229 | }, 230 | }); 231 | } 232 | }; 233 | 234 | const handleMouseLeave = () => { 235 | if (!useHandTracking) { 236 | store.clearAwareness(); 237 | } 238 | }; 239 | 240 | if (canvasRef.current) { 241 | canvasRef.current.addEventListener('mousemove', handleMouseMove); 242 | canvasRef.current.addEventListener('mouseleave', handleMouseLeave); 243 | } 244 | 245 | return () => { 246 | if (canvasRef.current) { 247 | canvasRef.current.removeEventListener('mousemove', handleMouseMove); 248 | canvasRef.current.removeEventListener('mouseleave', handleMouseLeave); 249 | } 250 | }; 251 | }, [store.isDrawing, useHandTracking]); 252 | 253 | useEffect(() => { 254 | const updateLocalAwareness = () => { 255 | if (useHandTracking && isHandReady) { 256 | store.updateAwareness({ 257 | cursor: store.cursorPosition, 258 | isDrawing: store.isDrawing, 259 | user: { 260 | id: store.clientID, 261 | name: store.userName, 262 | color: store.penColor, 263 | }, 264 | }); 265 | } 266 | }; 267 | 268 | updateLocalAwareness(); 269 | }, [store.cursorPosition, isHandReady, useHandTracking, store.isDrawing]); 270 | 271 | const clearCanvas = () => { 272 | store.clearCanvas(); 273 | }; 274 | 275 | // Add keyboard shortcut handler 276 | useEffect(() => { 277 | const handleKeyDown = (e) => { 278 | if ((e.metaKey || e.ctrlKey) && e.key === 'z') { 279 | e.preventDefault(); 280 | if (e.shiftKey) { 281 | store.redo(); 282 | } else { 283 | store.undo(); 284 | } 285 | } 286 | }; 287 | 288 | window.addEventListener('keydown', handleKeyDown); 289 | return () => window.removeEventListener('keydown', handleKeyDown); 290 | }, []); 291 | 292 | return ( 293 |
294 | 295 | 296 | 307 | 308 | {useHandTracking ? : null} 309 | 310 | {useHandTracking && !isHandReady && ( 311 |
312 |

313 | Please allow camera access and wait for the hand tracking model to load. 314 |
315 | In this mode, pinch to draw and make a fist to clear the canvas. 316 |

317 |
318 | )} 319 | 320 | {clearProgress > 0 && ( 321 |
322 |
323 |
327 |
328 |
329 | Clearing... 330 |
331 |
332 | )} 333 |
334 | ); 335 | }; 336 | 337 | export default Canvas; -------------------------------------------------------------------------------- /wb-frontend/src/components/AdvancedFeatures.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect, useContext } from 'react'; 2 | import { Triangle, Moon, Sun } from 'lucide-react'; 3 | import useUIStore from '../stores/uiStore'; 4 | 5 | const AdvancedFeatures = ({ canvasRef, bgCanvasRef, ydoc, awareness }) => { 6 | const [chatMessages, setChatMessages] = useState([]); 7 | const [newMessage, setNewMessage] = useState(''); 8 | const [miniMapVisible, setMiniMapVisible] = useState(false); 9 | const [shapeRecognitionEnabled, setShapeRecognitionEnabled] = useState(false); 10 | const miniMapRef = useRef(null); 11 | const { darkMode } = useUIStore(); 12 | 13 | 14 | const findTriangleVertices = (points) => { 15 | let vertices = []; 16 | let maxArea = 0; 17 | 18 | for (let i = 0; i < points.length; i++) { 19 | for (let j = i + 1; j < points.length; j++) { 20 | for (let k = j + 1; k < points.length; k++) { 21 | const area = getTriangleArea(points[i], points[j], points[k]); 22 | if (area > maxArea) { 23 | maxArea = area; 24 | vertices = [points[i], points[j], points[k]]; 25 | } 26 | } 27 | } 28 | } 29 | 30 | return vertices; 31 | }; 32 | 33 | const getTriangleArea = (p1, p2, p3) => { 34 | return Math.abs((p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y)) / 2; 35 | }; 36 | 37 | const getDistanceToLine = (point, lineStart, lineEnd) => { 38 | const numerator = Math.abs( 39 | (lineEnd.y - lineStart.y) * point.x - 40 | (lineEnd.x - lineStart.x) * point.y + 41 | lineEnd.x * lineStart.y - 42 | lineEnd.y * lineStart.x 43 | ); 44 | const denominator = Math.sqrt( 45 | Math.pow(lineEnd.y - lineStart.y, 2) + 46 | Math.pow(lineEnd.x - lineStart.x, 2) 47 | ); 48 | return numerator / denominator; 49 | }; 50 | 51 | const detectShape = (points) => { 52 | if (points.length < 3) return null; 53 | 54 | const xs = points.map(p => p.x); 55 | const ys = points.map(p => p.y); 56 | const minX = Math.min(...xs); 57 | const maxX = Math.max(...xs); 58 | const minY = Math.min(...ys); 59 | const maxY = Math.max(...ys); 60 | const width = maxX - minX; 61 | const height = maxY - minY; 62 | const centerX = (minX + maxX) / 2; 63 | const centerY = (minY + maxY) / 2; 64 | 65 | // Circle detection 66 | const isCircular = points.every((point) => { 67 | const distanceFromCenter = Math.sqrt( 68 | Math.pow(point.x - centerX, 2) + Math.pow(point.y - centerY, 2) 69 | ); 70 | const avgRadius = Math.min(width, height) / 2; 71 | return Math.abs(distanceFromCenter - avgRadius) < avgRadius * 0.3; 72 | }); 73 | 74 | if (isCircular) { 75 | return { 76 | type: 'circle', 77 | center: { x: centerX, y: centerY }, 78 | radius: Math.min(width, height) / 2 79 | }; 80 | } 81 | 82 | // Rectangle detection 83 | const isRectangular = points.every(point => { 84 | const distanceX = Math.min(Math.abs(point.x - minX), Math.abs(point.x - maxX)); 85 | const distanceY = Math.min(Math.abs(point.y - minY), Math.abs(point.y - maxY)); 86 | return distanceX < 20 || distanceY < 20; 87 | }); 88 | 89 | if (isRectangular) { 90 | const aspectRatio = width / height; 91 | if (Math.abs(aspectRatio - 1) < 0.2) { 92 | return { type: 'square', x: minX, y: minY, size: Math.max(width, height) }; 93 | } 94 | return { type: 'rectangle', x: minX, y: minY, w: width, h: height }; 95 | } 96 | 97 | // Triangle detection 98 | if (points.length >= 3) { 99 | const vertices = findTriangleVertices(points); 100 | const isTriangle = points.every(point => { 101 | const distances = vertices.map((v1, i) => { 102 | const v2 = vertices[(i + 1) % 3]; 103 | return getDistanceToLine(point, v1, v2); 104 | }); 105 | return Math.min(...distances) < 20; 106 | }); 107 | 108 | if (isTriangle) { 109 | return { type: 'triangle', vertices }; 110 | } 111 | } 112 | 113 | return null; 114 | }; 115 | 116 | useEffect(() => { 117 | const handleStrokeEnd = (event) => { 118 | if (!shapeRecognitionEnabled) return; 119 | 120 | const stroke = event.detail; 121 | if (!stroke || !stroke.points || stroke.points.length < 3) return; 122 | 123 | const shape = detectShape(stroke.points); 124 | if (!shape) return; 125 | 126 | const ctx = bgCanvasRef.current.getContext('2d'); 127 | 128 | // Create points for perfect shapes 129 | let perfectPoints = []; 130 | switch (shape.type) { 131 | case 'circle': 132 | // Generate circle points 133 | for (let i = 0; i <= 360; i += 10) { 134 | const angle = (i * Math.PI) / 180; 135 | perfectPoints.push({ 136 | x: shape.center.x + shape.radius * Math.cos(angle), 137 | y: shape.center.y + shape.radius * Math.sin(angle) 138 | }); 139 | } 140 | break; 141 | case 'square': 142 | const size = shape.size || shape.width; 143 | perfectPoints = [ 144 | { x: shape.x, y: shape.y }, 145 | { x: shape.x + size, y: shape.y }, 146 | { x: shape.x + size, y: shape.y + (shape.height || size) }, 147 | { x: shape.x, y: shape.y + (shape.height || size) }, 148 | { x: shape.x, y: shape.y } 149 | ]; 150 | break; 151 | case 'triangle': 152 | perfectPoints = [ 153 | shape.vertices[0], 154 | shape.vertices[1], 155 | shape.vertices[2], 156 | shape.vertices[0] 157 | ]; 158 | break; 159 | case 'rectangle': 160 | perfectPoints = [ 161 | { x: shape.x, y: shape.y }, // Top-left 162 | { x: shape.x + shape.w, y: shape.y }, // Top-right 163 | { x: shape.x + shape.w, y: shape.y + shape.h }, // Bottom-right 164 | { x: shape.x, y: shape.y + shape.h }, // Bottom-left 165 | { x: shape.x, y: shape.y } // Back to start 166 | ]; 167 | break; 168 | } 169 | 170 | // Clear original stroke using stroke path 171 | ctx.save(); 172 | ctx.globalCompositeOperation = 'destination-out'; 173 | ctx.strokeStyle = '#000'; 174 | ctx.lineWidth = stroke.width + 4; 175 | ctx.lineCap = 'round'; 176 | ctx.lineJoin = 'round'; 177 | ctx.beginPath(); 178 | 179 | // Follow the original stroke path 180 | ctx.moveTo(stroke.points[0].x, stroke.points[0].y); 181 | for (let i = 1; i < stroke.points.length; i++) { 182 | ctx.lineTo(stroke.points[i].x, stroke.points[i].y); 183 | } 184 | ctx.stroke(); 185 | ctx.restore(); 186 | 187 | // Then draw the perfect shape (existing code remains the same) 188 | ctx.save(); 189 | ctx.beginPath(); 190 | ctx.strokeStyle = stroke.color; 191 | ctx.lineWidth = stroke.width; 192 | ctx.lineCap = 'round'; 193 | ctx.lineJoin = 'round'; 194 | 195 | // Draw using points 196 | ctx.moveTo(perfectPoints[0].x, perfectPoints[0].y); 197 | for (let i = 1; i < perfectPoints.length; i++) { 198 | ctx.lineTo(perfectPoints[i].x, perfectPoints[i].y); 199 | } 200 | 201 | ctx.stroke(); 202 | ctx.restore(); 203 | 204 | // Update Yjs with perfect stroke 205 | ydoc.transact(() => { 206 | const strokesArray = ydoc.getArray('strokes'); 207 | if (strokesArray.length > 0) { 208 | strokesArray.delete(strokesArray.length - 1, 1); 209 | } 210 | strokesArray.push([{ 211 | id: crypto.randomUUID(), 212 | points: perfectPoints, 213 | color: stroke.color, 214 | width: stroke.width, 215 | type: shape.type, 216 | ...shape 217 | }]); 218 | }); 219 | }; 220 | 221 | const canvas = canvasRef.current; 222 | if (canvas) { 223 | canvas.addEventListener('strokeEnd', handleStrokeEnd); 224 | return () => canvas.removeEventListener('strokeEnd', handleStrokeEnd); 225 | } 226 | }, [canvasRef, shapeRecognitionEnabled, ydoc]); 227 | 228 | // Mini-map rendering 229 | useEffect(() => { 230 | if (!miniMapVisible || !miniMapRef.current || !bgCanvasRef.current) return; 231 | const miniMapCtx = miniMapRef.current.getContext('2d'); 232 | const bgCtx = bgCanvasRef.current.getContext('2d'); 233 | miniMapCtx.clearRect(0, 0, miniMapRef.current.width, miniMapRef.current.height); 234 | miniMapCtx.drawImage(bgCanvasRef.current, 0, 0, miniMapRef.current.width, miniMapRef.current.height); 235 | }, [miniMapVisible, bgCanvasRef]); 236 | 237 | // Handle chat messages 238 | const sendMessage = () => { 239 | if (!newMessage.trim()) return; 240 | setChatMessages([...chatMessages, { user: 'You', text: newMessage }]); 241 | setNewMessage(''); 242 | }; 243 | 244 | // Export canvas as PNG 245 | 246 | 247 | // Import image as reference 248 | const importImage = (e) => { 249 | const file = e.target.files[0]; 250 | if (!file) return; 251 | const reader = new FileReader(); 252 | reader.onload = () => { 253 | const img = new Image(); 254 | img.onload = () => { 255 | const ctx = bgCanvasRef.current.getContext('2d'); 256 | ctx.drawImage(img, 0, 0, bgCanvasRef.current.width, bgCanvasRef.current.height); 257 | }; 258 | img.src = reader.result; 259 | }; 260 | reader.readAsDataURL(file); 261 | }; 262 | 263 | return ( 264 | <> 265 | 266 | 267 | 268 | 269 |
270 | 271 | 277 | 278 |
279 | 280 | {/* 286 | 287 | {miniMapVisible && ( 288 | 295 | )} */} 296 | 297 | {/*
298 |

Chat

299 |
300 | {chatMessages.map((msg, index) => ( 301 |
302 | {msg.user}: {msg.text} 303 |
304 | ))} 305 |
306 |
307 | setNewMessage(e.target.value)} 311 | placeholder="Type a message..." 312 | className="flex-grow border rounded px-2 py-1" 313 | /> 314 | 320 |
321 |
*/} 322 | 323 | 324 | 325 | 326 | {/* */} 335 | 336 | 337 | 338 | ); 339 | }; 340 | 341 | export default AdvancedFeatures; 342 | -------------------------------------------------------------------------------- /wb-frontend/src/stores/whiteboardStore.js: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import * as Y from 'yjs'; 3 | import { WebsocketProvider } from 'y-websocket'; 4 | import { drawingSmoothing } from '../utils/smoothing'; 5 | import useUIStore from './uiStore'; 6 | import { detectShape } from '../utils/shapeUtils'; 7 | 8 | // Y.js connection singleton 9 | let ydoc = null; 10 | let provider = null; 11 | let yStrokes = null; 12 | let yActiveStrokes = null; 13 | let awareness = null; 14 | 15 | const API = (import.meta.env.MODE === 'development') ? 'ws://localhost:1234' : 'wss://ws.ronkiehn.dev'; 16 | 17 | const useWhiteboardStore = create((set, get) => ({ 18 | // User state 19 | userName: (() => { 20 | const saved = localStorage.getItem('wb-username'); 21 | return saved || `User-${Math.floor(Math.random() * 1000)}`; 22 | })(), 23 | 24 | userColor: `#${Math.floor(Math.random() * 16777215).toString(16)}`, 25 | 26 | // Add debounced username setter 27 | setUserName: (() => { 28 | let timeoutId; 29 | return (name) => { 30 | clearTimeout(timeoutId); 31 | timeoutId = setTimeout(() => { 32 | set({ userName: name }); 33 | localStorage.setItem('wb-username', name); 34 | }, 500); 35 | }; 36 | })(), 37 | 38 | // Drawing state 39 | lines: [], 40 | currentLine: null, 41 | selectedTool: 'pen', 42 | 43 | 44 | // Drawing state (keep separate from user state) 45 | penColor: 'black', 46 | penSize: 4, 47 | previousPenSize: 4, 48 | cursorPosition: { x: 0, y: 0 }, 49 | showCursor: false, 50 | isDrawing: false, 51 | 52 | // Y.js state 53 | isConnected: false, 54 | clientID: null, 55 | awarenessStates: [], 56 | activeUsers: [], 57 | 58 | // Add undo/redo stacks 59 | undoStack: [], 60 | redoStack: [], 61 | 62 | // Add a map to track local vs remote strokes 63 | localStrokes: new Map(), 64 | 65 | // Add new state for remote active strokes 66 | remoteActiveStrokes: [], 67 | 68 | // Smoothing parameters 69 | smoothingParams: { 70 | cursorHistorySize: 1, 71 | cursorDeadzone: 2, 72 | drawingDeadzone: 5, 73 | }, 74 | 75 | // Update smoothing parameters 76 | setSmoothingParams: (newParams) => { 77 | set((state) => ({ 78 | smoothingParams: { ...state.smoothingParams, ...newParams }, 79 | })); 80 | }, 81 | 82 | // pinching params 83 | pinchDist: 0.07, 84 | setPinchDist: (dist) => set({ pinchDist: dist }), 85 | 86 | // more hand tracking options 87 | fistToClear: true, 88 | setFistToClear: (value) => set({ fistToClear: value }), 89 | 90 | // Add clear progress state 91 | clearProgress: 0, 92 | setClearProgress: (progress) => set({ clearProgress: progress }), 93 | 94 | // Shape recognition state 95 | shapeRecognition: false, 96 | setShapeRecognition: (enabled) => set({ shapeRecognition: enabled }), 97 | 98 | // Initialize Y.js connection 99 | initializeYjs: (roomCode, userName) => { 100 | // Return early if already initialized 101 | if (ydoc && provider && yStrokes) return; 102 | 103 | console.log(`Initializing Y.js with room: ${roomCode}, user: ${userName}`); 104 | 105 | // Create Y.js doc 106 | ydoc = new Y.Doc(); 107 | 108 | // Set up WebSocket connection with proper parameters 109 | const wsUrl = new URL(API); 110 | wsUrl.searchParams.set('username', userName); 111 | wsUrl.searchParams.set('room', roomCode); 112 | wsUrl.searchParams.set('type', 'awareness'); 113 | wsUrl.searchParams.set('color', get().userColor); // Add this line 114 | wsUrl.pathname = `/${roomCode}`; 115 | 116 | console.log('Connecting to WebSocket:', wsUrl.toString()); 117 | 118 | // Create provider 119 | provider = new WebsocketProvider(wsUrl.toString(), roomCode, ydoc); 120 | yStrokes = ydoc.getArray('strokes'); 121 | yActiveStrokes = ydoc.getMap('activeStrokes'); // Add this 122 | awareness = provider.awareness; 123 | 124 | // Handle ping messages from server 125 | provider.ws.addEventListener('message', (event) => { 126 | try { 127 | const message = JSON.parse(event.data); 128 | if (message.type === 'ping') { 129 | // Respond with pong to keep connection alive 130 | provider.ws.send(JSON.stringify({ type: 'pong' })); 131 | } 132 | } catch (e) { 133 | // Ignore non-JSON messages (y-websocket protocol messages) 134 | } 135 | }); 136 | 137 | // Set client ID 138 | set({ clientID: ydoc.clientID }); 139 | 140 | // Add the user to the active users list 141 | set((state) => ({ 142 | activeUsers: [ 143 | ...state.activeUsers, 144 | { 145 | clientID: ydoc.clientID, 146 | userName, 147 | color: get().userColor, 148 | }, 149 | ], 150 | })); 151 | 152 | // Handle connection status changes 153 | provider.on('status', ({ status }) => { 154 | console.log(`Room ${roomCode} - WebSocket status:`, status); 155 | set({ isConnected: status === 'connected' }); 156 | 157 | if (status === 'connected') { 158 | const state = get(); 159 | // Clear existing state 160 | state.clearBgCanvas(); 161 | state.localStrokes.clear(); 162 | 163 | // Load all strokes from Y.js 164 | const currentStrokes = yStrokes.toArray(); 165 | // Process each stroke 166 | currentStrokes.forEach(strokeData => { 167 | const stroke = Array.isArray(strokeData) ? strokeData[0] : strokeData; 168 | if (stroke && stroke.points) { 169 | if (stroke.clientID === ydoc.clientID) { 170 | // If it's our stroke, store it locally uncompressed 171 | state.localStrokes.set(stroke.id, stroke); 172 | } 173 | get().drawStrokeOnBg(stroke); 174 | } 175 | }); 176 | } 177 | }); 178 | 179 | // Handle WebSocket messages for user updates 180 | provider.ws.addEventListener('message', (event) => { 181 | try { 182 | const message = JSON.parse(event.data); 183 | if (message.type === 'active-users') { 184 | set({ activeUsers: message.users }); 185 | } 186 | } catch (e) { 187 | // Ignore non-JSON messages (y-websocket protocol messages) 188 | } 189 | }); 190 | 191 | // Remove awareness state update handler since we're using WebSocket messages 192 | awareness.on('change', () => { 193 | const states = Array.from(awareness.getStates()); 194 | set({ awarenessStates: states }); 195 | }); 196 | 197 | // Handle new strokes and deletions from other clients 198 | yStrokes.observe(event => { 199 | // Handle deleted strokes 200 | if (event.changes.deleted && event.changes.deleted.size > 0) { 201 | // Clear and redraw everything when strokes are deleted 202 | const state = get(); 203 | state.clearBgCanvas(); 204 | 205 | // Redraw all remaining strokes 206 | yStrokes.toArray().forEach(item => { 207 | const stroke = Array.isArray(item) ? item[0] : item; 208 | if (stroke && stroke.points) { 209 | if (stroke.clientID === ydoc.clientID) { 210 | state.localStrokes.set(stroke.id, stroke); 211 | } 212 | get().drawStrokeOnBg(stroke); 213 | } 214 | }); 215 | return; 216 | } 217 | 218 | // Handle added strokes 219 | event.changes.added.forEach(item => { 220 | let content; 221 | if (item.content && item.content.getContent) { 222 | content = item.content.getContent(); 223 | } else if (Array.isArray(item.content)) { 224 | content = item.content; 225 | } else { 226 | console.warn("Unexpected content format:", item.content); 227 | return; 228 | } 229 | 230 | content.forEach(strokeData => { 231 | const stroke = Array.isArray(strokeData) ? strokeData[0] : strokeData; 232 | // Only process remote strokes 233 | if (stroke && stroke.points && stroke.clientID !== ydoc.clientID) { 234 | get().drawStrokeOnBg(stroke); 235 | get().importLines([stroke]); 236 | } 237 | }); 238 | }); 239 | }); 240 | 241 | // Add observer for active strokes 242 | yActiveStrokes.observe(() => { 243 | const activeStrokes = Array.from(yActiveStrokes.entries()) 244 | .filter(([clientId]) => clientId !== ydoc.clientID.toString()) 245 | .map(([_, stroke]) => stroke); 246 | 247 | set({ remoteActiveStrokes: activeStrokes }); 248 | }); 249 | }, 250 | 251 | // Clean up Y.js resources 252 | cleanupYjs: () => { 253 | if (yActiveStrokes) { 254 | yActiveStrokes.delete(ydoc?.clientID.toString()); 255 | } 256 | if (provider) { 257 | provider.disconnect(); 258 | provider = null; 259 | } 260 | if (ydoc) { 261 | const clientID = ydoc.clientID; 262 | set((state) => ({ 263 | activeUsers: state.activeUsers.filter((user) => user.clientID !== clientID), 264 | })); 265 | ydoc.destroy(); 266 | ydoc = null; 267 | } 268 | yStrokes = null; 269 | yActiveStrokes = null; 270 | awareness = null; 271 | set({ isConnected: false, clientID: null, activeUsers: [] }); 272 | }, 273 | 274 | // Get Y.js resources 275 | getYjsResources: () => ({ 276 | ydoc, 277 | provider, 278 | yStrokes, 279 | yActiveStrokes, 280 | awareness 281 | }), 282 | 283 | updateAwareness: (state) => { 284 | if (awareness) { 285 | awareness.setLocalState({ 286 | cursor: state.cursor, 287 | isDrawing: state.isDrawing, 288 | user: { 289 | id: state.user.id, 290 | name: state.user.name, 291 | color: state.penColor 292 | } 293 | }); 294 | } 295 | }, 296 | 297 | // Clear awareness state 298 | clearAwareness: () => { 299 | if (awareness) { 300 | awareness.setLocalState(null); 301 | } 302 | }, 303 | 304 | // Add cursor history for smoothing 305 | cursorHistory: [], 306 | cursorHistorySize: 5, 307 | 308 | startLine: (point) => { 309 | const state = get(); 310 | const newLine = { 311 | id: Date.now().toString(), 312 | clientID: ydoc?.clientID, 313 | points: [point], 314 | toolType: state.selectedTool, 315 | color: state.penColor, 316 | width: state.penSize 317 | }; 318 | 319 | // Add to active strokes map 320 | if (yActiveStrokes) { 321 | yActiveStrokes.set(ydoc.clientID.toString(), newLine); 322 | } 323 | 324 | set({ currentLine: newLine, isDrawing: true }); 325 | }, 326 | 327 | updateLine: (point) => set(state => { 328 | if (!state.currentLine) return state; 329 | // Get the last point 330 | const lastPoint = state.currentLine.points[state.currentLine.points.length - 1]; 331 | 332 | // If this is a hand tracking point, apply smoothing 333 | if (point.fromHandTracking) { 334 | point = drawingSmoothing(lastPoint, point, state.smoothingParams); 335 | } 336 | 337 | const updatedLine = { 338 | ...state.currentLine, 339 | points: [...state.currentLine.points, point] 340 | }; 341 | 342 | // Update in active strokes map 343 | if (yActiveStrokes) { 344 | yActiveStrokes.set(ydoc.clientID.toString(), updatedLine); 345 | } 346 | 347 | return { currentLine: updatedLine }; 348 | }), 349 | 350 | completeLine: () => { 351 | const state = get(); 352 | if (!state.currentLine) return set({ isDrawing: false }); 353 | 354 | // Remove from active strokes 355 | if (yActiveStrokes) { 356 | yActiveStrokes.delete(ydoc.clientID.toString()); 357 | } 358 | 359 | let finalStroke = state.currentLine; 360 | 361 | // Shape recognition 362 | if (state.shapeRecognition) { 363 | const detectedShape = detectShape(state.currentLine.points); 364 | if (detectedShape) { 365 | finalStroke = { 366 | ...state.currentLine, 367 | points: detectedShape.points, 368 | shapeType: detectedShape.type 369 | }; 370 | } 371 | } 372 | 373 | // Keep track of this local stroke 374 | state.localStrokes.set(finalStroke.id, finalStroke); 375 | 376 | // Draw the stroke to background 377 | get().drawStrokeOnBg(finalStroke); 378 | 379 | // Add to Y.js if connected 380 | if (yStrokes) { 381 | try { 382 | const compressedStroke = { 383 | points: get().compressStroke(finalStroke.points), 384 | color: finalStroke.color, 385 | width: finalStroke.width, 386 | clientID: finalStroke.clientID, 387 | id: finalStroke.id, 388 | toolType: finalStroke.toolType, 389 | shapeType: finalStroke.shapeType 390 | }; 391 | yStrokes.push([compressedStroke]); 392 | } catch (err) { 393 | console.error('Failed to push stroke to Y.js:', err); 394 | } 395 | } 396 | 397 | set({ 398 | lines: [...state.lines, finalStroke], 399 | currentLine: null, 400 | isDrawing: false, 401 | undoStack: [...state.undoStack, finalStroke], 402 | redoStack: [] 403 | }); 404 | }, 405 | 406 | // Add undo/redo methods 407 | undo: () => { 408 | const state = get(); 409 | const currentClientID = ydoc?.clientID; 410 | 411 | // Find the most recent stroke by this client 412 | const strokeIndex = state.undoStack.findLastIndex( 413 | stroke => stroke.clientID === currentClientID 414 | ); 415 | 416 | if (strokeIndex === -1) return; 417 | 418 | // Remove the stroke from undoStack and add to redoStack 419 | const strokeToUndo = state.undoStack[strokeIndex]; 420 | const newUndoStack = [ 421 | ...state.undoStack.slice(0, strokeIndex), 422 | ...state.undoStack.slice(strokeIndex + 1) 423 | ]; 424 | 425 | // Remove from local strokes 426 | state.localStrokes.delete(strokeToUndo.id); 427 | 428 | // Use Y.js transaction to ensure atomic updates 429 | if (yStrokes) { 430 | ydoc.transact(() => { 431 | // Find and remove the stroke from Y.js array 432 | const yStrokeIndex = yStrokes.toArray().findIndex( 433 | stroke => (Array.isArray(stroke) ? stroke[0] : stroke).id === strokeToUndo.id 434 | ); 435 | if (yStrokeIndex !== -1) { 436 | yStrokes.delete(yStrokeIndex, 1); 437 | } 438 | }); 439 | } 440 | 441 | // Update local state 442 | set(state => ({ 443 | undoStack: newUndoStack, 444 | redoStack: [...state.redoStack, strokeToUndo] 445 | })); 446 | 447 | // Force a redraw from Y.js state to ensure consistency 448 | const currentStrokes = yStrokes.toArray(); 449 | get().clearBgCanvas(); 450 | currentStrokes.forEach(strokeData => { 451 | const stroke = Array.isArray(strokeData) ? stroke[0] : strokeData; 452 | if (stroke && stroke.points) { 453 | get().drawStrokeOnBg(stroke); 454 | } 455 | }); 456 | }, 457 | 458 | redo: () => { 459 | const state = get(); 460 | const currentClientID = ydoc?.clientID; 461 | 462 | // Find the most recent stroke by this client in the redo stack 463 | const strokeIndex = state.redoStack.findLastIndex( 464 | stroke => stroke.clientID === currentClientID 465 | ); 466 | 467 | if (strokeIndex === -1) return; 468 | 469 | // Remove the stroke from redoStack 470 | const strokeToRedo = state.redoStack[strokeIndex]; 471 | const newRedoStack = [ 472 | ...state.redoStack.slice(0, strokeIndex), 473 | ...state.redoStack.slice(strokeIndex + 1) 474 | ]; 475 | 476 | // Use Y.js transaction for atomic update 477 | if (yStrokes) { 478 | ydoc.transact(() => { 479 | yStrokes.push([strokeToRedo]); 480 | }); 481 | } 482 | 483 | // Update local state 484 | set(state => ({ 485 | redoStack: newRedoStack, 486 | undoStack: [...state.undoStack, strokeToRedo] 487 | })); 488 | 489 | // Force a redraw from Y.js state 490 | const currentStrokes = yStrokes.toArray(); 491 | get().clearBgCanvas(); 492 | currentStrokes.forEach(strokeData => { 493 | const stroke = Array.isArray(strokeData) ? stroke[0] : strokeData; 494 | if (stroke && stroke.points) { 495 | get().drawStrokeOnBg(stroke); 496 | } 497 | }); 498 | }, 499 | 500 | // Tool settings 501 | setTool: (tool) => set(state => { 502 | return { 503 | selectedTool: tool, 504 | previousPenSize: tool === 'eraser' ? state.penSize : state.previousPenSize, 505 | // Remove the color change for eraser 506 | penSize: tool === 'eraser' ? 20 : state.previousPenSize 507 | }; 508 | }), 509 | 510 | setPenSize: (size) => set({ penSize: size }), 511 | 512 | setColor: (color) => set(state => ({ 513 | penColor: color, 514 | })), 515 | 516 | // Cursor tracking 517 | updateCursorPosition: (position) => set({ cursorPosition: position }), 518 | 519 | setShowCursor: (show) => set({ showCursor: show }), 520 | 521 | setIsDrawing: (isDrawing) => set({ isDrawing }), 522 | 523 | // Canvas operations 524 | clearCanvas: () => { 525 | const state = get(); 526 | if (state.bgCanvas) { 527 | const bgCtx = state.bgCanvas.getContext('2d'); 528 | bgCtx.clearRect(0, 0, state.bgCanvas.width, state.bgCanvas.height); 529 | } 530 | 531 | // Clear Y.js array if connected 532 | if (yStrokes) { 533 | // Use transact to batch the deletion 534 | ydoc.transact(() => { 535 | yStrokes.delete(0, yStrokes.length); 536 | }); 537 | } 538 | 539 | state.localStrokes.clear(); 540 | 541 | return set({ 542 | lines: [], 543 | currentLine: null, 544 | isDrawing: false, 545 | undoStack: [], 546 | redoStack: [] 547 | }); 548 | }, 549 | 550 | compressStroke: (points) => { 551 | if (points.length <= 2) return points; 552 | 553 | const tolerance = 2; 554 | const result = [points[0]]; 555 | 556 | for (let i = 1; i < points.length - 1; i++) { 557 | const prev = result[result.length - 1]; 558 | const current = points[i]; 559 | const next = points[i + 1]; 560 | 561 | const dx1 = current.x - prev.x; 562 | const dy1 = current.y - prev.y; 563 | const dx2 = next.x - current.x; 564 | const dy2 = next.y - current.y; 565 | 566 | const angle1 = Math.atan2(dy1, dx1); 567 | const angle2 = Math.atan2(dy2, dx2); 568 | const angleDiff = Math.abs(angle1 - angle2); 569 | 570 | if (angleDiff > tolerance * 0.1 || 571 | Math.sqrt(dx1*dx1 + dy1*dy1) > tolerance * 5) { 572 | result.push(current); 573 | } 574 | } 575 | 576 | result.push(points[points.length - 1]); 577 | return result; 578 | }, 579 | 580 | // Import external lines (from YJS) 581 | importLines: (newLines) => set(state => ({ 582 | lines: [...state.lines, ...newLines] 583 | })), 584 | 585 | setLines: (lines) => set({ lines }), 586 | 587 | // Background canvas management 588 | bgCanvas: null, 589 | setBgCanvas: (canvas) => { 590 | const width = document.documentElement.clientWidth; 591 | const height = document.documentElement.clientHeight - 48; 592 | const dpr = window.devicePixelRatio || 1; 593 | const ctx = canvas.getContext('2d'); 594 | 595 | const darkMode = useUIStore.getState().darkMode; 596 | ctx.fillStyle = darkMode ? '#171717' : '#ffffff'; 597 | ctx.fillRect(0, 0, width, height); 598 | 599 | canvas.width = width * dpr; 600 | canvas.height = height * dpr; 601 | 602 | ctx.scale(dpr, dpr); 603 | 604 | canvas.style.width = `${width}px`; 605 | canvas.style.height = `${height}px`; 606 | 607 | set({ bgCanvas: canvas }); 608 | }, 609 | 610 | // Stroke rendering 611 | renderStroke: (stroke, targetCtx) => { 612 | if (!stroke || !stroke.points || stroke.points.length === 0) return; 613 | 614 | targetCtx.save(); 615 | const dpr = window.devicePixelRatio || 1; 616 | targetCtx.scale(dpr, dpr); 617 | 618 | 619 | // Set composite operation based on tool type 620 | if (stroke.toolType === 'eraser') { 621 | targetCtx.globalCompositeOperation = 'destination-out'; 622 | targetCtx.strokeStyle = 'rgba(0,0,0,1)'; // Color doesn't matter for eraser 623 | } else { 624 | targetCtx.globalCompositeOperation = 'source-over'; 625 | // Handle dark mode for regular strokes 626 | const isDarkMode = useUIStore.getState().darkMode; 627 | targetCtx.strokeStyle = isDarkMode && stroke.color === 'black' ? 'white' : stroke.color; 628 | } 629 | 630 | targetCtx.lineWidth = stroke.width; 631 | targetCtx.beginPath(); 632 | targetCtx.moveTo(stroke.points[0].x, stroke.points[0].y); 633 | 634 | for (let i = 1; i < stroke.points.length; i++) { 635 | targetCtx.lineTo(stroke.points[i].x, stroke.points[i].y); 636 | } 637 | 638 | targetCtx.stroke(); 639 | targetCtx.restore(); 640 | }, 641 | 642 | // Background canvas operations 643 | clearBgCanvas: () => { 644 | const state = get(); 645 | if (!state.bgCanvas) return; 646 | 647 | const bgCtx = state.bgCanvas.getContext('2d'); 648 | bgCtx.clearRect(0, 0, state.bgCanvas.width, state.bgCanvas.height); 649 | }, 650 | 651 | drawStrokeOnBg: (stroke) => { 652 | const state = get(); 653 | if (!state.bgCanvas) return; 654 | const bgCtx = state.bgCanvas.getContext('2d'); 655 | 656 | state.renderStroke(stroke, bgCtx); 657 | }, 658 | 659 | // Set up canvas sync (moved from Canvas component) 660 | setupCanvasSync: () => { 661 | if (!yStrokes) return; 662 | 663 | // Provider sync handler - only redraw remote strokes 664 | provider?.on('sync', () => { 665 | const state = get(); 666 | // Clear canvas 667 | get().clearBgCanvas(); 668 | 669 | // First draw all remote strokes 670 | yStrokes.toArray().forEach(item => { 671 | const strokeData = Array.isArray(item) ? item[0] : item; 672 | if (strokeData && strokeData.clientID !== ydoc.clientID) { 673 | get().drawStrokeOnBg(strokeData); 674 | } 675 | }); 676 | 677 | // Then draw local strokes (uncompressed) 678 | state.localStrokes.forEach(stroke => { 679 | get().drawStrokeOnBg(stroke); 680 | }); 681 | }); 682 | }, 683 | 684 | // Update render function in Canvas component to include active strokes 685 | renderCanvas: (ctx) => { 686 | const state = get(); 687 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 688 | 689 | // Draw background with completed strokes 690 | if (state.bgCanvas) { 691 | ctx.drawImage(state.bgCanvas, 0, 0); 692 | } 693 | 694 | // Draw current local stroke 695 | if (state.currentLine) { 696 | state.renderStroke(state.currentLine, ctx); 697 | } 698 | 699 | // Draw remote active strokes 700 | state.remoteActiveStrokes.forEach(stroke => { 701 | state.renderStroke(stroke, ctx); 702 | }); 703 | 704 | // Draw other users' cursors 705 | state.awarenessStates.forEach(([clientID, state]) => { 706 | if (state.cursor && clientID !== ydoc.clientID) { 707 | ctx.save(); 708 | const dpr = window.devicePixelRatio || 1; 709 | ctx.scale(dpr, dpr); 710 | ctx.fillStyle = state.isDrawing ? state.user.color : 'gray'; 711 | ctx.beginPath(); 712 | ctx.arc(state.cursor.x, state.cursor.y, 6, 0, 2 * Math.PI); 713 | ctx.fill(); 714 | 715 | ctx.font = '12px Arial'; 716 | ctx.fillStyle = 'white'; 717 | ctx.strokeStyle = 'black'; 718 | ctx.lineWidth = 3; 719 | ctx.strokeText(state.user.name, state.cursor.x + 10, state.cursor.y + 10); 720 | ctx.fillText(state.user.name, state.cursor.x + 10, state.cursor.y + 10); 721 | ctx.restore(); 722 | } 723 | }); 724 | 725 | // Draw hand tracking cursor if enabled 726 | if (state.showCursor && state.cursorPosition) { 727 | const isDarkMode = useUIStore.getState().darkMode; 728 | ctx.save(); 729 | const dpr = window.devicePixelRatio || 1; 730 | ctx.scale(dpr, dpr); 731 | if (isDarkMode && state.penColor === 'black') { 732 | ctx.fillStyle = state.isDrawing ? 'white' : 'gray'; 733 | } else if (isDarkMode && state.selectedTool === 'eraser') { 734 | ctx.fillStyle = state.isDrawing ? 'black' : 'gray'; 735 | } else { 736 | ctx.fillStyle = state.isDrawing ? state.penColor : 'gray'; 737 | } 738 | ctx.beginPath(); 739 | const newPenSize = Math.max(12, state.penSize); 740 | ctx.arc(state.cursorPosition.x, state.cursorPosition.y, newPenSize / 2, 0, 2 * Math.PI); 741 | if (state.selectedTool === 'eraser'){ 742 | ctx.strokeStyle = isDarkMode ? 'white' : 'black'; 743 | ctx.lineWidth = 2; 744 | ctx.stroke(); 745 | } 746 | 747 | ctx.fill(); 748 | ctx.restore(); 749 | } 750 | }, 751 | 752 | // Get strokes for export/generation 753 | getStrokesForExport: () => { 754 | if (!yStrokes) return []; 755 | 756 | const state = get(); 757 | const canvasWidth = state.bgCanvas?.width || 2500; 758 | const canvasHeight = state.bgCanvas?.height || 1600; 759 | 760 | return { 761 | strokes: yStrokes.toArray().map(stroke => { 762 | const strokeData = Array.isArray(stroke) ? stroke[0] : stroke; 763 | return { 764 | points: strokeData.points, 765 | color: strokeData.color, 766 | width: strokeData.width 767 | }; 768 | }), 769 | canvasWidth, 770 | canvasHeight 771 | }; 772 | }, 773 | 774 | importGeneratedStrokes: (strokes) => { 775 | if (!yStrokes) return; 776 | 777 | if (!strokes.length) return; 778 | 779 | const clientID = ydoc.clientID; 780 | const processedStrokes = strokes.map(stroke => ({ 781 | ...stroke, 782 | id: Date.now().toString() + Math.random(), 783 | clientID, 784 | toolType: 'pen' 785 | })); 786 | 787 | // Add to Y.js array 788 | ydoc.transact(() => { 789 | processedStrokes.forEach(stroke => { 790 | yStrokes.push([stroke]); 791 | }); 792 | }); 793 | 794 | // Draw strokes on background 795 | processedStrokes.forEach(stroke => { 796 | get().drawStrokeOnBg(stroke); 797 | }); 798 | 799 | // Update local state 800 | set(state => ({ 801 | lines: [...state.lines, ...processedStrokes], 802 | undoStack: [...state.undoStack, ...processedStrokes] 803 | })); 804 | } 805 | })); 806 | 807 | export default useWhiteboardStore; -------------------------------------------------------------------------------- /wb-backend/ws-server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const http = require('http'); 3 | const WebSocket = require('ws'); 4 | const { setupWSConnection } = require('y-websocket/bin/utils.js'); 5 | const crypto = require('crypto'); 6 | const cors = require('cors'); 7 | const Y = require('yjs'); 8 | const path = require('path'); 9 | const { createCanvas } = require('canvas'); 10 | const fs = require("node:fs"); 11 | const mime = require("mime-types"); 12 | 13 | const { GoogleGenerativeAI } = require("@google/generative-ai"); 14 | const { GoogleAIFileManager } = require("@google/generative-ai/server"); 15 | const apiKey = process.env.GOOGLE_API_KEY; 16 | const environment = process.env.ENVIRONMENT || 'production'; 17 | 18 | const genAI = new GoogleGenerativeAI(apiKey); 19 | const fileManager = new GoogleAIFileManager(apiKey); 20 | 21 | const port = process.env.PORT || 1234; 22 | const host = process.env.HOST || '0.0.0.0'; 23 | const OUTPUT_DIR = path.join(__dirname, 'generated-images'); 24 | 25 | class RateLimiter { 26 | constructor(windowMs = 60000, maxConnections = 200) { 27 | this.windowMs = windowMs; 28 | this.maxConnections = maxConnections; 29 | this.connections = new Map(); 30 | } 31 | 32 | isRateLimited(ip) { 33 | const now = Date.now(); 34 | const history = this.connections.get(ip) || []; 35 | 36 | // Keep only connections within the time window 37 | const recentConnections = history.filter(ts => now - ts < this.windowMs); 38 | 39 | // Add new connection timestamp 40 | recentConnections.push(now); 41 | this.connections.set(ip, recentConnections); 42 | 43 | return recentConnections.length > this.maxConnections; 44 | } 45 | 46 | cleanup() { 47 | const now = Date.now(); 48 | for (const [ip, timestamps] of this.connections.entries()) { 49 | const valid = timestamps.filter(ts => now - ts < this.windowMs); 50 | if (valid.length === 0) { 51 | this.connections.delete(ip); 52 | } else { 53 | this.connections.set(ip, valid); 54 | } 55 | } 56 | } 57 | } 58 | 59 | const prompt = ` 60 | You are a teacher who is trying to make a student's artwork look nicer to impress their parents. You have been given this drawing, and you must enhance, refine and complete this drawing while maintaining its core elements and shapes. Try your best to leave the student's original work there, but add to the scene to make an impressive drawing. You may also only use the following colors: red, green, blue, black, and white. 61 | 62 | in other words: 63 | - REPEAT the entire drawing. Keep the scale the same, lines, and position of the drawing the same. 64 | - ENHANCE by adding additional lines, colors, fill, etc. 65 | - COMPLETE by adding other features to the foreground and background 66 | 67 | Leave the background white, and use thick strokes. NO INTRICATE DETAILS OR PATTERNS. 68 | but DO NOT 69 | - modify the original drawing in any way 70 | 71 | The image should be the same aspect ratio, and have ALL of the same original lines. Otherwise, the parent might suspect that the teacher did some of the work.`; 72 | 73 | const sanitizeInput = (input) => { 74 | return input 75 | .replace(/[&<>"']/g, (char) => { 76 | const entities = { 77 | '&': '&', 78 | '<': '<', 79 | '>': '>', 80 | '"': '"', 81 | "'": ''' 82 | }; 83 | return entities[char]; 84 | }) 85 | .replace(/[<>]/g, '') // Remove < and > 86 | .trim() 87 | .slice(0, 16); // Limit length 88 | }; 89 | 90 | const sanitizePrompt = (input) => { 91 | if (!input) return ''; 92 | return input 93 | .replace(/[&<>"']/g, '') // Remove potentially harmful characters 94 | .trim(); 95 | }; 96 | 97 | 98 | 99 | 100 | // Function to clean entire directory (only used at startup and shutdown) 101 | const cleanDirectory = () => { 102 | if (fs.existsSync(OUTPUT_DIR)) { 103 | const files = fs.readdirSync(OUTPUT_DIR); 104 | files.forEach(file => { 105 | try { 106 | fs.unlinkSync(path.join(OUTPUT_DIR, file)); 107 | } catch (err) { 108 | console.error(`Error deleting file ${file}:`, err); 109 | } 110 | }); 111 | console.log('Cleaned generated-images directory'); 112 | } 113 | }; 114 | 115 | // Create output directory if it doesn't exist and clean it 116 | if (!fs.existsSync(OUTPUT_DIR)) { 117 | fs.mkdirSync(OUTPUT_DIR, { recursive: true }); 118 | } else { 119 | cleanDirectory(); 120 | } 121 | 122 | const clients = new Map(); 123 | const rooms = new Map(); 124 | const ROOM_CLEANUP_DELAY = 10 * 60 * 1000; // 10 minutes 125 | const roomTimeouts = new Map(); 126 | const WSrateLimiter = new RateLimiter(5000, 30); // 30 connections every 5 seconds 127 | const httpRateLimiter = new RateLimiter(5000, 10); // 10 requests every 5 seconds 128 | const generateStrokesLimiter = new RateLimiter(5000, 1); // 1 request per 5s 129 | 130 | setInterval(() => WSrateLimiter.cleanup(), 10000); 131 | setInterval(() => httpRateLimiter.cleanup(), 10000); 132 | setInterval(() => generateStrokesLimiter.cleanup(), 60000); 133 | 134 | const INACTIVE_TIMEOUT = 10 * 60 * 1000; // 10 minutes inactivity timeout 135 | const HEARTBEAT_INTERVAL = 30 * 1000; // Send heartbeat every 30 seconds 136 | 137 | // Clean up inactive users every minute 138 | setInterval(() => { 139 | const now = Date.now(); 140 | for (const [clientId, client] of clients.entries()) { 141 | if (now - client.lastActive > INACTIVE_TIMEOUT) { 142 | console.log(`Cleaning up inactive user ${client.userName} in room ${client.roomCode}`); 143 | client.ws.close(1000, 'Inactive timeout'); 144 | clients.delete(clientId); 145 | 146 | // Notify remaining users in the room about the user leaving 147 | const remainingUsers = Array.from(clients.values()) 148 | .filter(c => c.roomCode === client.roomCode) 149 | .map(c => ({ 150 | clientID: c.id, 151 | userName: c.userName, 152 | color: c.color, 153 | roomCode: c.roomCode 154 | })); 155 | 156 | if (remainingUsers.length === 0) { 157 | scheduleRoomCleanup(client.roomCode); 158 | } 159 | 160 | wss.clients.forEach((wsClient) => { 161 | const clientData = Array.from(clients.values()).find(c => c.ws === wsClient); 162 | if (wsClient.readyState === WebSocket.OPEN && clientData?.roomCode === client.roomCode) { 163 | wsClient.send(JSON.stringify({ 164 | type: 'active-users', 165 | users: remainingUsers 166 | })); 167 | } 168 | }); 169 | } 170 | } 171 | }, 60000); 172 | 173 | const server = http.createServer((req, res) => { 174 | const ip = req.socket.remoteAddress; 175 | if (httpRateLimiter.isRateLimited(ip)) { 176 | console.warn(`Too many requests from ${ip}`); 177 | res.writeHead(429, { 'Content-Type': 'application/json' }); 178 | res.end(JSON.stringify({ error: 'Too many requests' })); 179 | return; 180 | } 181 | const corsMiddleware = cors({ 182 | origin: environment === 'production' 183 | ? ['https://gandalf.design', 'https://www.gandalf.design'] 184 | : ['http://localhost:5173'], 185 | methods: ['GET', 'POST', 'OPTIONS'], 186 | allowedHeaders: ['Content-Type', 'Authorization'], 187 | credentials: true 188 | }); 189 | 190 | corsMiddleware(req, res, () => { 191 | if (req.url === '/health') { 192 | res.writeHead(200, { 'Content-Type': 'application/json' }); 193 | res.end(JSON.stringify({ 194 | status: 'healthy', 195 | timestamp: new Date().toISOString(), 196 | activeConnections: wss.clients.size, 197 | activeRooms: rooms.size 198 | })); 199 | } else if (req.url === '/create-room') { 200 | const roomCode = crypto.randomUUID().slice(0, 4); 201 | if (!rooms.has(roomCode)) { 202 | rooms.set(roomCode, new Y.Doc()); 203 | } 204 | res.writeHead(200, { 'Content-Type': 'application/json' }); 205 | res.end(JSON.stringify({ roomCode })); 206 | } else if (req.url.startsWith('/check-room')) { 207 | const url = new URL(req.url, `http://${req.headers.host}`); 208 | const roomCode = url.searchParams.get('roomCode'); 209 | const exists = rooms.has(roomCode); 210 | res.writeHead(200, { 'Content-Type': 'application/json' }); 211 | res.end(JSON.stringify({ exists })); 212 | } else if (req.url === '/generate') { 213 | let data = ''; 214 | req.on('data', chunk => { 215 | data += chunk; 216 | }); 217 | 218 | req.on('end', async () => { 219 | try { 220 | const { strokes, canvasWidth, canvasHeight } = JSON.parse(data); 221 | 222 | if (!strokes || !Array.isArray(strokes)) { 223 | res.writeHead(400); 224 | res.end(JSON.stringify({ error: 'Invalid strokes data' })); 225 | return; 226 | } 227 | 228 | // Render strokes to image buffer with provided dimensions 229 | const imageBuffer = renderStrokesToCanvas(strokes, canvasWidth, canvasHeight); 230 | 231 | if (!imageBuffer) { 232 | throw new Error('Failed to render canvas'); 233 | } 234 | 235 | // Save the sketch 236 | try { 237 | const savedResult = await saveImage(imageBuffer); 238 | const sketchPath = savedResult.images[0].path; 239 | 240 | 241 | // Using the highlighted code - upload to Gemini and generate image 242 | const files = [ 243 | await uploadToGemini(sketchPath, "image/png"), 244 | ]; 245 | 246 | const chatSession = model.startChat({ 247 | generationConfig, 248 | history: [ 249 | { 250 | role: "user", 251 | parts: [ 252 | { 253 | fileData: { 254 | mimeType: files[0].mimeType, 255 | fileUri: files[0].uri, 256 | }, 257 | }, 258 | ], 259 | }, 260 | ], 261 | }); 262 | 263 | const result = await chatSession.sendMessage(prompt || "Draw a clip art version of this"); 264 | 265 | const generatedImages = []; 266 | const candidates = result.response.candidates; 267 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 268 | 269 | for(let candidate_index = 0; candidate_index < candidates.length; candidate_index++) { 270 | for(let part_index = 0; part_index < candidates[candidate_index].content.parts.length; part_index++) { 271 | const part = candidates[candidate_index].content.parts[part_index]; 272 | if(part.inlineData) { 273 | try { 274 | const filename = path.join(OUTPUT_DIR, `generated-${timestamp}-${candidate_index}-${part_index}.${mime.extension(part.inlineData.mimeType)}`); 275 | fs.writeFileSync(filename, Buffer.from(part.inlineData.data, 'base64')); 276 | 277 | generatedImages.push({ 278 | mimeType: part.inlineData.mimeType, 279 | data: part.inlineData.data, 280 | path: filename 281 | }); 282 | } catch (err) { 283 | console.error(err); 284 | } 285 | } 286 | } 287 | } 288 | 289 | // Return all results 290 | res.writeHead(200, { 'Content-Type': 'application/json' }); 291 | res.end(JSON.stringify({ 292 | images: generatedImages, 293 | text: result.response.text(), 294 | originalSketch: savedResult.images[0] 295 | })); 296 | 297 | // Clean up files immediately after sending response 298 | try { 299 | // Delete the original sketch 300 | fs.unlinkSync(sketchPath); 301 | // Delete all generated images 302 | generatedImages.forEach(img => { 303 | fs.unlinkSync(img.path); 304 | }); 305 | console.log('Cleaned up temporary files'); 306 | } catch (cleanupError) { 307 | console.error('Error cleaning up files:', cleanupError); 308 | } 309 | } catch (genError) { 310 | console.error('Gemini generation failed:', { 311 | error: genError.message, 312 | stack: genError.stack 313 | }); 314 | res.writeHead(500); 315 | res.end(JSON.stringify({ error: genError.message })); 316 | } 317 | } catch (error) { 318 | console.error('Request processing error:', { 319 | error: error.message, 320 | stack: error.stack 321 | }); 322 | res.writeHead(500); 323 | res.end(JSON.stringify({ error: error.message })); 324 | } 325 | }); 326 | } else if (req.url === '/generate-strokes') { 327 | const ip = req.socket.remoteAddress; 328 | if (generateStrokesLimiter.isRateLimited(ip)) { 329 | res.writeHead(429, { 'Content-Type': 'application/json' }); 330 | res.end(JSON.stringify({ error: 'Please wait between generations' })); 331 | return; 332 | } 333 | 334 | let data = ''; 335 | req.on('data', chunk => { 336 | data += chunk; 337 | }); 338 | 339 | req.on('end', async () => { 340 | try { 341 | const { strokes, userPrompt, canvasWidth, canvasHeight } = JSON.parse(data); 342 | if (!strokes || !Array.isArray(strokes)) { 343 | res.writeHead(400); 344 | res.end(JSON.stringify({ error: 'Invalid strokes data' })); 345 | return; 346 | } 347 | 348 | if (!userPrompt || typeof userPrompt !== 'string') { 349 | res.writeHead(400); 350 | res.end(JSON.stringify({ error: 'Invalid user prompt' })); 351 | return; 352 | } 353 | 354 | const sanitizedPrompt = sanitizePrompt(userPrompt); 355 | 356 | if (!sanitizedPrompt || typeof sanitizedPrompt !== 'string') { 357 | res.writeHead(400); 358 | res.end(JSON.stringify({ error: 'EVIL PROMPT DETECTED!?' })); 359 | return; 360 | } 361 | 362 | // Convert strokes to string representation for logging 363 | const strokesStr = JSON.stringify(strokes); 364 | const finalPrompt = `Strokes data: ${strokesStr}\nUser request: ${sanitizedPrompt}`; 365 | 366 | // Render strokes to image buffer 367 | const imageBuffer = renderStrokesToCanvas(strokes, canvasWidth, canvasHeight); 368 | 369 | if (!imageBuffer) { 370 | throw new Error('Failed to render canvas'); 371 | } 372 | 373 | // Save the sketch 374 | try { 375 | const savedResult = await saveImage(imageBuffer); 376 | const sketchPath = savedResult.images[0].path; 377 | 378 | 379 | // Using the highlighted code - upload to Gemini and generate image 380 | const files = [ 381 | await uploadToGemini(sketchPath, "image/png"), 382 | ]; 383 | 384 | const chatSession = textModel.startChat({ 385 | textGenerationConfig, 386 | history: [ 387 | { 388 | role: "user", 389 | parts: [ 390 | { 391 | fileData: { 392 | mimeType: files[0].mimeType, 393 | fileUri: files[0].uri, 394 | }, 395 | }, 396 | ], 397 | }, 398 | ], 399 | }); 400 | 401 | const result = await chatSession.sendMessage(finalPrompt); 402 | const response = result.response.text(); 403 | const cleanedText = response 404 | .replace(/```json\s*/g, '') // Remove opening ```json 405 | .replace(/```\s*$/g, '') // Remove closing ``` 406 | .replace(/^```|```$/g, '') // Remove any remaining backticks 407 | .trim(); 408 | // Return all results 409 | res.writeHead(200, { 'Content-Type': 'application/json' }); 410 | res.end(JSON.stringify({ newStrokes: cleanedText })); 411 | 412 | // Clean up files immediately after sending response 413 | try { 414 | // Delete the original sketch 415 | fs.unlinkSync(sketchPath); 416 | console.log('Cleaned up temporary files'); 417 | } catch (cleanupError) { 418 | console.error('Error cleaning up files:', cleanupError); 419 | } 420 | } catch (genError) { 421 | console.error('Gemini generation failed:', { 422 | error: genError.message, 423 | stack: genError.stack 424 | }); 425 | res.writeHead(500); 426 | res.end(JSON.stringify({ error: genError.message })); 427 | } 428 | } catch (error) { 429 | console.error('Request processing error:', { 430 | error: error.message, 431 | stack: error.stack 432 | }); 433 | res.writeHead(500); 434 | res.end(JSON.stringify({ error: error.message })); 435 | } 436 | }); 437 | } else { 438 | res.writeHead(200, { 'Content-Type': 'text/plain' }); 439 | res.end('Yjs WebSocket Server is running\n'); 440 | } 441 | }); 442 | }); 443 | 444 | const wss = new WebSocket.Server({ server }); 445 | 446 | wss.on('connection', (ws, req) => { 447 | ip = req.socket.remoteAddress; 448 | if (WSrateLimiter.isRateLimited(ip)) { 449 | console.warn(`Too many connections from ${ip}`); 450 | ws.close(1008, 'Too many connections from your IP'); 451 | return; 452 | } 453 | 454 | const url = new URL(req.url, `ws://${req.headers.host}`); 455 | const roomCode = url.searchParams.get('room'); 456 | const connectionType = url.searchParams.get('type')?.split('/')[0]; 457 | const userName = sanitizeInput(url.searchParams.get('username')?.split('/')[0]) || `User-${Math.floor(Math.random() * 1000)}`; 458 | const userColor = /^#[0-9A-F]{6}$/i.test(url.searchParams.get('color')) 459 | ? url.searchParams.get('color') 460 | : `#${Math.floor(Math.random() * 16777215).toString(16)}`; 461 | 462 | if (!roomCode) { 463 | ws.close(1000, 'No room code provided'); 464 | return; 465 | } 466 | 467 | const pathParts = url.pathname.split('/'); 468 | const docName = pathParts[pathParts.length - 1] || roomCode; 469 | const roomDocKey = roomCode; 470 | 471 | if (!rooms.has(roomDocKey)) { 472 | rooms.set(roomDocKey, new Y.Doc()); 473 | } 474 | 475 | const yDoc = rooms.get(roomDocKey); 476 | 477 | if (connectionType === 'awareness') { 478 | const clientID = crypto.randomUUID(); 479 | 480 | // Store client info first 481 | const clientInfo = { 482 | id: clientID, 483 | connectedAt: new Date(), 484 | ip: req.socket.remoteAddress, 485 | lastActive: Date.now(), 486 | userName, 487 | roomCode, 488 | ws, 489 | color: userColor 490 | }; 491 | 492 | clients.set(clientID, clientInfo); 493 | 494 | // Setup heartbeat to keep track of active users 495 | const heartbeat = setInterval(() => { 496 | if (ws.readyState === WebSocket.OPEN) { 497 | ws.send(JSON.stringify({ type: 'ping' })); 498 | } 499 | }, HEARTBEAT_INTERVAL); 500 | 501 | ws.on('message', (message) => { 502 | try { 503 | const data = JSON.parse(message); 504 | if (data.type === 'pong') { 505 | const client = clients.get(clientID); 506 | if (client) { 507 | client.lastActive = Date.now(); 508 | } 509 | } 510 | } catch (e) { 511 | // Ignore parsing errors for non-heartbeat messages 512 | } 513 | }); 514 | 515 | // Get all users in this room (including the new user since we stored it above) 516 | const activeUsers = Array.from(clients.values()) 517 | .filter(c => c.roomCode === roomCode) 518 | .map(c => ({ 519 | clientID: c.id, 520 | userName: c.userName, 521 | color: c.color, 522 | roomCode: c.roomCode 523 | })); 524 | 525 | // Send to all clients in THIS room only 526 | wss.clients.forEach((client) => { 527 | const clientData = Array.from(clients.values()).find(c => c.ws === client); 528 | if (client.readyState === WebSocket.OPEN && clientData?.roomCode === roomCode) { 529 | client.send(JSON.stringify({ 530 | type: 'active-users', 531 | users: activeUsers 532 | })); 533 | } 534 | }); 535 | 536 | 537 | ws.on('close', () => { 538 | clearInterval(heartbeat); 539 | clients.delete(clientID); 540 | // Send updated active users list after user leaves 541 | const remainingUsers = Array.from(clients.values()) 542 | .filter(c => c.roomCode === roomCode) 543 | .map(c => ({ 544 | clientID: c.id, 545 | userName: c.userName, 546 | color: c.color, 547 | roomCode: c.roomCode 548 | })); 549 | 550 | if (remainingUsers.length === 0) { 551 | scheduleRoomCleanup(roomCode); 552 | } 553 | 554 | wss.clients.forEach((client) => { 555 | const clientData = Array.from(clients.values()).find(c => c.ws === client); 556 | if (client.readyState === WebSocket.OPEN && clientData?.roomCode === roomCode) { 557 | client.send(JSON.stringify({ 558 | type: 'active-users', 559 | users: remainingUsers 560 | })); 561 | } 562 | }); 563 | }); 564 | 565 | ws.on('error', () => { 566 | clearInterval(heartbeat); 567 | ws.close(); 568 | }); 569 | } 570 | ws.on('error', () => { 571 | ws.close(); 572 | }); 573 | 574 | setupWSConnection(ws, req, { 575 | doc: yDoc, 576 | cors: true, 577 | maxBackoffTime: 2500, 578 | gc: false 579 | }); 580 | }); 581 | 582 | const getConnectedClients = () => { 583 | return Array.from(clients.values()).map(client => ({ 584 | id: client.id, 585 | connectedAt: client.connectedAt, 586 | ip: client.ip, 587 | lastActive: client.lastActive, 588 | userName: client.userName, 589 | roomCode: client.roomCode, 590 | connectionDuration: Date.now() - client.connectedAt 591 | })); 592 | }; 593 | 594 | setInterval(() => { 595 | const activeClients = getConnectedClients(); 596 | console.log('Active clients:', activeClients.length); 597 | }, 600000); 598 | 599 | server.listen(port, host, () => { 600 | console.log(`Yjs WebSocket Server is running on ws://${host}:${port}`); 601 | process.on('SIGINT', () => { 602 | cleanDirectory(); // Clean up files before shutting down 603 | wss.close(() => { 604 | console.log('WebSocket server closed'); 605 | process.exit(0); 606 | }); 607 | }); 608 | }); 609 | 610 | const renderStrokesToCanvas = (strokes, canvasWidth, canvasHeight) => { 611 | const canvas = createCanvas(canvasWidth, canvasHeight); 612 | const ctx = canvas.getContext('2d'); 613 | 614 | ctx.fillStyle = 'white'; 615 | ctx.fillRect(0, 0, canvas.width, canvas.height); 616 | 617 | strokes?.forEach((stroke, index) => { 618 | if (!stroke.points?.length) return; 619 | 620 | ctx.beginPath(); 621 | ctx.strokeStyle = stroke.color || 'black'; 622 | ctx.lineWidth = stroke.width || 3; 623 | ctx.lineCap = 'round'; 624 | ctx.lineJoin = 'round'; 625 | 626 | ctx.moveTo(stroke.points[0].x, stroke.points[0].y); 627 | for (let i = 1; i < stroke.points.length; i++) { 628 | ctx.lineTo(stroke.points[i].x, stroke.points[i].y); 629 | } 630 | ctx.stroke(); 631 | }); 632 | 633 | return canvas.toBuffer('image/png'); 634 | }; 635 | 636 | async function saveImage(imageBuffer) { 637 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 638 | const sketchPath = path.join(OUTPUT_DIR, `sketch-${timestamp}.png`); 639 | 640 | try { 641 | fs.writeFileSync(sketchPath, imageBuffer); 642 | 643 | return { 644 | images: [{ 645 | mimeType: "image/png", 646 | data: fs.readFileSync(sketchPath).toString('base64'), 647 | path: sketchPath 648 | }], 649 | text: "Sketch saved successfully" 650 | }; 651 | } catch (error) { 652 | console.error('Error saving sketch:', { 653 | error: error.message, 654 | stack: error.stack, 655 | timestamp: new Date().toISOString() 656 | }); 657 | throw error; 658 | } 659 | } 660 | 661 | const scheduleRoomCleanup = (roomCode) => { 662 | // Clear any existing timeout 663 | if (roomTimeouts.has(roomCode)) { 664 | clearTimeout(roomTimeouts.get(roomCode)); 665 | } 666 | 667 | // Schedule new cleanup 668 | const timeout = setTimeout(() => { 669 | const hasActiveUsers = Array.from(clients.values()) 670 | .some(client => client.roomCode === roomCode); 671 | 672 | if (!hasActiveUsers) { 673 | const doc = rooms.get(roomCode); 674 | if (doc) doc.destroy(); // Free up Yjs internals 675 | rooms.delete(roomCode); 676 | roomTimeouts.delete(roomCode); 677 | console.log(`Cleaned up inactive room: ${roomCode}`); 678 | } 679 | }, ROOM_CLEANUP_DELAY); 680 | 681 | roomTimeouts.set(roomCode, timeout); 682 | }; 683 | 684 | async function uploadToGemini(path, mimeType) { 685 | const uploadResult = await fileManager.uploadFile(path, { 686 | mimeType, 687 | displayName: path, 688 | }); 689 | const file = uploadResult.file; 690 | return file; 691 | } 692 | 693 | const model = genAI.getGenerativeModel({ 694 | model: "gemini-2.0-flash-exp-image-generation", 695 | }); 696 | 697 | const textModel = genAI.getGenerativeModel({ 698 | model: "gemini-2.0-flash", 699 | systemInstruction: `You are an assistant that helps add drawings to a digital whiteboard. You are given: 700 | 1) An image of the current whiteboard state 701 | 2) The existing stroke data that represents the current drawings 702 | 3) A user request for what to add to the whiteboard 703 | 704 | Respond ONLY with a JSON array of new strokes needed to fulfill the user's request. Each stroke should follow this format: 705 | {points: [{ x: number, y: number }], color: string, width: number} 706 | 707 | Guidelines for creating high-quality strokes: 708 | - Create smooth, natural-looking strokes with 10-20 points per stroke when appropriate 709 | - Match the style and stroke density of existing content on the whiteboard 710 | - Position new elements logically in relation to existing content 711 | - If asked to "fill" an area, use wider strokes or multiple overlapping strokes 712 | - For detailed drawings, use 10-20 strokes minimum to ensure adequate detail 713 | - Use appropriate colors that match the existing content or what is described in the user request 714 | Return ONLY the JSON array without explanations or additional text.` 715 | }); 716 | 717 | const generationConfig = { 718 | temperature: 0.01, 719 | topP: 0.95, 720 | topK: 40, 721 | maxOutputTokens: 8192, 722 | responseModalities: [ 723 | "image", 724 | "text", 725 | ], 726 | responseMimeType: "text/plain", 727 | }; 728 | 729 | const textGenerationConfig = { 730 | temperature: 1, 731 | topP: 0.95, 732 | topK: 40, 733 | maxOutputTokens: 8192, 734 | responseModalities: [ 735 | ], 736 | responseMimeType: "application/json", 737 | responseSchema: { 738 | type: "object", 739 | properties: { 740 | points: { 741 | type: "array", 742 | items: { 743 | type: "object", 744 | properties: { 745 | x: { 746 | type: "integer" 747 | }, 748 | y: { 749 | type: "integer" 750 | } 751 | } 752 | } 753 | }, 754 | color: { 755 | type: "string" 756 | }, 757 | width: { 758 | type: "integer" 759 | } 760 | }, 761 | required: [ 762 | "points", 763 | "color", 764 | "width" 765 | ] 766 | }, 767 | }; -------------------------------------------------------------------------------- /wb-backend/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wb-backend", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@google/generative-ai": "^0.24.0", 9 | "canvas": "^3.1.0", 10 | "cors": "^2.8.5", 11 | "dotenv": "^16.4.7", 12 | "http": "^0.0.1-security", 13 | "mime-types": "^3.0.1", 14 | "ws": "^8.18.1", 15 | "y-websocket": "^1.3.12" 16 | } 17 | }, 18 | "node_modules/@google/generative-ai": { 19 | "version": "0.24.0", 20 | "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.0.tgz", 21 | "integrity": "sha512-fnEITCGEB7NdX0BhoYZ/cq/7WPZ1QS5IzJJfC3Tg/OwkvBetMiVJciyaan297OvE4B9Jg1xvo0zIazX/9sGu1Q==", 22 | "license": "Apache-2.0", 23 | "engines": { 24 | "node": ">=18.0.0" 25 | } 26 | }, 27 | "node_modules/abstract-leveldown": { 28 | "version": "6.2.3", 29 | "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", 30 | "integrity": "sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==", 31 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 32 | "license": "MIT", 33 | "optional": true, 34 | "dependencies": { 35 | "buffer": "^5.5.0", 36 | "immediate": "^3.2.3", 37 | "level-concat-iterator": "~2.0.0", 38 | "level-supports": "~1.0.0", 39 | "xtend": "~4.0.0" 40 | }, 41 | "engines": { 42 | "node": ">=6" 43 | } 44 | }, 45 | "node_modules/async-limiter": { 46 | "version": "1.0.1", 47 | "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", 48 | "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", 49 | "license": "MIT", 50 | "optional": true 51 | }, 52 | "node_modules/base64-js": { 53 | "version": "1.5.1", 54 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 55 | "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 56 | "funding": [ 57 | { 58 | "type": "github", 59 | "url": "https://github.com/sponsors/feross" 60 | }, 61 | { 62 | "type": "patreon", 63 | "url": "https://www.patreon.com/feross" 64 | }, 65 | { 66 | "type": "consulting", 67 | "url": "https://feross.org/support" 68 | } 69 | ], 70 | "license": "MIT" 71 | }, 72 | "node_modules/bl": { 73 | "version": "4.1.0", 74 | "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", 75 | "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", 76 | "license": "MIT", 77 | "dependencies": { 78 | "buffer": "^5.5.0", 79 | "inherits": "^2.0.4", 80 | "readable-stream": "^3.4.0" 81 | } 82 | }, 83 | "node_modules/buffer": { 84 | "version": "5.7.1", 85 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 86 | "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 87 | "funding": [ 88 | { 89 | "type": "github", 90 | "url": "https://github.com/sponsors/feross" 91 | }, 92 | { 93 | "type": "patreon", 94 | "url": "https://www.patreon.com/feross" 95 | }, 96 | { 97 | "type": "consulting", 98 | "url": "https://feross.org/support" 99 | } 100 | ], 101 | "license": "MIT", 102 | "dependencies": { 103 | "base64-js": "^1.3.1", 104 | "ieee754": "^1.1.13" 105 | } 106 | }, 107 | "node_modules/canvas": { 108 | "version": "3.1.0", 109 | "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.0.tgz", 110 | "integrity": "sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==", 111 | "hasInstallScript": true, 112 | "license": "MIT", 113 | "dependencies": { 114 | "node-addon-api": "^7.0.0", 115 | "prebuild-install": "^7.1.1" 116 | }, 117 | "engines": { 118 | "node": "^18.12.0 || >= 20.9.0" 119 | } 120 | }, 121 | "node_modules/chownr": { 122 | "version": "1.1.4", 123 | "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 124 | "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", 125 | "license": "ISC" 126 | }, 127 | "node_modules/cors": { 128 | "version": "2.8.5", 129 | "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", 130 | "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", 131 | "license": "MIT", 132 | "dependencies": { 133 | "object-assign": "^4", 134 | "vary": "^1" 135 | }, 136 | "engines": { 137 | "node": ">= 0.10" 138 | } 139 | }, 140 | "node_modules/decompress-response": { 141 | "version": "6.0.0", 142 | "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", 143 | "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", 144 | "license": "MIT", 145 | "dependencies": { 146 | "mimic-response": "^3.1.0" 147 | }, 148 | "engines": { 149 | "node": ">=10" 150 | }, 151 | "funding": { 152 | "url": "https://github.com/sponsors/sindresorhus" 153 | } 154 | }, 155 | "node_modules/deep-extend": { 156 | "version": "0.6.0", 157 | "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", 158 | "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", 159 | "license": "MIT", 160 | "engines": { 161 | "node": ">=4.0.0" 162 | } 163 | }, 164 | "node_modules/deferred-leveldown": { 165 | "version": "5.3.0", 166 | "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-5.3.0.tgz", 167 | "integrity": "sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==", 168 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 169 | "license": "MIT", 170 | "optional": true, 171 | "dependencies": { 172 | "abstract-leveldown": "~6.2.1", 173 | "inherits": "^2.0.3" 174 | }, 175 | "engines": { 176 | "node": ">=6" 177 | } 178 | }, 179 | "node_modules/detect-libc": { 180 | "version": "2.0.3", 181 | "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", 182 | "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", 183 | "license": "Apache-2.0", 184 | "engines": { 185 | "node": ">=8" 186 | } 187 | }, 188 | "node_modules/dotenv": { 189 | "version": "16.4.7", 190 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", 191 | "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", 192 | "license": "BSD-2-Clause", 193 | "engines": { 194 | "node": ">=12" 195 | }, 196 | "funding": { 197 | "url": "https://dotenvx.com" 198 | } 199 | }, 200 | "node_modules/encoding-down": { 201 | "version": "6.3.0", 202 | "resolved": "https://registry.npmjs.org/encoding-down/-/encoding-down-6.3.0.tgz", 203 | "integrity": "sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==", 204 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 205 | "license": "MIT", 206 | "optional": true, 207 | "dependencies": { 208 | "abstract-leveldown": "^6.2.1", 209 | "inherits": "^2.0.3", 210 | "level-codec": "^9.0.0", 211 | "level-errors": "^2.0.0" 212 | }, 213 | "engines": { 214 | "node": ">=6" 215 | } 216 | }, 217 | "node_modules/end-of-stream": { 218 | "version": "1.4.4", 219 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", 220 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", 221 | "license": "MIT", 222 | "dependencies": { 223 | "once": "^1.4.0" 224 | } 225 | }, 226 | "node_modules/errno": { 227 | "version": "0.1.8", 228 | "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", 229 | "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", 230 | "license": "MIT", 231 | "optional": true, 232 | "dependencies": { 233 | "prr": "~1.0.1" 234 | }, 235 | "bin": { 236 | "errno": "cli.js" 237 | } 238 | }, 239 | "node_modules/expand-template": { 240 | "version": "2.0.3", 241 | "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", 242 | "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", 243 | "license": "(MIT OR WTFPL)", 244 | "engines": { 245 | "node": ">=6" 246 | } 247 | }, 248 | "node_modules/fs-constants": { 249 | "version": "1.0.0", 250 | "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", 251 | "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", 252 | "license": "MIT" 253 | }, 254 | "node_modules/github-from-package": { 255 | "version": "0.0.0", 256 | "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", 257 | "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", 258 | "license": "MIT" 259 | }, 260 | "node_modules/http": { 261 | "version": "0.0.1-security", 262 | "resolved": "https://registry.npmjs.org/http/-/http-0.0.1-security.tgz", 263 | "integrity": "sha512-RnDvP10Ty9FxqOtPZuxtebw1j4L/WiqNMDtuc1YMH1XQm5TgDRaR1G9u8upL6KD1bXHSp9eSXo/ED+8Q7FAr+g==" 264 | }, 265 | "node_modules/ieee754": { 266 | "version": "1.2.1", 267 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 268 | "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 269 | "funding": [ 270 | { 271 | "type": "github", 272 | "url": "https://github.com/sponsors/feross" 273 | }, 274 | { 275 | "type": "patreon", 276 | "url": "https://www.patreon.com/feross" 277 | }, 278 | { 279 | "type": "consulting", 280 | "url": "https://feross.org/support" 281 | } 282 | ], 283 | "license": "BSD-3-Clause" 284 | }, 285 | "node_modules/immediate": { 286 | "version": "3.3.0", 287 | "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz", 288 | "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", 289 | "license": "MIT", 290 | "optional": true 291 | }, 292 | "node_modules/inherits": { 293 | "version": "2.0.4", 294 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 295 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 296 | "license": "ISC" 297 | }, 298 | "node_modules/ini": { 299 | "version": "1.3.8", 300 | "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", 301 | "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", 302 | "license": "ISC" 303 | }, 304 | "node_modules/isomorphic.js": { 305 | "version": "0.2.5", 306 | "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", 307 | "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", 308 | "license": "MIT", 309 | "funding": { 310 | "type": "GitHub Sponsors ❤", 311 | "url": "https://github.com/sponsors/dmonad" 312 | } 313 | }, 314 | "node_modules/level": { 315 | "version": "6.0.1", 316 | "resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz", 317 | "integrity": "sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==", 318 | "license": "MIT", 319 | "optional": true, 320 | "dependencies": { 321 | "level-js": "^5.0.0", 322 | "level-packager": "^5.1.0", 323 | "leveldown": "^5.4.0" 324 | }, 325 | "engines": { 326 | "node": ">=8.6.0" 327 | }, 328 | "funding": { 329 | "type": "opencollective", 330 | "url": "https://opencollective.com/level" 331 | } 332 | }, 333 | "node_modules/level-codec": { 334 | "version": "9.0.2", 335 | "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-9.0.2.tgz", 336 | "integrity": "sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==", 337 | "deprecated": "Superseded by level-transcoder (https://github.com/Level/community#faq)", 338 | "license": "MIT", 339 | "optional": true, 340 | "dependencies": { 341 | "buffer": "^5.6.0" 342 | }, 343 | "engines": { 344 | "node": ">=6" 345 | } 346 | }, 347 | "node_modules/level-concat-iterator": { 348 | "version": "2.0.1", 349 | "resolved": "https://registry.npmjs.org/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz", 350 | "integrity": "sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==", 351 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 352 | "license": "MIT", 353 | "optional": true, 354 | "engines": { 355 | "node": ">=6" 356 | } 357 | }, 358 | "node_modules/level-errors": { 359 | "version": "2.0.1", 360 | "resolved": "https://registry.npmjs.org/level-errors/-/level-errors-2.0.1.tgz", 361 | "integrity": "sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==", 362 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 363 | "license": "MIT", 364 | "optional": true, 365 | "dependencies": { 366 | "errno": "~0.1.1" 367 | }, 368 | "engines": { 369 | "node": ">=6" 370 | } 371 | }, 372 | "node_modules/level-iterator-stream": { 373 | "version": "4.0.2", 374 | "resolved": "https://registry.npmjs.org/level-iterator-stream/-/level-iterator-stream-4.0.2.tgz", 375 | "integrity": "sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==", 376 | "license": "MIT", 377 | "optional": true, 378 | "dependencies": { 379 | "inherits": "^2.0.4", 380 | "readable-stream": "^3.4.0", 381 | "xtend": "^4.0.2" 382 | }, 383 | "engines": { 384 | "node": ">=6" 385 | } 386 | }, 387 | "node_modules/level-js": { 388 | "version": "5.0.2", 389 | "resolved": "https://registry.npmjs.org/level-js/-/level-js-5.0.2.tgz", 390 | "integrity": "sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==", 391 | "deprecated": "Superseded by browser-level (https://github.com/Level/community#faq)", 392 | "license": "MIT", 393 | "optional": true, 394 | "dependencies": { 395 | "abstract-leveldown": "~6.2.3", 396 | "buffer": "^5.5.0", 397 | "inherits": "^2.0.3", 398 | "ltgt": "^2.1.2" 399 | } 400 | }, 401 | "node_modules/level-packager": { 402 | "version": "5.1.1", 403 | "resolved": "https://registry.npmjs.org/level-packager/-/level-packager-5.1.1.tgz", 404 | "integrity": "sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==", 405 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 406 | "license": "MIT", 407 | "optional": true, 408 | "dependencies": { 409 | "encoding-down": "^6.3.0", 410 | "levelup": "^4.3.2" 411 | }, 412 | "engines": { 413 | "node": ">=6" 414 | } 415 | }, 416 | "node_modules/level-supports": { 417 | "version": "1.0.1", 418 | "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-1.0.1.tgz", 419 | "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", 420 | "license": "MIT", 421 | "optional": true, 422 | "dependencies": { 423 | "xtend": "^4.0.2" 424 | }, 425 | "engines": { 426 | "node": ">=6" 427 | } 428 | }, 429 | "node_modules/leveldown": { 430 | "version": "5.6.0", 431 | "resolved": "https://registry.npmjs.org/leveldown/-/leveldown-5.6.0.tgz", 432 | "integrity": "sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==", 433 | "deprecated": "Superseded by classic-level (https://github.com/Level/community#faq)", 434 | "hasInstallScript": true, 435 | "license": "MIT", 436 | "optional": true, 437 | "dependencies": { 438 | "abstract-leveldown": "~6.2.1", 439 | "napi-macros": "~2.0.0", 440 | "node-gyp-build": "~4.1.0" 441 | }, 442 | "engines": { 443 | "node": ">=8.6.0" 444 | } 445 | }, 446 | "node_modules/levelup": { 447 | "version": "4.4.0", 448 | "resolved": "https://registry.npmjs.org/levelup/-/levelup-4.4.0.tgz", 449 | "integrity": "sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==", 450 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 451 | "license": "MIT", 452 | "optional": true, 453 | "dependencies": { 454 | "deferred-leveldown": "~5.3.0", 455 | "level-errors": "~2.0.0", 456 | "level-iterator-stream": "~4.0.0", 457 | "level-supports": "~1.0.0", 458 | "xtend": "~4.0.0" 459 | }, 460 | "engines": { 461 | "node": ">=6" 462 | } 463 | }, 464 | "node_modules/lib0": { 465 | "version": "0.2.101", 466 | "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.101.tgz", 467 | "integrity": "sha512-LljA6+Ehf0Z7YnxhgSAvspzWALjW4wlWdN/W4iGiqYc1KvXQgOVXWI0xwlwqozIL5WRdKeUW2gq0DLhFsY+Xlw==", 468 | "license": "MIT", 469 | "dependencies": { 470 | "isomorphic.js": "^0.2.4" 471 | }, 472 | "bin": { 473 | "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", 474 | "0gentesthtml": "bin/gentesthtml.js", 475 | "0serve": "bin/0serve.js" 476 | }, 477 | "engines": { 478 | "node": ">=16" 479 | }, 480 | "funding": { 481 | "type": "GitHub Sponsors ❤", 482 | "url": "https://github.com/sponsors/dmonad" 483 | } 484 | }, 485 | "node_modules/lodash.debounce": { 486 | "version": "4.0.8", 487 | "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", 488 | "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", 489 | "license": "MIT" 490 | }, 491 | "node_modules/ltgt": { 492 | "version": "2.2.1", 493 | "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", 494 | "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", 495 | "license": "MIT", 496 | "optional": true 497 | }, 498 | "node_modules/mime-db": { 499 | "version": "1.54.0", 500 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", 501 | "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", 502 | "license": "MIT", 503 | "engines": { 504 | "node": ">= 0.6" 505 | } 506 | }, 507 | "node_modules/mime-types": { 508 | "version": "3.0.1", 509 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", 510 | "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", 511 | "license": "MIT", 512 | "dependencies": { 513 | "mime-db": "^1.54.0" 514 | }, 515 | "engines": { 516 | "node": ">= 0.6" 517 | } 518 | }, 519 | "node_modules/mimic-response": { 520 | "version": "3.1.0", 521 | "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 522 | "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", 523 | "license": "MIT", 524 | "engines": { 525 | "node": ">=10" 526 | }, 527 | "funding": { 528 | "url": "https://github.com/sponsors/sindresorhus" 529 | } 530 | }, 531 | "node_modules/minimist": { 532 | "version": "1.2.8", 533 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 534 | "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 535 | "license": "MIT", 536 | "funding": { 537 | "url": "https://github.com/sponsors/ljharb" 538 | } 539 | }, 540 | "node_modules/mkdirp-classic": { 541 | "version": "0.5.3", 542 | "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", 543 | "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", 544 | "license": "MIT" 545 | }, 546 | "node_modules/napi-build-utils": { 547 | "version": "2.0.0", 548 | "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", 549 | "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", 550 | "license": "MIT" 551 | }, 552 | "node_modules/napi-macros": { 553 | "version": "2.0.0", 554 | "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.0.0.tgz", 555 | "integrity": "sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==", 556 | "license": "MIT", 557 | "optional": true 558 | }, 559 | "node_modules/node-abi": { 560 | "version": "3.74.0", 561 | "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", 562 | "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", 563 | "license": "MIT", 564 | "dependencies": { 565 | "semver": "^7.3.5" 566 | }, 567 | "engines": { 568 | "node": ">=10" 569 | } 570 | }, 571 | "node_modules/node-addon-api": { 572 | "version": "7.1.1", 573 | "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", 574 | "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", 575 | "license": "MIT" 576 | }, 577 | "node_modules/node-gyp-build": { 578 | "version": "4.1.1", 579 | "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz", 580 | "integrity": "sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==", 581 | "license": "MIT", 582 | "optional": true, 583 | "bin": { 584 | "node-gyp-build": "bin.js", 585 | "node-gyp-build-optional": "optional.js", 586 | "node-gyp-build-test": "build-test.js" 587 | } 588 | }, 589 | "node_modules/object-assign": { 590 | "version": "4.1.1", 591 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 592 | "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", 593 | "license": "MIT", 594 | "engines": { 595 | "node": ">=0.10.0" 596 | } 597 | }, 598 | "node_modules/once": { 599 | "version": "1.4.0", 600 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 601 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 602 | "license": "ISC", 603 | "dependencies": { 604 | "wrappy": "1" 605 | } 606 | }, 607 | "node_modules/prebuild-install": { 608 | "version": "7.1.3", 609 | "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", 610 | "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", 611 | "license": "MIT", 612 | "dependencies": { 613 | "detect-libc": "^2.0.0", 614 | "expand-template": "^2.0.3", 615 | "github-from-package": "0.0.0", 616 | "minimist": "^1.2.3", 617 | "mkdirp-classic": "^0.5.3", 618 | "napi-build-utils": "^2.0.0", 619 | "node-abi": "^3.3.0", 620 | "pump": "^3.0.0", 621 | "rc": "^1.2.7", 622 | "simple-get": "^4.0.0", 623 | "tar-fs": "^2.0.0", 624 | "tunnel-agent": "^0.6.0" 625 | }, 626 | "bin": { 627 | "prebuild-install": "bin.js" 628 | }, 629 | "engines": { 630 | "node": ">=10" 631 | } 632 | }, 633 | "node_modules/prr": { 634 | "version": "1.0.1", 635 | "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", 636 | "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", 637 | "license": "MIT", 638 | "optional": true 639 | }, 640 | "node_modules/pump": { 641 | "version": "3.0.2", 642 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", 643 | "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", 644 | "license": "MIT", 645 | "dependencies": { 646 | "end-of-stream": "^1.1.0", 647 | "once": "^1.3.1" 648 | } 649 | }, 650 | "node_modules/rc": { 651 | "version": "1.2.8", 652 | "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", 653 | "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", 654 | "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", 655 | "dependencies": { 656 | "deep-extend": "^0.6.0", 657 | "ini": "~1.3.0", 658 | "minimist": "^1.2.0", 659 | "strip-json-comments": "~2.0.1" 660 | }, 661 | "bin": { 662 | "rc": "cli.js" 663 | } 664 | }, 665 | "node_modules/readable-stream": { 666 | "version": "3.6.2", 667 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 668 | "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 669 | "license": "MIT", 670 | "dependencies": { 671 | "inherits": "^2.0.3", 672 | "string_decoder": "^1.1.1", 673 | "util-deprecate": "^1.0.1" 674 | }, 675 | "engines": { 676 | "node": ">= 6" 677 | } 678 | }, 679 | "node_modules/safe-buffer": { 680 | "version": "5.2.1", 681 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 682 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 683 | "funding": [ 684 | { 685 | "type": "github", 686 | "url": "https://github.com/sponsors/feross" 687 | }, 688 | { 689 | "type": "patreon", 690 | "url": "https://www.patreon.com/feross" 691 | }, 692 | { 693 | "type": "consulting", 694 | "url": "https://feross.org/support" 695 | } 696 | ], 697 | "license": "MIT" 698 | }, 699 | "node_modules/semver": { 700 | "version": "7.7.1", 701 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", 702 | "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", 703 | "license": "ISC", 704 | "bin": { 705 | "semver": "bin/semver.js" 706 | }, 707 | "engines": { 708 | "node": ">=10" 709 | } 710 | }, 711 | "node_modules/simple-concat": { 712 | "version": "1.0.1", 713 | "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", 714 | "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", 715 | "funding": [ 716 | { 717 | "type": "github", 718 | "url": "https://github.com/sponsors/feross" 719 | }, 720 | { 721 | "type": "patreon", 722 | "url": "https://www.patreon.com/feross" 723 | }, 724 | { 725 | "type": "consulting", 726 | "url": "https://feross.org/support" 727 | } 728 | ], 729 | "license": "MIT" 730 | }, 731 | "node_modules/simple-get": { 732 | "version": "4.0.1", 733 | "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", 734 | "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", 735 | "funding": [ 736 | { 737 | "type": "github", 738 | "url": "https://github.com/sponsors/feross" 739 | }, 740 | { 741 | "type": "patreon", 742 | "url": "https://www.patreon.com/feross" 743 | }, 744 | { 745 | "type": "consulting", 746 | "url": "https://feross.org/support" 747 | } 748 | ], 749 | "license": "MIT", 750 | "dependencies": { 751 | "decompress-response": "^6.0.0", 752 | "once": "^1.3.1", 753 | "simple-concat": "^1.0.0" 754 | } 755 | }, 756 | "node_modules/string_decoder": { 757 | "version": "1.3.0", 758 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 759 | "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 760 | "license": "MIT", 761 | "dependencies": { 762 | "safe-buffer": "~5.2.0" 763 | } 764 | }, 765 | "node_modules/strip-json-comments": { 766 | "version": "2.0.1", 767 | "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", 768 | "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", 769 | "license": "MIT", 770 | "engines": { 771 | "node": ">=0.10.0" 772 | } 773 | }, 774 | "node_modules/tar-fs": { 775 | "version": "2.1.2", 776 | "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", 777 | "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", 778 | "license": "MIT", 779 | "dependencies": { 780 | "chownr": "^1.1.1", 781 | "mkdirp-classic": "^0.5.2", 782 | "pump": "^3.0.0", 783 | "tar-stream": "^2.1.4" 784 | } 785 | }, 786 | "node_modules/tar-stream": { 787 | "version": "2.2.0", 788 | "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", 789 | "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", 790 | "license": "MIT", 791 | "dependencies": { 792 | "bl": "^4.0.3", 793 | "end-of-stream": "^1.4.1", 794 | "fs-constants": "^1.0.0", 795 | "inherits": "^2.0.3", 796 | "readable-stream": "^3.1.1" 797 | }, 798 | "engines": { 799 | "node": ">=6" 800 | } 801 | }, 802 | "node_modules/tunnel-agent": { 803 | "version": "0.6.0", 804 | "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", 805 | "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", 806 | "license": "Apache-2.0", 807 | "dependencies": { 808 | "safe-buffer": "^5.0.1" 809 | }, 810 | "engines": { 811 | "node": "*" 812 | } 813 | }, 814 | "node_modules/util-deprecate": { 815 | "version": "1.0.2", 816 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 817 | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 818 | "license": "MIT" 819 | }, 820 | "node_modules/vary": { 821 | "version": "1.1.2", 822 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 823 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", 824 | "license": "MIT", 825 | "engines": { 826 | "node": ">= 0.8" 827 | } 828 | }, 829 | "node_modules/wrappy": { 830 | "version": "1.0.2", 831 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 832 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 833 | "license": "ISC" 834 | }, 835 | "node_modules/ws": { 836 | "version": "8.18.1", 837 | "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", 838 | "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", 839 | "license": "MIT", 840 | "engines": { 841 | "node": ">=10.0.0" 842 | }, 843 | "peerDependencies": { 844 | "bufferutil": "^4.0.1", 845 | "utf-8-validate": ">=5.0.2" 846 | }, 847 | "peerDependenciesMeta": { 848 | "bufferutil": { 849 | "optional": true 850 | }, 851 | "utf-8-validate": { 852 | "optional": true 853 | } 854 | } 855 | }, 856 | "node_modules/xtend": { 857 | "version": "4.0.2", 858 | "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", 859 | "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", 860 | "license": "MIT", 861 | "optional": true, 862 | "engines": { 863 | "node": ">=0.4" 864 | } 865 | }, 866 | "node_modules/y-leveldb": { 867 | "version": "0.1.2", 868 | "resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz", 869 | "integrity": "sha512-6ulEn5AXfXJYi89rXPEg2mMHAyyw8+ZfeMMdOtBbV8FJpQ1NOrcgi6DTAcXof0dap84NjHPT2+9d0rb6cFsjEg==", 870 | "license": "MIT", 871 | "optional": true, 872 | "dependencies": { 873 | "level": "^6.0.1", 874 | "lib0": "^0.2.31" 875 | }, 876 | "funding": { 877 | "type": "GitHub Sponsors ❤", 878 | "url": "https://github.com/sponsors/dmonad" 879 | }, 880 | "peerDependencies": { 881 | "yjs": "^13.0.0" 882 | } 883 | }, 884 | "node_modules/y-protocols": { 885 | "version": "1.0.6", 886 | "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", 887 | "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", 888 | "license": "MIT", 889 | "dependencies": { 890 | "lib0": "^0.2.85" 891 | }, 892 | "engines": { 893 | "node": ">=16.0.0", 894 | "npm": ">=8.0.0" 895 | }, 896 | "funding": { 897 | "type": "GitHub Sponsors ❤", 898 | "url": "https://github.com/sponsors/dmonad" 899 | }, 900 | "peerDependencies": { 901 | "yjs": "^13.0.0" 902 | } 903 | }, 904 | "node_modules/y-websocket": { 905 | "version": "1.3.12", 906 | "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-1.3.12.tgz", 907 | "integrity": "sha512-ZPFZ41aUG8WL87g8tIrSuB0NtWJhOsud/epRERd3h0T80cywTEP9gtJuHexJpZIZGI1LvN9F+bkowro/oAbfkg==", 908 | "license": "MIT", 909 | "dependencies": { 910 | "lib0": "^0.2.35", 911 | "lodash.debounce": "^4.0.8", 912 | "y-protocols": "^1.0.4" 913 | }, 914 | "bin": { 915 | "y-websocket-server": "bin/server.js" 916 | }, 917 | "funding": { 918 | "type": "GitHub Sponsors ❤", 919 | "url": "https://github.com/sponsors/dmonad" 920 | }, 921 | "optionalDependencies": { 922 | "ws": "^6.2.1", 923 | "y-leveldb": "^0.1.0" 924 | }, 925 | "peerDependencies": { 926 | "yjs": "^13.5.0" 927 | } 928 | }, 929 | "node_modules/y-websocket/node_modules/ws": { 930 | "version": "6.2.3", 931 | "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", 932 | "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", 933 | "license": "MIT", 934 | "optional": true, 935 | "dependencies": { 936 | "async-limiter": "~1.0.0" 937 | } 938 | }, 939 | "node_modules/yjs": { 940 | "version": "13.6.24", 941 | "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.24.tgz", 942 | "integrity": "sha512-xn/pYLTZa3uD1uDG8lpxfLRo5SR/rp0frdASOl2a71aYNvUXdWcLtVL91s2y7j+Q8ppmjZ9H3jsGVgoFMbT2VA==", 943 | "license": "MIT", 944 | "peer": true, 945 | "dependencies": { 946 | "lib0": "^0.2.99" 947 | }, 948 | "engines": { 949 | "node": ">=16.0.0", 950 | "npm": ">=8.0.0" 951 | }, 952 | "funding": { 953 | "type": "GitHub Sponsors ❤", 954 | "url": "https://github.com/sponsors/dmonad" 955 | } 956 | } 957 | } 958 | } 959 | --------------------------------------------------------------------------------