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 FONT_SIZES = [12, 16, 20, 28, 40, 56]; const TOOLS = [ { id:'select', label:'↖', tip:'Auswählen / Verschieben' }, { 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']; const BG = '#0D0D0F'; const getId = () => Math.random().toString(36).slice(2); const HANDLE_R = 6; // Resize-Handle Radius in Screen-Px // ── Bounding-Box-Berechnung ─────────────────────────────────────────────────── function getBounds(el) { switch (el.type) { case 'pen': case 'eraser': { if (!el.points?.length) return null; const xs = el.points.map(p => p.x), ys = el.points.map(p => p.y); const pad = (el.size||4) * (el.type==='eraser'?3:1); return { x: Math.min(...xs)-pad, y: Math.min(...ys)-pad, w: Math.max(...xs)-Math.min(...xs)+pad*2, h: Math.max(...ys)-Math.min(...ys)+pad*2 }; } case 'line': { const pad = el.size||4; return { x: Math.min(el.x1,el.x2)-pad, y: Math.min(el.y1,el.y2)-pad, w: Math.abs(el.x2-el.x1)+pad*2, h: Math.abs(el.y2-el.y1)+pad*2 }; } case 'rect': return { x: Math.min(el.x,el.x+el.w), y: Math.min(el.y,el.y+el.h), w: Math.abs(el.w), h: Math.abs(el.h) }; case 'ellipse': return { x: el.cx-Math.abs(el.rx), y: el.cy-Math.abs(el.ry), w: Math.abs(el.rx)*2, h: Math.abs(el.ry)*2 }; case 'text': { const fs = el.fontSize || 20; const w = (el.text?.length || 4) * fs * 0.6; return { x: el.x, y: el.y - fs, w, h: fs * 1.3 }; } default: return null; } } // Punkt-in-Bounds Hit-Test function hitTest(el, cx, cy) { const b = getBounds(el); if (!b) return false; return cx >= b.x && cx <= b.x+b.w && cy >= b.y && cy <= b.y+b.h; } // ── Element verschieben ─────────────────────────────────────────────────────── function moveElement(el, dx, dy) { switch (el.type) { case 'pen': case 'eraser': return { ...el, points: el.points.map(p => ({ x: p.x+dx, y: p.y+dy })) }; case 'line': return { ...el, x1: el.x1+dx, y1: el.y1+dy, x2: el.x2+dx, y2: el.y2+dy }; case 'rect': return { ...el, x: el.x+dx, y: el.y+dy }; case 'ellipse': return { ...el, cx: el.cx+dx, cy: el.cy+dy }; case 'text': return { ...el, x: el.x+dx, y: el.y+dy }; default: return el; } } // ── Element skalieren (Resize via Ecke rechts-unten) ───────────────────────── function resizeElement(el, nx, ny) { const b = getBounds(el); if (!b) return el; const scaleX = b.w > 1 ? Math.max(0.05, (nx - b.x) / b.w) : 1; const scaleY = b.h > 1 ? Math.max(0.05, (ny - b.y) / b.h) : 1; switch (el.type) { case 'pen': case 'eraser': return { ...el, points: el.points.map(p => ({ x: b.x + (p.x-b.x)*scaleX, y: b.y + (p.y-b.y)*scaleY })) }; case 'line': return { ...el, x1: b.x+(el.x1-b.x)*scaleX, y1: b.y+(el.y1-b.y)*scaleY, x2: b.x+(el.x2-b.x)*scaleX, y2: b.y+(el.y2-b.y)*scaleY }; case 'rect': return { ...el, w: el.w * scaleX, h: el.h * scaleY }; case 'ellipse': return { ...el, rx: el.rx * scaleX, ry: el.ry * scaleY }; case 'text': return { ...el, fontSize: Math.max(8, Math.round((el.fontSize||20) * ((scaleX+scaleY)/2))) }; default: return el; } } // ── Canvas rendern ──────────────────────────────────────────────────────────── function renderElements(ctx, elements, vp, selectedId, forExport) { const W = ctx.canvas.width, H = ctx.canvas.height; ctx.save(); // Hintergrund ctx.fillStyle = BG; ctx.fillRect(0, 0, W, H); ctx.translate(vp.x, vp.y); ctx.scale(vp.zoom, vp.zoom); for (const el of elements) { ctx.save(); 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.strokeStyle = BG; ctx.lineWidth = (el.size||4) * 3 / 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(); 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||1), Math.abs(el.ry||1), 0, 0, Math.PI*2); ctx.stroke(); break; case 'text': { const fs = el.fontSize || 20; ctx.font = `${fs}px monospace`; ctx.fillText(el.text, el.x, el.y); break; } } ctx.restore(); // Auswahl-Rahmen + Resize-Handle if (!forExport && selectedId === el.id) { const b = getBounds(el); if (b) { ctx.save(); ctx.strokeStyle = '#4ECDC4'; ctx.lineWidth = 1.5 / vp.zoom; ctx.setLineDash([4/vp.zoom, 3/vp.zoom]); ctx.strokeRect(b.x-2/vp.zoom, b.y-2/vp.zoom, b.w+4/vp.zoom, b.h+4/vp.zoom); ctx.setLineDash([]); // Resize-Handle rechts-unten const hx = b.x+b.w, hy = b.y+b.h, hr = HANDLE_R/vp.zoom; ctx.fillStyle = '#4ECDC4'; ctx.beginPath(); ctx.arc(hx, hy, hr, 0, Math.PI*2); ctx.fill(); ctx.restore(); } } } ctx.restore(); } // Offscreen-Export (ohne Auswahl-Rahmen, original Viewport) function exportToPng(elements, title) { if (!elements?.length) return; // Nichts zu exportieren let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity; for (const el of elements) { const b = getBounds(el); if (!b) continue; minX=Math.min(minX,b.x); minY=Math.min(minY,b.y); maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h); } if (!isFinite(minX)) { minX=0; minY=0; maxX=800; maxY=600; } const pad=40, W=Math.max(800,maxX-minX+pad*2), H=Math.max(600,maxY-minY+pad*2); const offscreen = document.createElement('canvas'); offscreen.width=W; offscreen.height=H; const ctx = offscreen.getContext('2d'); renderElements(ctx, elements, { x: -minX+pad, y: -minY+pad, zoom:1 }, null, true); const a = document.createElement('a'); a.href = offscreen.toDataURL('image/png'); a.download = (title||'whiteboard').replace(/[^\w\s-]/g,'').trim()+'.png'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } // ── Share-Modal ─────────────────────────────────────────────────────────────── 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 = uid => perms.find(p=>p.user_id===uid)?.role||'none'; const setRole = (uid, role) => setPerms(prev => { const f = prev.filter(p=>p.user_id!==uid); return role==='none' ? f : [...f,{user_id:uid,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.
} {allUsers.map(u=>(
{u.username} {['none','view','edit'].map(r=>( ))}
))}
); } // ── Hauptkomponente ─────────────────────────────────────────────────────────── export default function Whiteboard({ toast, mobile }) { // View const [view, setView] = useState('list'); const [boards, setBoards] = useState([]); const [unreadIds, setUnreadIds] = useState(new Set()); const [loading, setLoading] = useState(true); const [current, setCurrent] = useState(null); const [creating, setCreating] = useState(false); const [newTitle, setNewTitle] = useState(''); const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(''); // Canvas const [elements, setElements] = useState([]); const [undoStack, setUndoStack] = useState([]); const [tool, setTool] = useState('pen'); const [color, setColor] = useState('#FFFFFF'); const [size, setSize] = useState(1); const [fontSize, setFontSize] = useState(2); // Index in FONT_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); const [textPreview, setTextPreview] = useState(null); // Live-Vorschau während Tippen const [selectedId,setSelectedId]= useState(null); // Kollaboration const [collab, setCollab] = useState([]); 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 isDragging = useRef(false); // Element verschieben const isResizing = useRef(false); // Element resize const panStart = useRef({x:0,y:0}); const dragStart = useRef({x:0,y:0}); // Startpos für Drag const vpRef = useRef({x:0,y:0,zoom:1}); const elementsRef = useRef([]); const selectedRef = useRef(null); const textPreviewRef = useRef(null); // ── Hilfsfunktionen ────────────────────────────────────────────────────── const syncVp = (vp) => { vpRef.current=vp; setViewport({...vp}); }; const toCanvas = (e) => { const rect = canvasRef.current.getBoundingClientRect(); const sx = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left; const sy = (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - rect.top; return { x:(sx-vpRef.current.x)/vpRef.current.zoom, y:(sy-vpRef.current.y)/vpRef.current.zoom, sx, sy }; }; // Ist der Pointer auf dem Resize-Handle des selektierten Elements? const onResizeHandle = (cx, cy) => { if (!selectedRef.current) return false; const el = elementsRef.current.find(e=>e.id===selectedRef.current); if (!el) return false; const b = getBounds(el); if (!b) return false; const hxS = (b.x+b.w)*vpRef.current.zoom + vpRef.current.x; const hyS = (b.y+b.h)*vpRef.current.zoom + vpRef.current.y; const rect = canvasRef.current.getBoundingClientRect(); const mxS = cx + rect.left - rect.left; const myS = cy + rect.top - rect.top; // cx/cy sind schon in Screen-Koordinaten (sx,sy aus toCanvas) return Math.hypot(cx - (b.x+b.w)*vpRef.current.zoom - vpRef.current.x, cy - (b.y+b.h)*vpRef.current.zoom - vpRef.current.y) <= HANDLE_R + 4; }; // ── Element-Operationen ────────────────────────────────────────────────── const broadcast = (els) => { wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'elements',elements:els})); }; const pushElement = (el) => { const next = [...elementsRef.current, el]; elementsRef.current = next; setUndoStack(prev => [...prev, elementsRef.current.slice(0,-1)]); setElements([...next]); broadcast(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]); broadcast(next); }; const updateElement = (id, updater) => { const next = elementsRef.current.map(e => e.id===id ? updater(e) : e); elementsRef.current = next; setElements([...next]); broadcast(next); setUndoStack(prev => [...prev, elementsRef.current]); }; const undo = () => { if (!undoStack.length) return; const prev = undoStack[undoStack.length-1]; setUndoStack(s => s.slice(0,-1)); elementsRef.current = prev; setElements([...prev]); broadcast(prev); setSelectedId(null); selectedRef.current=null; }; // ── Laden / Speichern ──────────────────────────────────────────────────── const loadBoards = useCallback(() => { setLoading(true); Promise.all([ api('/tools/whiteboard'), api('/tools/whiteboard/unread').catch(() => ({ count: 0 })), ]).then(([d, u]) => { setBoards(d.whiteboards || []); // unread count kommt global — wir markieren alle als "möglicherweise unread" // getGameForUser gibt uns keine IDs, also nutzen wir den globalen count als Indikator }).catch(() => toast('Fehler', 'error')).finally(() => setLoading(false)); // Einzelne unread IDs vom Backend holen api('/tools/whiteboard/unread-ids').then(d => setUnreadIds(new Set(d.ids || []))).catch(() => {}); }, []); const renameBoard = async (id, title) => { if (!title?.trim()) return; try { await api(`/tools/whiteboard/${id}/title`,{method:'PATCH',body:{title:title.trim()}}); setBoards(prev=>prev.map(b=>b.id===id?{...b,title:title.trim()}:b)); if (current?.id===id) setCurrent(prev=>({...prev,title:title.trim()})); } catch(e){ toast(e.message,'error'); } finally { setEditingId(null); } }; useEffect(()=>{ loadBoards(); },[loadBoards]); const openBoard = async (board) => { setUnreadIds(prev => { const s = new Set(prev); s.delete(board.id); return s; }); 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'); } }; const closeBoard = () => { disconnectWs(); setView('list'); setCurrent(null); setElements([]); setUndoStack([]); setCollab([]); setSelectedId(null); selectedRef.current=null; loadBoards(); }; 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); } }; // ── WebSocket ───────────────────────────────────────────────────────────── const connectWs = useCallback((wbId) => { const token = localStorage.getItem('sk_token'); const proto = location.protocol==='https:'?'wss':'ws'; const ws = new WebSocket(`${proto}://${location.host}/ws/whiteboard?token=${token}&id=${wbId}`); wsRef.current = ws; const cc = {}; let ci=0; ws.onmessage = (e) => { const msg = JSON.parse(e.data); switch(msg.type) { case 'user_join': if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length]; setCollab(prev=>[...prev.filter(c=>c.userId!==msg.userId),{userId:msg.userId,username:msg.username,color:cc[msg.userId],x:0,y:0}]); break; case 'user_leave': setCollab(prev=>prev.filter(c=>c.userId!==msg.userId)); break; case 'active_users': msg.users.forEach(u=>{ if(!cc[u.userId]) cc[u.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length]; }); setCollab(msg.users.map(u=>({...u,color:cc[u.userId]||CURSOR_COLORS[0],x:0,y:0}))); break; case 'cursor': if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length]; setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:msg.x,y:msg.y}:c)); clearTimeout(cursorTimers.current[msg.userId]); cursorTimers.current[msg.userId]=setTimeout(()=>setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:-9999,y:-9999}:c)),3000); break; case 'elements': elementsRef.current=msg.elements; setElements([...msg.elements]); break; } }; ws.onclose=()=>setCollab([]); ws.onerror=()=>{}; },[]); const disconnectWs = useCallback(()=>{ wsRef.current?.close(); wsRef.current=null; setCollab([]); },[]); // ── Canvas-Rendering ────────────────────────────────────────────────────── const render = useCallback(() => { const canvas = canvasRef.current; if(!canvas) return; const ctx = canvas.getContext('2d'); renderElements(ctx, elementsRef.current, vpRef.current, selectedRef.current, false); // Cursor anderer User for (const c of collab) { if (c.x===-9999) continue; 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+6, sy-4); ctx.restore(); } // Text-Vorschau während Tippen if (textPreviewRef.current) { const { text, x, y, fontSize, color } = textPreviewRef.current; const vp = vpRef.current; const sx = x * vp.zoom + vp.x; const sy = y * vp.zoom + vp.y; ctx.save(); ctx.font = `${fontSize * vp.zoom}px monospace`; ctx.fillStyle = color; ctx.globalAlpha = 0.85; ctx.fillText(text || '|', sx, sy); // Cursor-Blinken simulieren: kleiner Strich hinter dem Text if (text) { const tw = ctx.measureText(text).width; ctx.fillRect(sx + tw + 1, sy - fontSize * vp.zoom * 0.8, 2, fontSize * vp.zoom); } ctx.restore(); } }, [collab, textPreview]); useEffect(()=>{ render(); }, [elements, viewport, selectedId, collab, render]); // Canvas-Resize useEffect(()=>{ if(view!=='canvas') return; const resize=()=>{ const c=canvasRef.current; if(!c) return; c.width=c.offsetWidth; c.height=c.offsetHeight; render(); }; resize(); window.addEventListener('resize',resize); return ()=>window.removeEventListener('resize',resize); },[view, render]); // ── Pointer-Events ──────────────────────────────────────────────────────── const onPointerDown = (e) => { if (current?.role==='view') return; e.preventDefault(); const { x, y, sx, sy } = toCanvas(e); // Panning: Mittelklick oder Leerzeichen-Drag (über CSS cursor:grab bei select-tool) if (e.button===1) { isPanning.current=true; panStart.current={ x: sx-vpRef.current.x, y: sy-vpRef.current.y }; return; } // Select-Tool if (tool==='select') { // Resize-Handle? if (onResizeHandle(sx, sy)) { isResizing.current=true; dragStart.current={x,y}; return; } // Element treffen? const hit = [...elementsRef.current].reverse().find(el=>hitTest(el,x,y)); if (hit) { selectedRef.current=hit.id; setSelectedId(hit.id); isDragging.current=true; dragStart.current={x,y}; return; } // Leerer Klick → Auswahl aufheben selectedRef.current=null; setSelectedId(null); return; } // Text if (tool==='text') { textPreviewRef.current = { text:'', x, y, fontSize:FONT_SIZES[fontSize], color }; render(); setTextInput({ sx, sy, x, y }); 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); wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'cursor',x,y})); if (isPanning.current) { syncVp({ ...vpRef.current, x: sx-panStart.current.x, y: sy-panStart.current.y }); return; } if (isDragging.current && selectedRef.current) { const dx=x-dragStart.current.x, dy=y-dragStart.current.y; dragStart.current={x,y}; const next=elementsRef.current.map(el=>el.id===selectedRef.current?moveElement(el,dx,dy):el); elementsRef.current=next; setElements([...next]); return; } if (isResizing.current && selectedRef.current) { const next=elementsRef.current.map(el=>el.id===selectedRef.current?resizeElement(el,x,y):el); elementsRef.current=next; setElements([...next]); 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 = () => { isPanning.current=false; if (isDragging.current||isResizing.current) { broadcast(elementsRef.current); setUndoStack(prev=>[...prev, elementsRef.current]); } isDragging.current=false; isResizing.current=false; if (!drawing.current) return; drawing.current=false; if (currentEl.current) { finalizeLastElement(currentEl.current); currentEl.current=null; } }; 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, my=e.clientY-rect.top; const nz=Math.max(0.1,Math.min(8, vpRef.current.zoom*delta)); syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom), y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz }); }; const zoom = (factor) => { const nz=Math.max(0.1,Math.min(8,vpRef.current.zoom*factor)); const canvas=canvasRef.current; if(!canvas) return; const mx=canvas.width/2, my=canvas.height/2; syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom), y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz }); }; // Tastenkürzel useEffect(()=>{ if(view!=='canvas') return; const onKey=(e)=>{ if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return; if((e.ctrlKey||e.metaKey)&&e.key==='z'){ e.preventDefault(); undo(); } if((e.ctrlKey||e.metaKey)&&e.key==='s'){ e.preventDefault(); save(); } if((e.ctrlKey||e.metaKey)&&(e.key==='='||e.key==='+')){ e.preventDefault(); zoom(1.2); } if((e.ctrlKey||e.metaKey)&&e.key==='-'){ e.preventDefault(); zoom(0.8); } if((e.ctrlKey||e.metaKey)&&e.key==='0'){ e.preventDefault(); syncVp({x:0,y:0,zoom:1}); } if(e.key==='Escape'){ setSelectedId(null); selectedRef.current=null; } if((e.key==='Delete'||e.key==='Backspace')&&selectedRef.current&&e.target.tagName!=='INPUT'){ e.preventDefault(); const next=elementsRef.current.filter(el=>el.id!==selectedRef.current); setUndoStack(prev=>[...prev,elementsRef.current]); elementsRef.current=next; setElements([...next]); broadcast(next); setSelectedId(null); selectedRef.current=null; } }; window.addEventListener('keydown',onKey); return ()=>window.removeEventListener('keydown',onKey); },[view, undoStack]); // ── Cursor-Style ────────────────────────────────────────────────────────── const getCursor = () => { if (tool==='select') return isDragging.current?'grabbing':isResizing.current?'nwse-resize':'default'; if (tool==='eraser') return 'cell'; if (tool==='text') return 'text'; return 'crosshair'; }; // ── Render: Liste ───────────────────────────────────────────────────────── if (view==='list') return (

