From ad7aad0b2c722cb089a3e7609bae3d8d8c637824 Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 25 Jun 2026 21:25:03 +0200 Subject: [PATCH] =?UTF-8?q?Whiteboard:=20Select/Move/Resize,=20Textgr?= =?UTF-8?q?=C3=B6=C3=9Fen-Auswahl,=20Zoom-Buttons,=20PNG-Export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/tools/whiteboard.jsx | 1252 ++++++++++++++++------------- 1 file changed, 691 insertions(+), 561 deletions(-) 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 }}> -
-
- {mob &&
} -
-
TEILEN
- -
-
-
- {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}}> +
+
+ {mob&&
} +
+
TEILEN
+ +
+
+
+ {allUsers.length===0&&
Keine anderen Benutzer.
} + {allUsers.map(u=>( +
+ {u.username} + {['none','view','edit'].map(r=>( + + ))} +
+ ))} +
+
+ + +
+
+
+ ); +} + +// ── Hauptkomponente ─────────────────────────────────────────────────────────── export default function Whiteboard({ toast, mobile }) { - // ── View-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 => ( - ))}
-
+
{/* Farben */} -
- {COLORS.map(c => ( -
-
+
{/* Strichstärke */} -
- {SIZES.map((s, i) => ( - ))}
-
+ {/* Textgröße — nur bei Text-Tool */} + {tool==='text'&&<> +
+ TEXT +
+ {FONT_SIZES.map((fs,i)=>( + + ))} +
+ } + +
{/* Undo + Löschen */} - + + {selectedId&&( + + )} + + - {/* Zoom-Info */} - - {Math.round(viewport.zoom * 100)}% +
+ + {/* Zoom */} + + + {Math.round(viewport.zoom*100)}% - +
@@ -753,14 +877,20 @@ export default function Whiteboard({ toast, mobile }) { {/* ── Canvas ───────────────────────────────────────────────────────── */} + + {/* Hinweis: Select-Tool Aktionen */} + {tool==='select'&&selectedId&&!mobile&&( +
+ Ziehen: verschieben · Ecke (◉) ziehen: skalieren · Entf: löschen +
+ )}
); }