├── 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 |
13 | {darkMode ? : }
14 |
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 |
63 |
64 |
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
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 |
89 | Create
90 |
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 |
101 | AI
102 |
103 |
104 |
105 | {showPrompt && (
106 |
107 | {error && (
108 |
109 | {error}
110 |
111 | )}
112 |
113 |
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 |
86 | {isGenerating ? '⏳ Generating...' : 'Improve Image ✨'}
87 |
88 |
89 | {error && (
90 |
91 | {error}
92 |
93 | )}
94 | {generatedImages.length > 0 && (
95 |
96 |
Generated Images
97 |
98 | {generatedImages.map((img) => (
99 |
100 |
deleteGeneratedImage(img.id)}
102 | className="cursor-pointer absolute p-1 top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center hover:bg-red-600"
103 | title="Delete image"
104 | >
105 |
106 |
107 |
{
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 |
170 | {isLoading && (
171 |
172 | Loading hand tracking model...
173 |
174 | )}
175 |
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 |
62 | {useHandTracking ? : }
63 |
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 | {
92 | store.setColor(color);
93 | store.selectedTool === 'eraser' && store.setTool('pen');
94 | }}
95 | aria-label={color}
96 | />
97 | ))}
98 |
99 |
100 |
101 |
102 | store.setTool('eraser')}
107 | aria-label="Eraser"
108 | >
109 |
110 |
111 |
112 |
113 |
114 | store.setShapeRecognition(!store.shapeRecognition)}
118 | aria-label="Shape Recognition"
119 | >
120 |
121 |
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 |
161 |
162 |
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 |
126 | Export as PNG
127 |
128 |
129 |
130 |
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 |
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 | setShapeRecognitionEnabled(prev => !prev)}
274 | >
275 |
276 |
277 |
278 |
279 |
280 | {/* setMiniMapVisible(!miniMapVisible)}
283 | >
284 | {miniMapVisible ? 'Hide Mini-map' : 'Show Mini-map'}
285 |
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 |
318 | Send
319 |
320 |
321 |
*/}
322 |
323 |
324 |
325 |
326 | {/*
327 | Import Image
328 |
334 | */}
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 |
--------------------------------------------------------------------------------