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}}>
{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 [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);
api('/tools/whiteboard').then(d=>setBoards(d.whiteboards||[])).catch(()=>toast('Fehler','error')).finally(()=>setLoading(false));
}, []);
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) => {
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); }}>
🖊
{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)=>(