Whiteboard: neues Tool mit Canvas, Undo, Kollaboration via WebSocket, Berechtigungssystem

This commit is contained in:
2026-06-25 20:38:03 +02:00
parent 8eea117321
commit e96946e246
6 changed files with 964 additions and 4 deletions

View File

@@ -0,0 +1,695 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Konstanten ────────────────────────────────────────────────────────────────
const COLORS = ['#FFFFFF','#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C','#4ADE80','#F87171','#000000'];
const SIZES = [2, 4, 8, 14, 22];
const TOOLS = [
{ id:'select', label:'↖', tip:'Auswählen' },
{ id:'pen', label:'✏', tip:'Stift' },
{ id:'line', label:'', tip:'Linie' },
{ id:'rect', label:'▭', tip:'Rechteck' },
{ id:'ellipse', label:'◯', tip:'Ellipse' },
{ id:'text', label:'T', tip:'Text' },
{ id:'eraser', label:'⌫', tip:'Radierer' },
];
const CURSOR_COLORS = ['#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C'];
// ── Utility ───────────────────────────────────────────────────────────────────
const getId = () => Math.random().toString(36).slice(2);
// ── Share-Modal (außerhalb Hauptkomponente — Lesson Learned) ──────────────────
function ShareModal({ wbId, onClose, toast }) {
const [allUsers, setAllUsers] = useState([]);
const [perms, setPerms] = useState([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
Promise.all([
api('/tools/whiteboard/users-list'),
api(`/tools/whiteboard/${wbId}/data`),
]).then(([u, d]) => {
setAllUsers(u.users || []);
setPerms(d.permissions || []);
}).catch(() => {});
}, [wbId]);
const roleOf = (userId) => perms.find(p => p.user_id === userId)?.role || 'none';
const setRole = (userId, role) => {
setPerms(prev => {
const filtered = prev.filter(p => p.user_id !== userId);
if (role === 'none') return filtered;
return [...filtered, { user_id: userId, role }];
});
};
const save = async () => {
setSaving(true);
try {
await api(`/tools/whiteboard/${wbId}/permissions`, {
method: 'PUT',
body: { permissions: allUsers.map(u => ({ user_id: u.id, role: roleOf(u.id) === 'none' ? null : roleOf(u.id) })) },
});
toast('Berechtigungen gespeichert ✓');
onClose();
} catch(e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
const mob = window.innerWidth < 768;
return (
<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 vorhanden.</div>}
{allUsers.map(u => (
<div key={u.id} style={{ display:'flex', alignItems:'center', gap:10, 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>
);
}
// ── Text-Eingabe-Overlay ───────────────────────────────────────────────────────
function TextInput({ x, y, color, size, onDone }) {
const ref = useRef(null);
useEffect(() => { ref.current?.focus(); }, []);
return (
<input ref={ref}
onKeyDown={e => { if (e.key === 'Enter') onDone(ref.current.value); if (e.key === 'Escape') onDone(''); }}
onBlur={() => onDone(ref.current?.value || '')}
style={{ position:'absolute', left:x, top:y, background:'transparent', border:'none',
outline:'1px dashed rgba(255,255,255,0.4)', color, fontSize: size * 4,
fontFamily:'monospace', minWidth:100, zIndex:100 }}
/>
);
}
// ── Canvas-Renderer ────────────────────────────────────────────────────────────
function renderElements(ctx, elements, vp) {
ctx.save();
ctx.translate(vp.x, vp.y);
ctx.scale(vp.zoom, vp.zoom);
ctx.clearRect(-vp.x/vp.zoom, -vp.y/vp.zoom, ctx.canvas.width/vp.zoom, ctx.canvas.height/vp.zoom);
for (const el of elements) {
ctx.globalAlpha = 1;
ctx.strokeStyle = el.color || '#fff';
ctx.fillStyle = el.color || '#fff';
ctx.lineWidth = (el.size || 4) / vp.zoom;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
switch (el.type) {
case 'pen': {
if (!el.points?.length) break;
ctx.beginPath();
ctx.moveTo(el.points[0].x, el.points[0].y);
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.stroke();
break;
}
case 'eraser': {
if (!el.points?.length) break;
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
ctx.lineWidth = (el.size || 20) / vp.zoom;
ctx.beginPath();
ctx.moveTo(el.points[0].x, el.points[0].y);
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.stroke();
ctx.restore();
break;
}
case 'line': {
ctx.beginPath();
ctx.moveTo(el.x1, el.y1);
ctx.lineTo(el.x2, el.y2);
ctx.stroke();
break;
}
case 'rect': {
ctx.strokeRect(el.x, el.y, el.w, el.h);
break;
}
case 'ellipse': {
ctx.beginPath();
ctx.ellipse(el.cx, el.cy, Math.abs(el.rx), Math.abs(el.ry), 0, 0, Math.PI * 2);
ctx.stroke();
break;
}
case 'text': {
ctx.font = `${(el.size || 4) * 4}px monospace`;
ctx.fillText(el.text, el.x, el.y);
break;
}
}
}
ctx.restore();
}
// ── Haupt-Whiteboard-Komponente ────────────────────────────────────────────────
export default function Whiteboard({ toast, mobile }) {
// ── View-State ────────────────────────────────────────────────────────────
const [view, setView] = useState('list'); // 'list' | 'canvas'
const [boards, setBoards] = useState([]);
const [loading, setLoading] = useState(true);
const [current, setCurrent] = useState(null); // { id, title, role }
// ── Canvas-State ──────────────────────────────────────────────────────────
const [elements, setElements] = useState([]);
const [undoStack, setUndoStack] = useState([]);
const [tool, setTool] = useState('pen');
const [color, setColor] = useState('#FFFFFF');
const [size, setSize] = useState(1); // Index in SIZES
const [viewport, setViewport] = useState({ x:0, y:0, zoom:1 });
const [saving, setSaving] = useState(false);
const [shareModal, setShareModal] = useState(false);
const [textInput, setTextInput] = useState(null); // {x,y} | null
// ── Kollaboration ─────────────────────────────────────────────────────────
const [collab, setCollab] = useState([]); // [{userId, username, x, y, color}]
const wsRef = useRef(null);
const cursorTimers = useRef({});
// ── Canvas-Refs ───────────────────────────────────────────────────────────
const canvasRef = useRef(null);
const drawing = useRef(false);
const currentEl = useRef(null);
const startPos = useRef({ x:0, y:0 });
const isPanning = useRef(false);
const panStart = useRef({ x:0, y:0 });
const vpRef = useRef({ x:0, y:0, zoom:1 });
const elementsRef = useRef([]);
// Hooks immer vor Early Returns (Lesson Learned)
const loadBoards = useCallback(() => {
setLoading(true);
api('/tools/whiteboard')
.then(d => setBoards(d.whiteboards || []))
.catch(() => toast('Fehler beim Laden', 'error'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { loadBoards(); }, [loadBoards]);
// Canvas rendern wenn sich Elemente oder Viewport ändern
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
renderElements(ctx, elementsRef.current, vpRef.current);
// Cursor anderer User zeichnen
for (const c of collab) {
const sx = c.x * vpRef.current.zoom + vpRef.current.x;
const sy = c.y * vpRef.current.zoom + vpRef.current.y;
ctx.save();
ctx.fillStyle = c.color;
ctx.font = '10px monospace';
ctx.fillText('▶ ' + c.username, sx + 4, sy - 4);
ctx.restore();
}
}, [elements, viewport, collab]);
// ── WebSocket aufbauen ────────────────────────────────────────────────────
const connectWs = useCallback((wbId) => {
const token = localStorage.getItem('sk_token');
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/whiteboard?token=${token}&id=${wbId}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
const collabColors = {};
let colorIdx = 0;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'user_join':
if (!collabColors[msg.userId]) collabColors[msg.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length];
setCollab(prev => [...prev.filter(c => c.userId !== msg.userId), { userId: msg.userId, username: msg.username, color: collabColors[msg.userId], x:0, y:0 }]);
break;
case 'user_leave':
setCollab(prev => prev.filter(c => c.userId !== msg.userId));
break;
case 'active_users':
for (const u of msg.users) {
if (!collabColors[u.userId]) collabColors[u.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length];
}
setCollab(msg.users.map(u => ({ ...u, color: collabColors[u.userId] || CURSOR_COLORS[0], x:0, y:0 })));
break;
case 'cursor':
if (!collabColors[msg.userId]) collabColors[msg.userId] = CURSOR_COLORS[colorIdx++ % CURSOR_COLORS.length];
setCollab(prev => prev.map(c => c.userId === msg.userId ? { ...c, x: msg.x, y: msg.y } : c));
// Cursor nach 3s ausblenden
clearTimeout(cursorTimers.current[msg.userId]);
cursorTimers.current[msg.userId] = setTimeout(() => {
setCollab(prev => prev.map(c => c.userId === msg.userId ? { ...c, x:-999, y:-999 } : c));
}, 3000);
break;
case 'elements':
// Canvas von Mitarbeiter aktualisieren
elementsRef.current = msg.elements;
setElements([...msg.elements]);
break;
}
};
ws.onclose = () => setCollab([]);
ws.onerror = () => {};
}, []);
const disconnectWs = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
setCollab([]);
}, []);
// ── Whiteboard öffnen ─────────────────────────────────────────────────────
const openBoard = async (board) => {
try {
const d = await api(`/tools/whiteboard/${board.id}/data`);
const els = JSON.parse(typeof d.elements === 'string' ? d.elements : JSON.stringify(d.elements || []));
const vp = JSON.parse(typeof d.viewport === 'string' ? d.viewport : JSON.stringify(d.viewport || {x:0,y:0,zoom:1}));
elementsRef.current = els;
vpRef.current = vp;
setElements(els);
setViewport(vp);
setUndoStack([]);
setCurrent({ id: board.id, title: d.title, role: d.role });
setView('canvas');
connectWs(board.id);
} catch(e) { toast(e.message, 'error'); }
};
// ── Whiteboard schließen ──────────────────────────────────────────────────
const closeBoard = () => {
disconnectWs();
setView('list');
setCurrent(null);
setElements([]);
setUndoStack([]);
setCollab([]);
loadBoards();
};
// ── Canvas-Koordinaten ────────────────────────────────────────────────────
const toCanvas = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const cx = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left;
const cy = (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - rect.top;
return {
x: (cx - vpRef.current.x) / vpRef.current.zoom,
y: (cy - vpRef.current.y) / vpRef.current.zoom,
sx: cx, sy: cy,
};
};
// ── Zeichnen ──────────────────────────────────────────────────────────────
const pushElement = (el) => {
const next = [...elementsRef.current, el];
elementsRef.current = next;
setUndoStack(prev => [...prev, elementsRef.current.slice(0, -1)]);
setElements([...next]);
wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: next }));
};
const updateLastElement = (el) => {
const next = [...elementsRef.current.slice(0, -1), el];
elementsRef.current = next;
setElements([...next]);
};
const finalizeLastElement = (el) => {
const next = [...elementsRef.current.slice(0, -1), el];
elementsRef.current = next;
setElements([...next]);
wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: next }));
};
const undo = () => {
if (!undoStack.length) return;
const prev = undoStack[undoStack.length - 1];
setUndoStack(s => s.slice(0, -1));
elementsRef.current = prev;
setElements([...prev]);
wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: prev }));
};
// ── Pointer Events ────────────────────────────────────────────────────────
const onPointerDown = (e) => {
if (current?.role === 'view') return;
e.preventDefault();
const { x, y, sx, sy } = toCanvas(e);
// Panning mit Mittelklick oder Space+Drag
if (e.button === 1 || tool === 'select') {
isPanning.current = true;
panStart.current = { x: sx - vpRef.current.x, y: sy - vpRef.current.y };
return;
}
if (tool === 'text') {
const rect = canvasRef.current.getBoundingClientRect();
setTextInput({ x: sx + rect.left, y: sy + rect.top - SIZES[size] * 2 });
return;
}
drawing.current = true;
startPos.current = { x, y };
if (tool === 'pen' || tool === 'eraser') {
const el = { id: getId(), type: tool, color, size: SIZES[size], points: [{ x, y }] };
currentEl.current = el;
pushElement(el);
} else if (tool === 'line') {
const el = { id: getId(), type: 'line', color, size: SIZES[size], x1:x, y1:y, x2:x, y2:y };
currentEl.current = el;
pushElement(el);
} else if (tool === 'rect') {
const el = { id: getId(), type: 'rect', color, size: SIZES[size], x, y, w:0, h:0 };
currentEl.current = el;
pushElement(el);
} else if (tool === 'ellipse') {
const el = { id: getId(), type: 'ellipse', color, size: SIZES[size], cx:x, cy:y, rx:0, ry:0 };
currentEl.current = el;
pushElement(el);
}
};
const onPointerMove = (e) => {
const { x, y, sx, sy } = toCanvas(e);
// Cursor per WS senden (gedrosselt)
if (wsRef.current?.readyState === 1) {
wsRef.current.send(JSON.stringify({ type:'cursor', x, y }));
}
if (isPanning.current) {
const nx = sx - panStart.current.x;
const ny = sy - panStart.current.y;
vpRef.current = { ...vpRef.current, x: nx, y: ny };
setViewport({ ...vpRef.current });
return;
}
if (!drawing.current || !currentEl.current) return;
const el = currentEl.current;
if (tool === 'pen' || tool === 'eraser') {
const updated = { ...el, points: [...el.points, { x, y }] };
currentEl.current = updated;
updateLastElement(updated);
} else if (tool === 'line') {
const updated = { ...el, x2: x, y2: y };
currentEl.current = updated;
updateLastElement(updated);
} else if (tool === 'rect') {
const updated = { ...el, w: x - el.x, h: y - el.y };
currentEl.current = updated;
updateLastElement(updated);
} else if (tool === 'ellipse') {
const updated = { ...el, rx: (x - el.cx), ry: (y - el.cy) };
currentEl.current = updated;
updateLastElement(updated);
}
};
const onPointerUp = () => {
if (isPanning.current) { isPanning.current = false; return; }
if (!drawing.current) return;
drawing.current = false;
if (currentEl.current) {
finalizeLastElement(currentEl.current);
currentEl.current = null;
}
};
// Zoom mit Mausrad
const onWheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const rect = canvasRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const newZoom = Math.max(0.1, Math.min(5, vpRef.current.zoom * delta));
const nx = mx - (mx - vpRef.current.x) * (newZoom / vpRef.current.zoom);
const ny = my - (my - vpRef.current.y) * (newZoom / vpRef.current.zoom);
vpRef.current = { x: nx, y: ny, zoom: newZoom };
setViewport({ ...vpRef.current });
};
// Canvas-Größe anpassen
useEffect(() => {
if (view !== 'canvas') return;
const resize = () => {
const c = canvasRef.current;
if (!c) return;
c.width = c.offsetWidth;
c.height = c.offsetHeight;
renderElements(c.getContext('2d'), elementsRef.current, vpRef.current);
};
resize();
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, [view]);
// Speichern
const save = async () => {
if (saving) return;
setSaving(true);
try {
await api(`/tools/whiteboard/${current.id}/save`, {
body: { elements: elementsRef.current, viewport: vpRef.current },
});
toast('Gespeichert ✓');
} catch(e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
// Tastenkürzel
useEffect(() => {
if (view !== 'canvas') return;
const onKey = (e) => {
if (e.target.tagName === 'INPUT') return;
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [view, undoStack]);
// ── Render: Liste ──────────────────────────────────────────────────────────
if (view === 'list') return (
<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 }}/>
<button onClick={async () => {
try {
const wb = await api('/tools/whiteboard', { body: { title: 'Neues Whiteboard' } });
await openBoard(wb);
} catch(e) { toast(e.message, 'error'); }
}} 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={() => openBoard(b)}>
<div style={{ fontSize:24 }}>🖊</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>{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>
{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>
);
// ── Render: Canvas ─────────────────────────────────────────────────────────
const canEdit = current?.role !== 'view';
return (
<div style={{ display:'flex', flexDirection:'column', height:'100%', background:'#0D0D0F', position:'relative' }}>
{/* Share-Modal */}
{shareModal && current?.role === 'owner' && (
<ShareModal wbId={current.id} onClose={() => setShareModal(false)} toast={toast}/>
)}
{/* Text-Eingabe */}
{textInput && (
<TextInput x={textInput.x} y={textInput.y} color={color} size={SIZES[size]}
onDone={text => {
setTextInput(null);
if (text?.trim()) {
const { x, y } = toCanvas({ clientX: textInput.x, clientY: textInput.y + SIZES[size] * 2 });
pushElement({ id: getId(), type:'text', color, size, text, x, y });
}
}}
/>
)}
{/* ── Top-Bar ──────────────────────────────────────────────────────── */}
<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>
<span style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700, flex:1, minWidth:0,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{current?.title}
{current?.role === 'view' && <span style={{ color:'rgba(255,255,255,0.3)', fontSize:10, marginLeft:8 }}>👁 nur lesen</span>}
</span>
{/* Aktive User */}
{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>
)}
{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:6, padding:'6px 12px',
background:'rgba(0,0,0,0.45)', borderBottom:'1px solid rgba(255,255,255,0.06)',
flexShrink:0, flexWrap:'wrap' }}>
{/* Werkzeuge */}
<div style={{ display:'flex', gap:3 }}>
{TOOLS.map(t => (
<button key={t.id} onClick={() => setTool(t.id)} 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>
<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' }}>
Undo
</button>
<button onClick={() => { if (confirm('Alles löschen?')) { elementsRef.current=[]; setElements([]); setUndoStack([]); wsRef.current?.readyState===1&&wsRef.current.send(JSON.stringify({type:'elements',elements:[]})); } }}
style={{ background:'rgba(255,107,157,0.08)', border:'1px solid rgba(255,107,157,0.2)',
borderRadius:7, padding:'5px 9px', color:'rgba(255,107,157,0.7)', cursor:'pointer', fontSize:12, fontFamily:'monospace' }}>
🗑 Leeren
</button>
{/* Zoom-Info */}
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, marginLeft:4 }}>
{Math.round(viewport.zoom * 100)}%
</span>
<button onClick={() => { vpRef.current={x:0,y:0,zoom:1}; setViewport({x:0,y:0,zoom:1}); }}
style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.25)', cursor:'pointer', fontFamily:'monospace', fontSize:10 }}>
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: tool==='select' ? 'grab' : tool==='eraser' ? 'cell' : tool==='text' ? 'text' : 'crosshair' }}
/>
</div>
);
}