From e96946e246a20fed967472538f7604fbd382213d Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 25 Jun 2026 20:38:03 +0200 Subject: [PATCH] Whiteboard: neues Tool mit Canvas, Undo, Kollaboration via WebSocket, Berechtigungssystem --- backend/package.json | 3 +- backend/src/index.js | 101 +++- backend/src/tools/whiteboard/routes.js | 153 ++++++ frontend/src/icons.jsx | 5 + frontend/src/toolRegistry.js | 11 +- frontend/src/tools/whiteboard.jsx | 695 +++++++++++++++++++++++++ 6 files changed, 964 insertions(+), 4 deletions(-) create mode 100644 backend/src/tools/whiteboard/routes.js create mode 100644 frontend/src/tools/whiteboard.jsx diff --git a/backend/package.json b/backend/package.json index 946aec8..212e9ba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "better-sqlite3": "^9.4.3", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "ws": "^8.17.1" } } \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 5cbe99d..66d86a2 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -33,7 +33,7 @@ app.use((_req, res, next) => { "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com", // iCal-Feeds werden serverseitig geprosxt β†’ nur 'self' nΓΆtig - "connect-src 'self' https://web.archive.org", + "connect-src 'self' https://web.archive.org wss://dickendock.sermer.org ws://localhost:4000", // data: fΓΌr Avatare (base64), google.com + gstatic.com fΓΌr Favicons im QuickLinks-Widget "img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org", "worker-src 'self'", @@ -127,7 +127,104 @@ app.get('/api/build-time', (_req, res) => { } catch { res.json({ buildTime: 'unknown' }); } }); -app.listen(4000, () => console.log('πŸš€ Dicken Dock lΓ€uft auf Port 4000')); +// ── HTTP + WebSocket Server ─────────────────────────────────────────────────── +const http = require('http'); +const { WebSocketServer } = require('ws'); +const jwt = require('jsonwebtoken'); + +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/ws/whiteboard' }); + +// Raum-Map: whiteboard_id β†’ Set +const rooms = new Map(); + +wss.on('connection', (ws, req) => { + // Token aus Query-String auslesen: /ws/whiteboard?token=...&id=... + const params = new URL(req.url, 'http://localhost').searchParams; + const token = params.get('token'); + const wbId = parseInt(params.get('id')); + + let userId = null; + try { + const p = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret'); + userId = p.id; + } catch { + ws.close(1008, 'Unauthorized'); + return; + } + + // Zugriff prΓΌfen + const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(wbId); + if (!wb) { ws.close(1008, 'Not found'); return; } + const isOwner = wb.owner_id === userId; + const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(wbId, userId); + if (!isOwner && !perm) { ws.close(1008, 'Forbidden'); return; } + const role = isOwner ? 'owner' : perm.role; + + ws.userId = userId; + ws.wbId = wbId; + ws.role = role; + ws.username = db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || 'Unbekannt'; + + // Raum beitreten + if (!rooms.has(wbId)) rooms.set(wbId, new Set()); + rooms.get(wbId).add(ws); + + // Anderen im Raum mitteilen wer jointe + broadcast(wbId, { type: 'user_join', userId, username: ws.username, role }, ws); + + // Aktive User-Liste an neuen Client schicken + const activeUsers = [...rooms.get(wbId)] + .filter(c => c !== ws && c.readyState === 1) + .map(c => ({ userId: c.userId, username: c.username, role: c.role })); + ws.send(JSON.stringify({ type: 'active_users', users: activeUsers })); + + ws.on('message', raw => { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + + switch (msg.type) { + case 'cursor': + // Cursor-Position live broadcasten (nur wenn nicht view-only) + broadcast(wbId, { type:'cursor', userId, username:ws.username, x:msg.x, y:msg.y }, ws); + break; + + case 'elements': + // Canvas-Γ„nderungen von Edit-Berechtigten an alle broadcasten + if (role === 'view') break; + broadcast(wbId, { type:'elements', userId, elements: msg.elements }, ws); + break; + + case 'ping': + ws.send(JSON.stringify({ type: 'pong' })); + break; + } + }); + + ws.on('close', () => { + const room = rooms.get(wbId); + if (room) { + room.delete(ws); + if (room.size === 0) rooms.delete(wbId); + else broadcast(wbId, { type: 'user_leave', userId, username: ws.username }); + } + }); + + ws.on('error', () => ws.terminate()); +}); + +function broadcast(wbId, msg, exclude = null) { + const room = rooms.get(wbId); + if (!room) return; + const data = JSON.stringify(msg); + for (const client of room) { + if (client !== exclude && client.readyState === 1) { + client.send(data); + } + } +} + +server.listen(4000, () => console.log('πŸš€ Dicken Dock lΓ€uft auf Port 4000')); // ── Push-Zeitplaner Hintergrund-Job ────────────────────────────────────────── const db = require('./db'); diff --git a/backend/src/tools/whiteboard/routes.js b/backend/src/tools/whiteboard/routes.js new file mode 100644 index 0000000..1820c1b --- /dev/null +++ b/backend/src/tools/whiteboard/routes.js @@ -0,0 +1,153 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate, requireAdmin } = require('../../middleware/auth'); +const router = express.Router(); + +// ── DB-Migration ────────────────────────────────────────────────────────────── +(function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS whiteboards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT 'Neues Whiteboard', + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + CREATE TABLE IF NOT EXISTS whiteboard_data ( + whiteboard_id INTEGER PRIMARY KEY REFERENCES whiteboards(id) ON DELETE CASCADE, + elements TEXT NOT NULL DEFAULT '[]', + viewport TEXT NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}' + ); + CREATE TABLE IF NOT EXISTS whiteboard_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + whiteboard_id INTEGER NOT NULL REFERENCES whiteboards(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'edit', + UNIQUE(whiteboard_id, user_id) + ); + `); +})(); + +const uid = req => req.user.id; + +// Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurΓΌck +function access(whiteboardId, userId) { + const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId); + if (!wb) return null; + if (wb.owner_id === userId) return 'owner'; + const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(whiteboardId, userId); + return perm?.role || null; +} + +// ── Liste ───────────────────────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const me = uid(req); + // Eigene + geteilte Whiteboards + const own = db.prepare(` + SELECT w.*, 'owner' as role, u.username as owner_name + FROM whiteboards w JOIN users u ON u.id=w.owner_id + WHERE w.owner_id=? ORDER BY w.updated_at DESC + `).all(me); + const shared = db.prepare(` + SELECT w.*, p.role, u.username as owner_name + FROM whiteboards w + JOIN whiteboard_permissions p ON p.whiteboard_id=w.id AND p.user_id=? + JOIN users u ON u.id=w.owner_id + ORDER BY w.updated_at DESC + `).all(me); + res.json({ whiteboards: [...own, ...shared] }); +}); + +// ── Erstellen ───────────────────────────────────────────────────────────────── +router.post('/', authenticate, (req, res) => { + const { title = 'Neues Whiteboard' } = req.body; + const me = uid(req); + const r = db.prepare("INSERT INTO whiteboards (owner_id, title) VALUES (?,?)").run(me, title.trim() || 'Neues Whiteboard'); + db.prepare("INSERT INTO whiteboard_data (whiteboard_id) VALUES (?)").run(r.lastInsertRowid); + res.json(db.prepare('SELECT * FROM whiteboards WHERE id=?').get(r.lastInsertRowid)); +}); + +// ── Umbenennen ──────────────────────────────────────────────────────────────── +router.patch('/:id/title', authenticate, (req, res) => { + const { title } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' }); + const me = uid(req); + const role = access(req.params.id, me); + if (!role || role === 'view') return res.status(403).json({ error: 'Kein Zugriff' }); + db.prepare("UPDATE whiteboards SET title=?, updated_at=datetime('now','localtime') WHERE id=?").run(title.trim(), req.params.id); + res.json({ ok: true }); +}); + +// ── LΓΆschen ─────────────────────────────────────────────────────────────────── +router.delete('/:id', authenticate, (req, res) => { + const me = uid(req); + const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id); + if (!wb) return res.status(404).json({ error: 'Nicht gefunden' }); + if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann lΓΆschen' }); + db.prepare('DELETE FROM whiteboards WHERE id=?').run(wb.id); + res.json({ ok: true }); +}); + +// ── Canvas laden ────────────────────────────────────────────────────────────── +router.get('/:id/data', authenticate, (req, res) => { + const me = uid(req); + const role = access(req.params.id, me); + if (!role) return res.status(403).json({ error: 'Kein Zugriff' }); + const data = db.prepare('SELECT * FROM whiteboard_data WHERE whiteboard_id=?').get(req.params.id); + const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id); + const perms = db.prepare(` + SELECT p.*, u.username FROM whiteboard_permissions p + JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=? + `).all(req.params.id); + const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_id); + res.json({ ...data, role, title: wb.title, owner: owner.username, permissions: perms }); +}); + +// ── Canvas speichern ────────────────────────────────────────────────────────── +// Feste Route vor :id-Routen +router.post('/:id/save', authenticate, (req, res) => { + const me = uid(req); + const role = access(req.params.id, me); + if (!role || role === 'view') return res.status(403).json({ error: 'Kein Schreibzugriff' }); + const { elements, viewport } = req.body; + db.prepare(` + UPDATE whiteboard_data SET elements=?, viewport=? WHERE whiteboard_id=? + `).run(JSON.stringify(elements || []), JSON.stringify(viewport || {x:0,y:0,zoom:1}), req.params.id); + db.prepare("UPDATE whiteboards SET updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id); + res.json({ ok: true }); +}); + +// ── Berechtigungen: alle User fΓΌr Share-Modal ───────────────────────────────── +router.get('/users-list', authenticate, (req, res) => { + const me = uid(req); + const users = db.prepare('SELECT id, username FROM users WHERE id!=? ORDER BY username').all(me); + res.json({ users }); +}); + +// ── Berechtigungen setzen ───────────────────────────────────────────────────── +router.put('/:id/permissions', authenticate, (req, res) => { + const me = uid(req); + const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id); + if (!wb) return res.status(404).json({ error: 'Nicht gefunden' }); + if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann Berechtigungen vergeben' }); + const { permissions } = req.body; // [{user_id, role: 'edit'|'view'|null}] + if (!Array.isArray(permissions)) return res.status(400).json({ error: 'permissions fehlt' }); + + const upsert = db.prepare("INSERT INTO whiteboard_permissions (whiteboard_id,user_id,role) VALUES (?,?,?) ON CONFLICT(whiteboard_id,user_id) DO UPDATE SET role=excluded.role"); + const remove = db.prepare("DELETE FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?"); + const tx = db.transaction(() => { + for (const p of permissions) { + if (!p.user_id) continue; + if (p.role === null || p.role === 'none') remove.run(req.params.id, p.user_id); + else upsert.run(req.params.id, p.user_id, p.role); + } + }); + tx(); + const updated = db.prepare(` + SELECT p.*, u.username FROM whiteboard_permissions p + JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=? + `).all(req.params.id); + res.json({ permissions: updated }); +}); + +module.exports = router; diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx index 5f86327..f124cca 100644 --- a/frontend/src/icons.jsx +++ b/frontend/src/icons.jsx @@ -181,3 +181,8 @@ export const KanbanIcon = ({size=20,color='currentColor',sw}) => base(<> , size, color, sw); +export const WhiteboardIcon = ({size=20,color='currentColor',sw}) => base(<> + + + +, size, color, sw); diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js index d8fcb87..530b914 100644 --- a/frontend/src/toolRegistry.js +++ b/frontend/src/toolRegistry.js @@ -10,7 +10,8 @@ import DevTools from './tools/devtools.jsx'; import Media from './tools/media.jsx'; import PaywallKiller from './tools/paywallkiller.jsx'; import Kanban from './tools/kanban.jsx'; -import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon } from './icons.jsx'; +import Whiteboard from './tools/whiteboard.jsx'; +import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon } from './icons.jsx'; export const TOOLS = [ { @@ -53,6 +54,14 @@ export const TOOLS = [ group: 'Werkzeuge', component: Kanban, }, + { + id: 'whiteboard', + Icon: WhiteboardIcon, + label: 'Whiteboard', + navLabel: 'Whiteboard', + group: 'Werkzeuge', + component: Whiteboard, + }, { id: 'dateien', Icon: DatabaseIcon, diff --git a/frontend/src/tools/whiteboard.jsx b/frontend/src/tools/whiteboard.jsx new file mode 100644 index 0000000..9d6d894 --- /dev/null +++ b/frontend/src/tools/whiteboard.jsx @@ -0,0 +1,695 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { api, S } from '../lib.js'; + +// ── Konstanten ──────────────────────────────────────────────────────────────── +const COLORS = ['#FFFFFF','#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C','#4ADE80','#F87171','#000000']; +const SIZES = [2, 4, 8, 14, 22]; +const TOOLS = [ + { id:'select', label:'β†–', tip:'AuswΓ€hlen' }, + { id:'pen', label:'✏', tip:'Stift' }, + { id:'line', label:'β•±', tip:'Linie' }, + { id:'rect', label:'β–­', tip:'Rechteck' }, + { id:'ellipse', label:'β—―', tip:'Ellipse' }, + { id:'text', label:'T', tip:'Text' }, + { id:'eraser', label:'⌫', tip:'Radierer' }, +]; + +const CURSOR_COLORS = ['#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C']; + +// ── Utility ─────────────────────────────────────────────────────────────────── +const getId = () => Math.random().toString(36).slice(2); + +// ── Share-Modal (außerhalb Hauptkomponente β€” Lesson Learned) ────────────────── +function ShareModal({ wbId, onClose, toast }) { + const [allUsers, setAllUsers] = useState([]); + const [perms, setPerms] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + Promise.all([ + api('/tools/whiteboard/users-list'), + api(`/tools/whiteboard/${wbId}/data`), + ]).then(([u, d]) => { + setAllUsers(u.users || []); + setPerms(d.permissions || []); + }).catch(() => {}); + }, [wbId]); + + const roleOf = (userId) => perms.find(p => p.user_id === userId)?.role || 'none'; + + const setRole = (userId, role) => { + setPerms(prev => { + const filtered = prev.filter(p => p.user_id !== userId); + if (role === 'none') return filtered; + return [...filtered, { user_id: userId, role }]; + }); + }; + + const save = async () => { + setSaving(true); + try { + await api(`/tools/whiteboard/${wbId}/permissions`, { + method: 'PUT', + body: { permissions: allUsers.map(u => ({ user_id: u.id, role: roleOf(u.id) === 'none' ? null : roleOf(u.id) })) }, + }); + toast('Berechtigungen gespeichert βœ“'); + onClose(); + } catch(e) { toast(e.message, 'error'); } + finally { setSaving(false); } + }; + + const mob = window.innerWidth < 768; + return ( +
e.target === e.currentTarget && onClose()} + style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000, + display:'flex', alignItems: mob ? 'flex-end' : 'center', justifyContent:'center', padding: mob ? 0 : 20 }}> +
+
+ {mob &&
} +
+
TEILEN
+ +
+
+
+ {allUsers.length === 0 &&
Keine anderen Benutzer vorhanden.
} + {allUsers.map(u => ( +
+ {u.username} + {['none','view','edit'].map(r => ( + + ))} +
+ ))} +
+
+ + +
+
+
+ ); +} + +// ── Text-Eingabe-Overlay ─────────────────────────────────────────────────────── +function TextInput({ x, y, color, size, onDone }) { + const ref = useRef(null); + useEffect(() => { ref.current?.focus(); }, []); + return ( + { if (e.key === 'Enter') onDone(ref.current.value); if (e.key === 'Escape') onDone(''); }} + onBlur={() => onDone(ref.current?.value || '')} + style={{ position:'absolute', left:x, top:y, background:'transparent', border:'none', + outline:'1px dashed rgba(255,255,255,0.4)', color, fontSize: size * 4, + fontFamily:'monospace', minWidth:100, zIndex:100 }} + /> + ); +} + +// ── Canvas-Renderer ──────────────────────────────────────────────────────────── +function renderElements(ctx, elements, vp) { + ctx.save(); + ctx.translate(vp.x, vp.y); + ctx.scale(vp.zoom, vp.zoom); + ctx.clearRect(-vp.x/vp.zoom, -vp.y/vp.zoom, ctx.canvas.width/vp.zoom, ctx.canvas.height/vp.zoom); + + for (const el of elements) { + ctx.globalAlpha = 1; + ctx.strokeStyle = el.color || '#fff'; + ctx.fillStyle = el.color || '#fff'; + ctx.lineWidth = (el.size || 4) / vp.zoom; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + switch (el.type) { + case 'pen': { + if (!el.points?.length) break; + ctx.beginPath(); + ctx.moveTo(el.points[0].x, el.points[0].y); + for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y); + ctx.stroke(); + break; + } + case 'eraser': { + if (!el.points?.length) break; + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.lineWidth = (el.size || 20) / vp.zoom; + ctx.beginPath(); + ctx.moveTo(el.points[0].x, el.points[0].y); + for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y); + ctx.stroke(); + ctx.restore(); + break; + } + case 'line': { + ctx.beginPath(); + ctx.moveTo(el.x1, el.y1); + ctx.lineTo(el.x2, el.y2); + ctx.stroke(); + break; + } + case 'rect': { + ctx.strokeRect(el.x, el.y, el.w, el.h); + break; + } + case 'ellipse': { + ctx.beginPath(); + ctx.ellipse(el.cx, el.cy, Math.abs(el.rx), Math.abs(el.ry), 0, 0, Math.PI * 2); + ctx.stroke(); + break; + } + case 'text': { + ctx.font = `${(el.size || 4) * 4}px monospace`; + ctx.fillText(el.text, el.x, el.y); + break; + } + } + } + ctx.restore(); +} + +// ── Haupt-Whiteboard-Komponente ──────────────────────────────────────────────── +export default function Whiteboard({ toast, mobile }) { + // ── View-State ──────────────────────────────────────────────────────────── + const [view, setView] = useState('list'); // 'list' | 'canvas' + const [boards, setBoards] = useState([]); + const [loading, setLoading] = useState(true); + const [current, setCurrent] = useState(null); // { id, title, role } + + // ── Canvas-State ────────────────────────────────────────────────────────── + const [elements, setElements] = useState([]); + const [undoStack, setUndoStack] = useState([]); + const [tool, setTool] = useState('pen'); + const [color, setColor] = useState('#FFFFFF'); + const [size, setSize] = useState(1); // Index in SIZES + const [viewport, setViewport] = useState({ x:0, y:0, zoom:1 }); + const [saving, setSaving] = useState(false); + const [shareModal, setShareModal] = useState(false); + const [textInput, setTextInput] = useState(null); // {x,y} | null + + // ── Kollaboration ───────────────────────────────────────────────────────── + const [collab, setCollab] = useState([]); // [{userId, username, x, y, color}] + const wsRef = useRef(null); + const cursorTimers = useRef({}); + + // ── Canvas-Refs ─────────────────────────────────────────────────────────── + const canvasRef = useRef(null); + const drawing = useRef(false); + const currentEl = useRef(null); + const startPos = useRef({ x:0, y:0 }); + const isPanning = useRef(false); + const panStart = useRef({ x:0, y:0 }); + const vpRef = useRef({ x:0, y:0, zoom:1 }); + const elementsRef = useRef([]); + + // Hooks immer vor Early Returns (Lesson Learned) + const loadBoards = useCallback(() => { + setLoading(true); + api('/tools/whiteboard') + .then(d => setBoards(d.whiteboards || [])) + .catch(() => toast('Fehler beim Laden', 'error')) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { loadBoards(); }, [loadBoards]); + + // Canvas rendern wenn sich Elemente oder Viewport Γ€ndern + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + renderElements(ctx, elementsRef.current, vpRef.current); + // Cursor anderer User zeichnen + for (const c of collab) { + const sx = c.x * vpRef.current.zoom + vpRef.current.x; + const sy = c.y * vpRef.current.zoom + vpRef.current.y; + ctx.save(); + ctx.fillStyle = c.color; + ctx.font = '10px monospace'; + ctx.fillText('β–Ά ' + c.username, sx + 4, sy - 4); + ctx.restore(); + } + }, [elements, viewport, collab]); + + // ── WebSocket aufbauen ──────────────────────────────────────────────────── + const connectWs = useCallback((wbId) => { + const token = localStorage.getItem('sk_token'); + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/whiteboard?token=${token}&id=${wbId}`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + const collabColors = {}; + let colorIdx = 0; + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + switch (msg.type) { + case 'user_join': + if (!collabColors[msg.userId]) collabColors[msg.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length]; + setCollab(prev => [...prev.filter(c => c.userId !== msg.userId), { userId: msg.userId, username: msg.username, color: collabColors[msg.userId], x:0, y:0 }]); + break; + case 'user_leave': + setCollab(prev => prev.filter(c => c.userId !== msg.userId)); + break; + case 'active_users': + for (const u of msg.users) { + if (!collabColors[u.userId]) collabColors[u.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length]; + } + setCollab(msg.users.map(u => ({ ...u, color: collabColors[u.userId] || CURSOR_COLORS[0], x:0, y:0 }))); + break; + case 'cursor': + if (!collabColors[msg.userId]) collabColors[msg.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length]; + setCollab(prev => prev.map(c => c.userId === msg.userId ? { ...c, x: msg.x, y: msg.y } : c)); + // Cursor nach 3s ausblenden + clearTimeout(cursorTimers.current[msg.userId]); + cursorTimers.current[msg.userId] = setTimeout(() => { + setCollab(prev => prev.map(c => c.userId === msg.userId ? { ...c, x:-999, y:-999 } : c)); + }, 3000); + break; + case 'elements': + // Canvas von Mitarbeiter aktualisieren + elementsRef.current = msg.elements; + setElements([...msg.elements]); + break; + } + }; + ws.onclose = () => setCollab([]); + ws.onerror = () => {}; + }, []); + + const disconnectWs = useCallback(() => { + wsRef.current?.close(); + wsRef.current = null; + setCollab([]); + }, []); + + // ── Whiteboard ΓΆffnen ───────────────────────────────────────────────────── + const openBoard = async (board) => { + try { + const d = await api(`/tools/whiteboard/${board.id}/data`); + const els = JSON.parse(typeof d.elements === 'string' ? d.elements : JSON.stringify(d.elements || [])); + const vp = JSON.parse(typeof d.viewport === 'string' ? d.viewport : JSON.stringify(d.viewport || {x:0,y:0,zoom:1})); + elementsRef.current = els; + vpRef.current = vp; + setElements(els); + setViewport(vp); + setUndoStack([]); + setCurrent({ id: board.id, title: d.title, role: d.role }); + setView('canvas'); + connectWs(board.id); + } catch(e) { toast(e.message, 'error'); } + }; + + // ── Whiteboard schließen ────────────────────────────────────────────────── + const closeBoard = () => { + disconnectWs(); + setView('list'); + setCurrent(null); + setElements([]); + setUndoStack([]); + setCollab([]); + loadBoards(); + }; + + // ── Canvas-Koordinaten ──────────────────────────────────────────────────── + const toCanvas = (e) => { + const rect = canvasRef.current.getBoundingClientRect(); + const cx = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left; + const cy = (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - rect.top; + return { + x: (cx - vpRef.current.x) / vpRef.current.zoom, + y: (cy - vpRef.current.y) / vpRef.current.zoom, + sx: cx, sy: cy, + }; + }; + + // ── Zeichnen ────────────────────────────────────────────────────────────── + const pushElement = (el) => { + const next = [...elementsRef.current, el]; + elementsRef.current = next; + setUndoStack(prev => [...prev, elementsRef.current.slice(0, -1)]); + setElements([...next]); + wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: next })); + }; + + const updateLastElement = (el) => { + const next = [...elementsRef.current.slice(0, -1), el]; + elementsRef.current = next; + setElements([...next]); + }; + + const finalizeLastElement = (el) => { + const next = [...elementsRef.current.slice(0, -1), el]; + elementsRef.current = next; + setElements([...next]); + wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: next })); + }; + + const undo = () => { + if (!undoStack.length) return; + const prev = undoStack[undoStack.length - 1]; + setUndoStack(s => s.slice(0, -1)); + elementsRef.current = prev; + setElements([...prev]); + wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: prev })); + }; + + // ── Pointer Events ──────────────────────────────────────────────────────── + const onPointerDown = (e) => { + if (current?.role === 'view') return; + e.preventDefault(); + const { x, y, sx, sy } = toCanvas(e); + + // Panning mit Mittelklick oder Space+Drag + if (e.button === 1 || tool === 'select') { + isPanning.current = true; + panStart.current = { x: sx - vpRef.current.x, y: sy - vpRef.current.y }; + return; + } + + if (tool === 'text') { + const rect = canvasRef.current.getBoundingClientRect(); + setTextInput({ x: sx + rect.left, y: sy + rect.top - SIZES[size] * 2 }); + return; + } + + drawing.current = true; + startPos.current = { x, y }; + + if (tool === 'pen' || tool === 'eraser') { + const el = { id: getId(), type: tool, color, size: SIZES[size], points: [{ x, y }] }; + currentEl.current = el; + pushElement(el); + } else if (tool === 'line') { + const el = { id: getId(), type: 'line', color, size: SIZES[size], x1:x, y1:y, x2:x, y2:y }; + currentEl.current = el; + pushElement(el); + } else if (tool === 'rect') { + const el = { id: getId(), type: 'rect', color, size: SIZES[size], x, y, w:0, h:0 }; + currentEl.current = el; + pushElement(el); + } else if (tool === 'ellipse') { + const el = { id: getId(), type: 'ellipse', color, size: SIZES[size], cx:x, cy:y, rx:0, ry:0 }; + currentEl.current = el; + pushElement(el); + } + }; + + const onPointerMove = (e) => { + const { x, y, sx, sy } = toCanvas(e); + + // Cursor per WS senden (gedrosselt) + if (wsRef.current?.readyState === 1) { + wsRef.current.send(JSON.stringify({ type:'cursor', x, y })); + } + + if (isPanning.current) { + const nx = sx - panStart.current.x; + const ny = sy - panStart.current.y; + vpRef.current = { ...vpRef.current, x: nx, y: ny }; + setViewport({ ...vpRef.current }); + return; + } + + if (!drawing.current || !currentEl.current) return; + const el = currentEl.current; + + if (tool === 'pen' || tool === 'eraser') { + const updated = { ...el, points: [...el.points, { x, y }] }; + currentEl.current = updated; + updateLastElement(updated); + } else if (tool === 'line') { + const updated = { ...el, x2: x, y2: y }; + currentEl.current = updated; + updateLastElement(updated); + } else if (tool === 'rect') { + const updated = { ...el, w: x - el.x, h: y - el.y }; + currentEl.current = updated; + updateLastElement(updated); + } else if (tool === 'ellipse') { + const updated = { ...el, rx: (x - el.cx), ry: (y - el.cy) }; + currentEl.current = updated; + updateLastElement(updated); + } + }; + + const onPointerUp = () => { + if (isPanning.current) { isPanning.current = false; return; } + if (!drawing.current) return; + drawing.current = false; + if (currentEl.current) { + finalizeLastElement(currentEl.current); + currentEl.current = null; + } + }; + + // Zoom mit Mausrad + const onWheel = (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const rect = canvasRef.current.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const newZoom = Math.max(0.1, Math.min(5, vpRef.current.zoom * delta)); + const nx = mx - (mx - vpRef.current.x) * (newZoom / vpRef.current.zoom); + const ny = my - (my - vpRef.current.y) * (newZoom / vpRef.current.zoom); + vpRef.current = { x: nx, y: ny, zoom: newZoom }; + setViewport({ ...vpRef.current }); + }; + + // Canvas-Grâße anpassen + useEffect(() => { + if (view !== 'canvas') return; + const resize = () => { + const c = canvasRef.current; + if (!c) return; + c.width = c.offsetWidth; + c.height = c.offsetHeight; + renderElements(c.getContext('2d'), elementsRef.current, vpRef.current); + }; + resize(); + window.addEventListener('resize', resize); + return () => window.removeEventListener('resize', resize); + }, [view]); + + // Speichern + const save = async () => { + if (saving) return; + setSaving(true); + try { + await api(`/tools/whiteboard/${current.id}/save`, { + body: { elements: elementsRef.current, viewport: vpRef.current }, + }); + toast('Gespeichert βœ“'); + } catch(e) { toast(e.message, 'error'); } + finally { setSaving(false); } + }; + + // TastenkΓΌrzel + useEffect(() => { + if (view !== 'canvas') return; + const onKey = (e) => { + if (e.target.tagName === 'INPUT') return; + if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); } + if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save(); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [view, undoStack]); + + // ── Render: Liste ────────────────────────────────────────────────────────── + if (view === 'list') return ( +
+
+

