Files
dickendock/frontend/src/tools/whiteboard.jsx

959 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div onClick={e=>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}}>
<div style={{background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',borderRadius:mob?'16px 16px 0 0':14,
width:'100%',maxWidth:440,display:'flex',flexDirection:'column',maxHeight:mob?'80vh':'75vh',
paddingBottom:mob?'calc(56px + env(safe-area-inset-bottom,0px))':0}}>
<div style={{padding:'18px 20px 0',flexShrink:0}}>
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{...S.head,marginBottom:0}}>TEILEN</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
</div>
<div style={{overflowY:'auto',padding:'0 20px',flex:1}}>
{allUsers.length===0&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine anderen Benutzer.</div>}
{allUsers.map(u=>(
<div key={u.id} style={{display:'flex',alignItems:'center',gap:8,padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{flex:1,fontFamily:'monospace',fontSize:13,color:'#fff'}}>{u.username}</span>
{['none','view','edit'].map(r=>(
<button key={r} onClick={()=>setRole(u.id,r)} style={{...S.btn(r==='edit'?'#4ECDC4':r==='view'?'#60A5FA':'#888888',true),
fontSize:10,padding:'3px 8px',
background:roleOf(u.id)===r?(r==='edit'?'rgba(78,205,196,0.2)':r==='view'?'rgba(96,165,250,0.2)':'rgba(136,136,136,0.2)'):'transparent',
fontWeight:roleOf(u.id)===r?700:400}}>
{r==='none'?'✕ kein':r==='view'?'👁 lesen':'✏ bearbeiten'}
</button>
))}
</div>
))}
</div>
<div style={{padding:'14px 20px 20px',flexShrink:0,borderTop:'1px solid rgba(255,255,255,0.07)',display:'flex',gap:8,justifyContent:'flex-end'}}>
<button onClick={onClose} style={S.btn('#888888',true)}>Abbrechen</button>
<button onClick={save} disabled={saving} style={S.btn('#4ECDC4',true)}>{saving?'…':'Speichern'}</button>
</div>
</div>
</div>
);
}
// ── 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 (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px'}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>WHITEBOARD</h2>
<div style={{flex:1}}/>
{creating ? (
<div style={{display:'flex',gap:6}}>
<input autoFocus value={newTitle} onChange={e=>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}}/>
<button onClick={async()=>{
if(!newTitle.trim()) return;
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
catch(err){ toast(err.message,'error'); }
}} style={S.btn('#4ECDC4',true)}>Erstellen</button>
<button onClick={()=>{setCreating(false);setNewTitle('');}} style={S.btn('#888888',true)}></button>
</div>
) : (
<button onClick={()=>setCreating(true)} style={S.btn('#4ECDC4')}>+ Neu</button>
)}
</div>
{loading&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>}
{!loading&&boards.length===0&&(
<div style={{...S.card,textAlign:'center',padding:40,color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12}}>
Noch kein Whiteboard. Erstelle eines mit "+ Neu".
</div>
)}
{boards.map(b=>(
<div key={b.id} style={{...S.card,display:'flex',alignItems:'center',gap:12,marginBottom:8,cursor:'pointer'}}
onClick={()=>{ if(editingId!==b.id) openBoard(b); }}>
<div style={{fontSize:24}}>🖊</div>
<div style={{flex:1,minWidth:0}}>
{editingId===b.id?(
<input autoFocus value={editTitle} onChange={e=>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%'}}/>
):(
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}
onDoubleClick={e=>{e.stopPropagation();if(b.role==='owner'||b.role==='edit'){setEditingId(b.id);setEditTitle(b.title);}}}>
{b.title}
</div>
)}
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
{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'})}
</div>
</div>
<div style={{display:'flex',gap:4,flexShrink:0}}>
<button onClick={e=>{e.stopPropagation();
api(`/tools/whiteboard/${b.id}/data`).then(d=>{
const els=JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
exportToPng(els,b.title);
}).catch(()=>toast('Export fehlgeschlagen','error'));
}} title="Als PNG exportieren"
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}></button>
{(b.role==='owner'||b.role==='edit')&&(
<button onClick={e=>{e.stopPropagation();setEditingId(b.id);setEditTitle(b.title);}} title="Umbenennen"
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}></button>
)}
{b.role==='owner'&&(
<button onClick={e=>{e.stopPropagation();if(confirm(`"${b.title}" löschen?`))
api(`/tools/whiteboard/${b.id}`,{method:'DELETE'}).then(loadBoards).catch(err=>toast(err.message,'error'));
}} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:16,padding:'4px 6px'}}></button>
)}
</div>
</div>
))}
</div>
);
// ── Render: Canvas ────────────────────────────────────────────────────────
const canEdit = current?.role !== 'view';
return (
<div style={{display:'flex',flexDirection:'column',height:'100%',background:BG,position:'relative'}}>
{shareModal&&current?.role==='owner'&&(
<ShareModal wbId={current.id} onClose={()=>setShareModal(false)} toast={toast}/>
)}
{textInput&&(
mobile ? (
<div style={{position:'fixed',bottom:0,left:0,right:0,zIndex:500,
background:'#1a1a1e',borderTop:'1px solid rgba(255,255,255,0.15)',
padding:'12px 16px',paddingBottom:'calc(12px + env(safe-area-inset-bottom,0px))'}}>
<div style={{fontSize:10,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',marginBottom:6}}>
TEXT EINGEBEN · Enter = bestätigen · Esc = abbrechen
</div>
<div style={{display:'flex',gap:8}}>
<input autoFocus
onChange={e=>{
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}}/>
<button onMouseDown={e=>{
e.preventDefault();
const inp=e.currentTarget.previousSibling;
const v=inp?.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={S.btn('#4ECDC4',true)}></button>
<button onMouseDown={e=>{e.preventDefault();textPreviewRef.current=null;setTextInput(null);render();}} style={S.btn('#888888',true)}></button>
</div>
</div>
) : (
<div style={{position:'absolute',left:textInput.sx,top:textInput.sy,zIndex:100,pointerEvents:'auto'}}>
<input autoFocus
onChange={e=>{
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}}/>
</div>
)
)}
{/* ── Topbar ───────────────────────────────────────────────────────── */}
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:'rgba(0,0,0,0.6)',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,flexWrap:'wrap'}}>
<button onClick={closeBoard} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:18,padding:'2px 6px',lineHeight:1}}></button>
{editingId===current?.id?(
<input autoFocus value={editTitle} onChange={e=>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'}}/>
):(
<span onDoubleClick={()=>{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'&&<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,marginLeft:8}}>👁 nur lesen</span>}
</span>
)}
{collab.length>0&&(
<div style={{display:'flex',gap:4,alignItems:'center'}}>
{collab.map(c=>(
<span key={c.userId} style={{background:c.color,color:'#000',fontFamily:'monospace',fontSize:9,borderRadius:10,padding:'2px 7px',fontWeight:700}}>
{c.username[0].toUpperCase()}
</span>
))}
</div>
)}
<button onClick={()=>exportToPng(elementsRef.current,current?.title)}
style={{...S.btn('#60A5FA',true),fontSize:11}}> PNG</button>
{canEdit&&<button onClick={save} disabled={saving} style={{...S.btn('#4ECDC4',true),fontSize:11}}>{saving?'…':'💾 Speichern'}</button>}
{current?.role==='owner'&&(
<button onClick={()=>setShareModal(true)} style={{...S.btn('#A78BFA',true),fontSize:11}}>👥 Teilen</button>
)}
</div>
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
{canEdit&&(
<div style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
background:'rgba(0,0,0,0.45)',borderBottom:'1px solid rgba(255,255,255,0.06)',
flexShrink:0,flexWrap:'wrap'}}>
{/* Tools */}
<div style={{display:'flex',gap:3}}>
{TOOLS.map(t=>(
<button key={t.id} onClick={()=>{
setTool(t.id); setSelectedId(null); selectedRef.current=null;
textPreviewRef.current=null; setTextInput(null); render();
}} title={t.tip}
style={{background:tool===t.id?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${tool===t.id?'#4ECDC4':'rgba(255,255,255,0.1)'}`,
borderRadius:7,padding:'5px 9px',color:tool===t.id?'#4ECDC4':'rgba(255,255,255,0.7)',
cursor:'pointer',fontSize:14,lineHeight:1,fontFamily:'monospace'}}>
{t.label}
</button>
))}
</div>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Farben */}
<div style={{display:'flex',gap:3}}>
{COLORS.map(c=>(
<button key={c} onClick={()=>setColor(c)}
style={{width:18,height:18,borderRadius:'50%',background:c,
border:color===c?'2px solid #fff':'1px solid rgba(255,255,255,0.2)',cursor:'pointer',padding:0,flexShrink:0}}/>
))}
</div>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Strichstärke */}
<div style={{display:'flex',gap:3,alignItems:'center'}}>
{SIZES.map((s,i)=>(
<button key={i} onClick={()=>setSize(i)}
style={{width:18,height:18,borderRadius:'50%',
background:size===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${size===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
cursor:'pointer',padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
<div style={{width:s+2,height:s+2,borderRadius:'50%',background:size===i?'#4ECDC4':'#888'}}/>
</button>
))}
</div>
{/* Textgröße — nur bei Text-Tool */}
{tool==='text'&&<>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9}}>TEXT</span>
<div style={{display:'flex',gap:3,alignItems:'center'}}>
{FONT_SIZES.map((fs,i)=>(
<button key={i} onClick={()=>setFontSize(i)}
style={{padding:'2px 6px',borderRadius:6,fontFamily:'monospace',fontSize:Math.min(14,fs/2)+1,
background:fontSize===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${fontSize===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
color:fontSize===i?'#4ECDC4':'rgba(255,255,255,0.6)',cursor:'pointer'}}>
{fs}
</button>
))}
</div>
</>}
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Undo + Löschen */}
<button onClick={undo} disabled={!undoStack.length} title="Rückgängig (Ctrl+Z)"
style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:7,padding:'5px 9px',color:undoStack.length?'rgba(255,255,255,0.7)':'rgba(255,255,255,0.2)',
cursor:undoStack.length?'pointer':'default',fontSize:12,fontFamily:'monospace'}}></button>
{selectedId&&(
<button onClick={()=>{
const next=elementsRef.current.filter(el=>el.id!==selectedId);
setUndoStack(prev=>[...prev,elementsRef.current]);
elementsRef.current=next; setElements([...next]); broadcast(next);
setSelectedId(null); selectedRef.current=null;
}} style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.25)',
borderRadius:7,padding:'5px 9px',color:'#ff6b9d',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
Löschen
</button>
)}
<button onClick={()=>{if(confirm('Alles löschen?')){
elementsRef.current=[];setElements([]);setUndoStack([]);broadcast([]);
setSelectedId(null);selectedRef.current=null;
}}} style={{background:'rgba(255,107,157,0.05)',border:'1px solid rgba(255,107,157,0.15)',
borderRadius:7,padding:'5px 9px',color:'rgba(255,107,157,0.5)',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
🗑
</button>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Zoom */}
<button onClick={()=>zoom(1.2)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}></button>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,minWidth:32,textAlign:'center'}}>
{Math.round(viewport.zoom*100)}%
</span>
<button onClick={()=>zoom(0.8)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}></button>
<button onClick={()=>syncVp({x:0,y:0,zoom:1})}
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontFamily:'monospace',fontSize:9}}>
Reset
</button>
</div>
)}
{/* ── Canvas ───────────────────────────────────────────────────────── */}
<canvas ref={canvasRef}
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerLeave={onPointerUp} onWheel={onWheel}
style={{flex:1,width:'100%',touchAction:'none',cursor:getCursor()}}
/>
{/* Hinweis: Select-Tool Aktionen */}
{tool==='select'&&selectedId&&!mobile&&(
<div style={{position:'absolute',bottom:12,left:'50%',transform:'translateX(-50%)',
background:'rgba(0,0,0,0.7)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,padding:'5px 12px',fontFamily:'monospace',fontSize:10,color:'rgba(255,255,255,0.4)',
pointerEvents:'none',whiteSpace:'nowrap'}}>
Ziehen: verschieben · Ecke () ziehen: skalieren · Entf: löschen
</div>
)}
</div>
);
}