diff --git a/frontend/src/tools/whiteboard.jsx b/frontend/src/tools/whiteboard.jsx
index 34a006d..3a036f9 100644
--- a/frontend/src/tools/whiteboard.jsx
+++ b/frontend/src/tools/whiteboard.jsx
@@ -2,128 +2,112 @@ 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' },
+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'];
-
-// ── Utility ───────────────────────────────────────────────────────────────────
+const BG = '#0D0D0F';
const getId = () => Math.random().toString(36).slice(2);
+const HANDLE_R = 6; // Resize-Handle Radius in Screen-Px
-// ── Share-Modal (außerhalb Hauptkomponente — Lesson Learned) ──────────────────
-function ShareModal({ wbId, onClose, toast }) {
- const [allUsers, setAllUsers] = useState([]);
- const [perms, setPerms] = useState([]);
- const [saving, setSaving] = useState(false);
-
- useEffect(() => {
- Promise.all([
- api('/tools/whiteboard/users-list'),
- api(`/tools/whiteboard/${wbId}/data`),
- ]).then(([u, d]) => {
- setAllUsers(u.users || []);
- setPerms(d.permissions || []);
- }).catch(() => {});
- }, [wbId]);
-
- const roleOf = (userId) => perms.find(p => p.user_id === userId)?.role || 'none';
-
- const setRole = (userId, role) => {
- setPerms(prev => {
- const filtered = prev.filter(p => p.user_id !== userId);
- if (role === 'none') return filtered;
- return [...filtered, { user_id: userId, role }];
- });
- };
-
- const save = async () => {
- setSaving(true);
- try {
- await api(`/tools/whiteboard/${wbId}/permissions`, {
- method: 'PUT',
- body: { permissions: allUsers.map(u => ({ user_id: u.id, role: roleOf(u.id) === 'none' ? null : roleOf(u.id) })) },
- });
- toast('Berechtigungen gespeichert ✓');
- onClose();
- } catch(e) { toast(e.message, 'error'); }
- finally { setSaving(false); }
- };
-
- const mob = window.innerWidth < 768;
- return (
-
e.target === e.currentTarget && onClose()}
- style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000,
- display:'flex', alignItems: mob ? 'flex-end' : 'center', justifyContent:'center', padding: mob ? 0 : 20 }}>
-
-
-
- {allUsers.length === 0 &&
Keine anderen Benutzer vorhanden.
}
- {allUsers.map(u => (
-
- {u.username}
- {['none','view','edit'].map(r => (
-
- ))}
-
- ))}
-
-
-
-
-
-
-
- );
+// ── 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;
+ }
}
-// ── Text-Eingabe-Overlay ───────────────────────────────────────────────────────
-function TextInput({ x, y, color, size, onDone }) {
- const ref = useRef(null);
- useEffect(() => { ref.current?.focus(); }, []);
- return (
- { if (e.key === 'Enter') onDone(ref.current.value); if (e.key === 'Escape') onDone(''); }}
- onBlur={() => onDone(ref.current?.value || '')}
- style={{ position:'absolute', left:x, top:y, background:'transparent', border:'none',
- outline:'1px dashed rgba(255,255,255,0.4)', color, fontSize: size * 4,
- fontFamily:'monospace', minWidth:100, zIndex:100 }}
- />
- );
+// 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;
}
-// ── Canvas-Renderer ────────────────────────────────────────────────────────────
-function renderElements(ctx, elements, vp) {
+// ── 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);
- 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.save();
ctx.globalAlpha = 1;
ctx.strokeStyle = el.color || '#fff';
ctx.fillStyle = el.color || '#fff';
@@ -142,610 +126,750 @@ function renderElements(ctx, elements, vp) {
}
case 'eraser': {
if (!el.points?.length) break;
- ctx.save();
- ctx.strokeStyle = '#0D0D0F'; // Hintergrundfarbe des Canvas
- ctx.lineWidth = (el.size || 4) * 3 / vp.zoom; // Radierer ist 3× breiter als Stift
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
+ 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();
- ctx.restore();
break;
}
case 'line': {
- ctx.beginPath();
- ctx.moveTo(el.x1, el.y1);
- ctx.lineTo(el.x2, el.y2);
- ctx.stroke();
+ ctx.beginPath(); ctx.moveTo(el.x1, el.y1); ctx.lineTo(el.x2, el.y2); ctx.stroke();
break;
}
- case 'rect': {
+ case 'rect':
ctx.strokeRect(el.x, el.y, el.w, el.h);
break;
- }
- case 'ellipse': {
+ case 'ellipse':
ctx.beginPath();
- ctx.ellipse(el.cx, el.cy, Math.abs(el.rx), Math.abs(el.ry), 0, 0, Math.PI * 2);
+ 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': {
- ctx.font = `${(el.size || 4) * 4}px monospace`;
+ 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();
}
-// ── Haupt-Whiteboard-Komponente ────────────────────────────────────────────────
+// Offscreen-Export (ohne Auswahl-Rahmen, original Viewport)
+function exportToPng(elements, title) {
+ // Bounding-Box aller Elemente berechnen
+ 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';
+ a.click();
+}
+
+// ── 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-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 }
- const [creating, setCreating] = useState(false);
- const [newTitle, setNewTitle] = useState('');
- const [editingId, setEditingId] = useState(null); // ID des gerade umbenannten Boards
- const [editTitle, setEditTitle] = useState('');
+ // 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-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
+ // 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 [selectedId,setSelectedId]= useState(null);
- // ── Kollaboration ─────────────────────────────────────────────────────────
- const [collab, setCollab] = useState([]); // [{userId, username, x, y, color}]
+ // 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 panStart = useRef({ x:0, y:0 });
- const vpRef = useRef({ x:0, y:0, zoom:1 });
- const elementsRef = 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);
- // 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));
- }, []);
+ // ── Hilfsfunktionen ──────────────────────────────────────────────────────
+ const syncVp = (vp) => { vpRef.current=vp; setViewport({...vp}); };
- 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]);
-
- // 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,
- };
+ 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}));
};
- // ── Zeichnen ──────────────────────────────────────────────────────────────
const pushElement = (el) => {
const next = [...elementsRef.current, el];
elementsRef.current = next;
- setUndoStack(prev => [...prev, elementsRef.current.slice(0, -1)]);
+ setUndoStack(prev => [...prev, elementsRef.current.slice(0,-1)]);
setElements([...next]);
- wsRef.current?.readyState === 1 && wsRef.current.send(JSON.stringify({ type:'elements', elements: next }));
+ broadcast(next);
};
const updateLastElement = (el) => {
- const next = [...elementsRef.current.slice(0, -1), 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];
+ 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 }));
+ 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));
+ 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 }));
+ broadcast(prev);
+ setSelectedId(null); selectedRef.current=null;
};
- // ── Pointer Events ────────────────────────────────────────────────────────
+ // ── 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();
+ }
+ }, [collab]);
+
+ 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;
+ 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;
+ // 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;
}
- if (tool === 'text') {
- const rect = canvasRef.current.getBoundingClientRect();
- setTextInput({ x: sx + rect.left, y: sy + rect.top - SIZES[size] * 2 });
- 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;
}
- drawing.current = true;
- startPos.current = { x, y };
+ // Text
+ if (tool==='text') {
+ setTextInput({ sx, sy, x, y }); return;
+ }
- 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);
+ 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 }));
- }
+ 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;
+ syncVp({ ...vpRef.current, x: sx-panStart.current.x, y: sy-panStart.current.y }); return;
}
- if (!drawing.current || !currentEl.current) return;
- const el = currentEl.current;
+ 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 (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);
+ 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 = () => {
- if (isPanning.current) { isPanning.current = false; return; }
- if (!drawing.current) return;
- drawing.current = false;
- if (currentEl.current) {
- finalizeLastElement(currentEl.current);
- currentEl.current = null;
+ 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; }
};
- // 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 });
+ 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 });
};
- // 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); }
+ 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') return;
- if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); }
- if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save(); }
+ 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]);
+ window.addEventListener('keydown',onKey);
+ return ()=>window.removeEventListener('keydown',onKey);
+ },[view, undoStack]);
- // ── Render: Liste ──────────────────────────────────────────────────────────
- if (view === 'list') return (
-
-
-
- WHITEBOARD
-
-
+ // ── 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') {
- 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'); }
+ 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(''); }
+ if(e.key==='Escape'){setCreating(false);setNewTitle('');}
}}
- placeholder="Name des Whiteboards"
- style={{ ...S.inp, width: mobile ? 160 : 220, fontSize:12 }}
- />
-
-
+ placeholder="Name des Whiteboards" style={{...S.inp,width:mobile?160:220,fontSize:12}}/>
+
+
) : (
-
+
)}
- {loading &&
Lädt…
}
-
- {!loading && boards.length === 0 && (
-
+ {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); } }}>
+ {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'?'👑 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' && (
-
- )}
+
+
+ {(b.role==='owner'||b.role==='edit')&&(
+
+ )}
+ {b.role==='owner'&&(
+
+ )}
+
))}
);
- // ── Render: Canvas ─────────────────────────────────────────────────────────
+ // ── Render: Canvas ────────────────────────────────────────────────────────
const canEdit = current?.role !== 'view';
return (
-
+
- {/* Share-Modal */}
- {shareModal && current?.role === 'owner' && (
-
setShareModal(false)} toast={toast}/>
+ {shareModal&¤t?.role==='owner'&&(
+ setShareModal(false)} toast={toast}/>
)}
- {/* Text-Eingabe */}
- {textInput && (
- {
- setTextInput(null);
- if (text?.trim()) {
- const { x, y } = toCanvas({ clientX: textInput.x, clientY: textInput.y + SIZES[size] * 2 });
- pushElement({ id: getId(), type:'text', color, size, text, x, y });
- }
- }}
- />
+ {textInput&&(
+
+ {
+ if(e.key==='Enter'){
+ const v=e.target.value;
+ 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') setTextInput(null);
+ }}
+ onBlur={e=>{
+ const v=e.target.value;
+ 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:'transparent',border:'none',outline:'1px dashed rgba(255,255,255,0.4)',
+ color,fontSize:FONT_SIZES[fontSize],fontFamily:'monospace',minWidth:100}}/>
+
)}
- {/* ── Top-Bar ──────────────────────────────────────────────────────── */}
-
-
- {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' }}>
+ {/* ── 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}
+ {current?.role==='view'&&👁 nur lesen}
)}
-
- {/* Aktive User */}
- {collab.length > 0 && (
-
- {collab.map(c => (
-
+ {collab.length>0&&(
+
+ {collab.map(c=>(
+
{c.username[0].toUpperCase()}
))}
)}
-
- {canEdit && }
- {current?.role === 'owner' && (
-
+
+ {canEdit&&}
+ {current?.role==='owner'&&(
+
)}
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
- {canEdit && (
-
+ {canEdit&&(
+
- {/* Werkzeuge */}
-
- {TOOLS.map(t => (
-