+ WHITEBOARD +

+
+ +
+ + {loading &&
LΓ€dt…
} + + {!loading && boards.length === 0 && ( +
+ Noch kein Whiteboard. Erstelle eines mit "+ Neu". +
+ )} + + {boards.map(b => ( +
openBoard(b)}> +
πŸ–Š
+
+
{b.title}
+
+ {b.role === 'owner' ? 'πŸ‘‘ Eigenes' : b.role === 'edit' ? '✏ Bearbeiten' : 'πŸ‘ Lesen'} + {b.owner_name && b.role !== 'owner' && ` Β· von ${b.owner_name}`} + {' Β· ' + new Date(b.updated_at).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })} +
+
+ {b.role === 'owner' && ( + + )} +
+ ))} +
+ ); + + // ── Render: Canvas ───────────────────────────────────────────────────────── + const canEdit = current?.role !== 'view'; + + return ( +
+ + {/* Share-Modal */} + {shareModal && current?.role === 'owner' && ( + setShareModal(false)} toast={toast}/> + )} + + {/* Text-Eingabe */} + {textInput && ( + { + setTextInput(null); + if (text?.trim()) { + const { x, y } = toCanvas({ clientX: textInput.x, clientY: textInput.y + SIZES[size] * 2 }); + pushElement({ id: getId(), type:'text', color, size, text, x, y }); + } + }} + /> + )} + + {/* ── Top-Bar ──────────────────────────────────────────────────────── */} +
+ + + {current?.title} + {current?.role === 'view' && πŸ‘ nur lesen} + + + {/* Aktive User */} + {collab.length > 0 && ( +
+ {collab.map(c => ( + + {c.username[0].toUpperCase()} + + ))} +
+ )} + + {canEdit && } + {current?.role === 'owner' && ( + + )} +
+ + {/* ── Toolbar ──────────────────────────────────────────────────────── */} + {canEdit && ( +
+ + {/* Werkzeuge */} +
+ {TOOLS.map(t => ( + + ))} +
+ +
+ + {/* Farben */} +
+ {COLORS.map(c => ( +
+ +
+ + {/* StrichstΓ€rke */} +
+ {SIZES.map((s, i) => ( + + ))} +
+ +
+ + {/* Undo + LΓΆschen */} + + + + {/* Zoom-Info */} + + {Math.round(viewport.zoom * 100)}% + + +
+ )} + + {/* ── Canvas ───────────────────────────────────────────────────────── */} + +
+ ); +}