Whiteboard: neues Tool mit Canvas, Undo, Kollaboration via WebSocket, Berechtigungssystem
This commit is contained in:
695
frontend/src/tools/whiteboard.jsx
Normal file
695
frontend/src/tools/whiteboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user