WHITEBOARD

{creating ? (
setNewTitle(e.target.value)} onKeyDown={async e=>{ if(e.key==='Enter'&&newTitle.trim()){ try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); } catch(err){ toast(err.message,'error'); } } if(e.key==='Escape'){setCreating(false);setNewTitle('');} }} placeholder="Name des Whiteboards" style={{...S.inp,width:mobile?160:220,fontSize:12}}/>
) : ( )}
{loading&&
Lädt…
} {!loading&&boards.length===0&&(
Noch kein Whiteboard. Erstelle eines mit "+ Neu".
)} {boards.map(b=>(
{ if(editingId!==b.id) openBoard(b); }}>
🖊 {unreadIds.has(b.id) && ( )}
{editingId===b.id?( setEditTitle(e.target.value)} onKeyDown={e=>{if(e.key==='Enter') renameBoard(b.id,editTitle); if(e.key==='Escape') setEditingId(null);}} onBlur={()=>renameBoard(b.id,editTitle)} onClick={e=>e.stopPropagation()} style={{...S.inp,fontSize:12,fontWeight:700,padding:'3px 8px',width:'100%'}}/> ):(
{e.stopPropagation();if(b.role==='owner'||b.role==='edit'){setEditingId(b.id);setEditTitle(b.title);}}}> {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'||b.role==='edit')&&( )} {b.role==='owner'&&( )}
))}
); // ── Render: Canvas ──────────────────────────────────────────────────────── const canEdit = current?.role !== 'view'; return (
{shareModal&¤t?.role==='owner'&&( setShareModal(false)} toast={toast}/> )} {textInput&&( mobile ? (
TEXT EINGEBEN · Enter = bestätigen · Esc = abbrechen
{ textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color}; render(); }} onKeyDown={e=>{ if(e.key==='Enter'){ const v=e.target.value; textPreviewRef.current=null; setTextInput(null); if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y}); } if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); } }} placeholder="Text eingeben…" style={{...S.inp,flex:1,fontSize:14,color}}/>
) : (
{ textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color}; render(); }} onKeyDown={e=>{ if(e.key==='Enter'){ const v=e.target.value; textPreviewRef.current=null; setTextInput(null); if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y}); } if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); } }} onBlur={e=>{ const v=e.target.value; textPreviewRef.current=null; setTextInput(null); if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y}); }} style={{background:'rgba(0,0,0,0.7)',border:'none', outline:`1px dashed ${color}`,borderRadius:4,padding:'2px 6px', color,fontSize:FONT_SIZES[fontSize],fontFamily:'monospace',minWidth:120}}/>
) )} {/* ── Topbar ───────────────────────────────────────────────────────── */}
{editingId===current?.id?( setEditTitle(e.target.value)} onKeyDown={e=>{if(e.key==='Enter') renameBoard(current.id,editTitle); if(e.key==='Escape') setEditingId(null);}} onBlur={()=>renameBoard(current.id,editTitle)} style={{...S.inp,fontSize:13,fontWeight:700,flex:1,padding:'3px 8px'}}/> ):( {if(current?.role!=='view'){setEditingId(current.id);setEditTitle(current.title);}}} title={current?.role!=='view'?'Doppelklick zum Umbenennen':''} style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,flex:1,minWidth:0, overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',cursor:current?.role!=='view'?'text':'default'}}> {current?.title} {current?.role==='view'&&👁 nur lesen} )} {collab.length>0&&(
{collab.map(c=>( {c.username[0].toUpperCase()} ))}
)} {canEdit&&} {current?.role==='owner'&&( )}
{/* ── Toolbar ──────────────────────────────────────────────────────── */} {canEdit&&(
{/* Tools */}
{TOOLS.map(t=>( ))}
{/* Farben */}
{COLORS.map(c=>(
{/* Strichstärke */}
{SIZES.map((s,i)=>( ))}
{/* Textgröße — nur bei Text-Tool */} {tool==='text'&&<>
TEXT
{FONT_SIZES.map((fs,i)=>( ))}
}
{/* Undo + Löschen */} {selectedId&&( )}
{/* Zoom */} {Math.round(viewport.zoom*100)}%
)} {/* ── Canvas ───────────────────────────────────────────────────────── */} {/* Hinweis: Select-Tool Aktionen */} {tool==='select'&&selectedId&&!mobile&&(
Ziehen: verschieben · Ecke (◉) ziehen: skalieren · Entf: löschen
)}
); }