959 lines
46 KiB
JavaScript
959 lines
46 KiB
JavaScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { api, S } from '../lib.js';
|
||
|
||
// ── Konstanten ────────────────────────────────────────────────────────────────
|
||
const COLORS = ['#FFFFFF','#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C','#4ADE80','#F87171','#000000'];
|
||
const SIZES = [2, 4, 8, 14, 22];
|
||
const FONT_SIZES = [12, 16, 20, 28, 40, 56];
|
||
const TOOLS = [
|
||
{ id:'select', label:'↖', tip:'Auswählen / Verschieben' },
|
||
{ id:'pen', label:'✏', tip:'Stift' },
|
||
{ id:'line', label:'╱', tip:'Linie' },
|
||
{ id:'rect', label:'▭', tip:'Rechteck' },
|
||
{ id:'ellipse', label:'◯', tip:'Ellipse' },
|
||
{ id:'text', label:'T', tip:'Text' },
|
||
{ id:'eraser', label:'🧹', tip:'Radierer' },
|
||
];
|
||
const CURSOR_COLORS = ['#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C'];
|
||
const BG = '#0D0D0F';
|
||
const getId = () => Math.random().toString(36).slice(2);
|
||
const HANDLE_R = 6; // Resize-Handle Radius in Screen-Px
|
||
|
||
// ── Bounding-Box-Berechnung ───────────────────────────────────────────────────
|
||
function getBounds(el) {
|
||
switch (el.type) {
|
||
case 'pen': case 'eraser': {
|
||
if (!el.points?.length) return null;
|
||
const xs = el.points.map(p => p.x), ys = el.points.map(p => p.y);
|
||
const pad = (el.size||4) * (el.type==='eraser'?3:1);
|
||
return { x: Math.min(...xs)-pad, y: Math.min(...ys)-pad,
|
||
w: Math.max(...xs)-Math.min(...xs)+pad*2, h: Math.max(...ys)-Math.min(...ys)+pad*2 };
|
||
}
|
||
case 'line': {
|
||
const pad = el.size||4;
|
||
return { x: Math.min(el.x1,el.x2)-pad, y: Math.min(el.y1,el.y2)-pad,
|
||
w: Math.abs(el.x2-el.x1)+pad*2, h: Math.abs(el.y2-el.y1)+pad*2 };
|
||
}
|
||
case 'rect':
|
||
return { x: Math.min(el.x,el.x+el.w), y: Math.min(el.y,el.y+el.h),
|
||
w: Math.abs(el.w), h: Math.abs(el.h) };
|
||
case 'ellipse':
|
||
return { x: el.cx-Math.abs(el.rx), y: el.cy-Math.abs(el.ry),
|
||
w: Math.abs(el.rx)*2, h: Math.abs(el.ry)*2 };
|
||
case 'text': {
|
||
const fs = el.fontSize || 20;
|
||
const w = (el.text?.length || 4) * fs * 0.6;
|
||
return { x: el.x, y: el.y - fs, w, h: fs * 1.3 };
|
||
}
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
// Punkt-in-Bounds Hit-Test
|
||
function hitTest(el, cx, cy) {
|
||
const b = getBounds(el);
|
||
if (!b) return false;
|
||
return cx >= b.x && cx <= b.x+b.w && cy >= b.y && cy <= b.y+b.h;
|
||
}
|
||
|
||
// ── Element verschieben ───────────────────────────────────────────────────────
|
||
function moveElement(el, dx, dy) {
|
||
switch (el.type) {
|
||
case 'pen': case 'eraser':
|
||
return { ...el, points: el.points.map(p => ({ x: p.x+dx, y: p.y+dy })) };
|
||
case 'line':
|
||
return { ...el, x1: el.x1+dx, y1: el.y1+dy, x2: el.x2+dx, y2: el.y2+dy };
|
||
case 'rect':
|
||
return { ...el, x: el.x+dx, y: el.y+dy };
|
||
case 'ellipse':
|
||
return { ...el, cx: el.cx+dx, cy: el.cy+dy };
|
||
case 'text':
|
||
return { ...el, x: el.x+dx, y: el.y+dy };
|
||
default: return el;
|
||
}
|
||
}
|
||
|
||
// ── Element skalieren (Resize via Ecke rechts-unten) ─────────────────────────
|
||
function resizeElement(el, nx, ny) {
|
||
const b = getBounds(el);
|
||
if (!b) return el;
|
||
const scaleX = b.w > 1 ? Math.max(0.05, (nx - b.x) / b.w) : 1;
|
||
const scaleY = b.h > 1 ? Math.max(0.05, (ny - b.y) / b.h) : 1;
|
||
switch (el.type) {
|
||
case 'pen': case 'eraser':
|
||
return { ...el, points: el.points.map(p => ({ x: b.x + (p.x-b.x)*scaleX, y: b.y + (p.y-b.y)*scaleY })) };
|
||
case 'line':
|
||
return { ...el, x1: b.x+(el.x1-b.x)*scaleX, y1: b.y+(el.y1-b.y)*scaleY,
|
||
x2: b.x+(el.x2-b.x)*scaleX, y2: b.y+(el.y2-b.y)*scaleY };
|
||
case 'rect':
|
||
return { ...el, w: el.w * scaleX, h: el.h * scaleY };
|
||
case 'ellipse':
|
||
return { ...el, rx: el.rx * scaleX, ry: el.ry * scaleY };
|
||
case 'text':
|
||
return { ...el, fontSize: Math.max(8, Math.round((el.fontSize||20) * ((scaleX+scaleY)/2))) };
|
||
default: return el;
|
||
}
|
||
}
|
||
|
||
// ── Canvas rendern ────────────────────────────────────────────────────────────
|
||
function renderElements(ctx, elements, vp, selectedId, forExport) {
|
||
const W = ctx.canvas.width, H = ctx.canvas.height;
|
||
ctx.save();
|
||
// Hintergrund
|
||
ctx.fillStyle = BG;
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
ctx.translate(vp.x, vp.y);
|
||
ctx.scale(vp.zoom, vp.zoom);
|
||
|
||
for (const el of elements) {
|
||
ctx.save();
|
||
ctx.globalAlpha = 1;
|
||
ctx.strokeStyle = el.color || '#fff';
|
||
ctx.fillStyle = el.color || '#fff';
|
||
ctx.lineWidth = (el.size || 4) / vp.zoom;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
|
||
switch (el.type) {
|
||
case 'pen': {
|
||
if (!el.points?.length) break;
|
||
ctx.beginPath();
|
||
ctx.moveTo(el.points[0].x, el.points[0].y);
|
||
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
|
||
ctx.stroke();
|
||
break;
|
||
}
|
||
case 'eraser': {
|
||
if (!el.points?.length) break;
|
||
ctx.strokeStyle = BG;
|
||
ctx.lineWidth = (el.size||4) * 3 / vp.zoom;
|
||
ctx.beginPath();
|
||
ctx.moveTo(el.points[0].x, el.points[0].y);
|
||
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
|
||
ctx.stroke();
|
||
break;
|
||
}
|
||
case 'line': {
|
||
ctx.beginPath(); ctx.moveTo(el.x1, el.y1); ctx.lineTo(el.x2, el.y2); ctx.stroke();
|
||
break;
|
||
}
|
||
case 'rect':
|
||
ctx.strokeRect(el.x, el.y, el.w, el.h);
|
||
break;
|
||
case 'ellipse':
|
||
ctx.beginPath();
|
||
ctx.ellipse(el.cx, el.cy, Math.abs(el.rx||1), Math.abs(el.ry||1), 0, 0, Math.PI*2);
|
||
ctx.stroke();
|
||
break;
|
||
case 'text': {
|
||
const fs = el.fontSize || 20;
|
||
ctx.font = `${fs}px monospace`;
|
||
ctx.fillText(el.text, el.x, el.y);
|
||
break;
|
||
}
|
||
}
|
||
ctx.restore();
|
||
|
||
// Auswahl-Rahmen + Resize-Handle
|
||
if (!forExport && selectedId === el.id) {
|
||
const b = getBounds(el);
|
||
if (b) {
|
||
ctx.save();
|
||
ctx.strokeStyle = '#4ECDC4';
|
||
ctx.lineWidth = 1.5 / vp.zoom;
|
||
ctx.setLineDash([4/vp.zoom, 3/vp.zoom]);
|
||
ctx.strokeRect(b.x-2/vp.zoom, b.y-2/vp.zoom, b.w+4/vp.zoom, b.h+4/vp.zoom);
|
||
ctx.setLineDash([]);
|
||
// Resize-Handle rechts-unten
|
||
const hx = b.x+b.w, hy = b.y+b.h, hr = HANDLE_R/vp.zoom;
|
||
ctx.fillStyle = '#4ECDC4';
|
||
ctx.beginPath(); ctx.arc(hx, hy, hr, 0, Math.PI*2); ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// Offscreen-Export (ohne Auswahl-Rahmen, original Viewport)
|
||
function exportToPng(elements, title) {
|
||
if (!elements?.length) return; // Nichts zu exportieren
|
||
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
|
||
for (const el of elements) {
|
||
const b = getBounds(el); if (!b) continue;
|
||
minX=Math.min(minX,b.x); minY=Math.min(minY,b.y);
|
||
maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h);
|
||
}
|
||
if (!isFinite(minX)) { minX=0; minY=0; maxX=800; maxY=600; }
|
||
const pad=40, W=Math.max(800,maxX-minX+pad*2), H=Math.max(600,maxY-minY+pad*2);
|
||
const offscreen = document.createElement('canvas');
|
||
offscreen.width=W; offscreen.height=H;
|
||
const ctx = offscreen.getContext('2d');
|
||
renderElements(ctx, elements, { x: -minX+pad, y: -minY+pad, zoom:1 }, null, true);
|
||
const a = document.createElement('a');
|
||
a.href = offscreen.toDataURL('image/png');
|
||
a.download = (title||'whiteboard').replace(/[^\w\s-]/g,'').trim()+'.png';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
}
|
||
|
||
// ── Share-Modal ───────────────────────────────────────────────────────────────
|
||
function ShareModal({ wbId, onClose, toast }) {
|
||
const [allUsers, setAllUsers] = useState([]);
|
||
const [perms, setPerms] = useState([]);
|
||
const [saving, setSaving] = useState(false);
|
||
useEffect(() => {
|
||
Promise.all([api('/tools/whiteboard/users-list'), api(`/tools/whiteboard/${wbId}/data`)])
|
||
.then(([u,d]) => { setAllUsers(u.users||[]); setPerms(d.permissions||[]); }).catch(()=>{});
|
||
}, [wbId]);
|
||
const roleOf = uid => perms.find(p=>p.user_id===uid)?.role||'none';
|
||
const setRole = (uid, role) => setPerms(prev => {
|
||
const f = prev.filter(p=>p.user_id!==uid);
|
||
return role==='none' ? f : [...f,{user_id:uid,role}];
|
||
});
|
||
const save = async () => {
|
||
setSaving(true);
|
||
try {
|
||
await api(`/tools/whiteboard/${wbId}/permissions`,{ method:'PUT',
|
||
body:{ permissions: allUsers.map(u=>({ user_id:u.id, role: roleOf(u.id)==='none'?null:roleOf(u.id) })) }});
|
||
toast('Berechtigungen gespeichert ✓'); onClose();
|
||
} catch(e){ toast(e.message,'error'); } finally { setSaving(false); }
|
||
};
|
||
const mob = window.innerWidth < 768;
|
||
return (
|
||
<div onClick={e=>e.target===e.currentTarget&&onClose()} style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.75)',zIndex:2000,
|
||
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:20}}>
|
||
<div style={{background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',borderRadius:mob?'16px 16px 0 0':14,
|
||
width:'100%',maxWidth:440,display:'flex',flexDirection:'column',maxHeight:mob?'80vh':'75vh',
|
||
paddingBottom:mob?'calc(56px + env(safe-area-inset-bottom,0px))':0}}>
|
||
<div style={{padding:'18px 20px 0',flexShrink:0}}>
|
||
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
|
||
<div style={{...S.head,marginBottom:0}}>TEILEN</div>
|
||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||
</div>
|
||
</div>
|
||
<div style={{overflowY:'auto',padding:'0 20px',flex:1}}>
|
||
{allUsers.length===0&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine anderen Benutzer.</div>}
|
||
{allUsers.map(u=>(
|
||
<div key={u.id} style={{display:'flex',alignItems:'center',gap:8,padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||
<span style={{flex:1,fontFamily:'monospace',fontSize:13,color:'#fff'}}>{u.username}</span>
|
||
{['none','view','edit'].map(r=>(
|
||
<button key={r} onClick={()=>setRole(u.id,r)} style={{...S.btn(r==='edit'?'#4ECDC4':r==='view'?'#60A5FA':'#888888',true),
|
||
fontSize:10,padding:'3px 8px',
|
||
background:roleOf(u.id)===r?(r==='edit'?'rgba(78,205,196,0.2)':r==='view'?'rgba(96,165,250,0.2)':'rgba(136,136,136,0.2)'):'transparent',
|
||
fontWeight:roleOf(u.id)===r?700:400}}>
|
||
{r==='none'?'✕ kein':r==='view'?'👁 lesen':'✏ bearbeiten'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{padding:'14px 20px 20px',flexShrink:0,borderTop:'1px solid rgba(255,255,255,0.07)',display:'flex',gap:8,justifyContent:'flex-end'}}>
|
||
<button onClick={onClose} style={S.btn('#888888',true)}>Abbrechen</button>
|
||
<button onClick={save} disabled={saving} style={S.btn('#4ECDC4',true)}>{saving?'…':'Speichern'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||
export default function Whiteboard({ toast, mobile }) {
|
||
// View
|
||
const [view, setView] = useState('list');
|
||
const [boards, setBoards] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [current, setCurrent] = useState(null);
|
||
const [creating, setCreating] = useState(false);
|
||
const [newTitle, setNewTitle] = useState('');
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [editTitle, setEditTitle] = useState('');
|
||
|
||
// Canvas
|
||
const [elements, setElements] = useState([]);
|
||
const [undoStack, setUndoStack] = useState([]);
|
||
const [tool, setTool] = useState('pen');
|
||
const [color, setColor] = useState('#FFFFFF');
|
||
const [size, setSize] = useState(1);
|
||
const [fontSize, setFontSize] = useState(2); // Index in FONT_SIZES
|
||
const [viewport, setViewport] = useState({x:0,y:0,zoom:1});
|
||
const [saving, setSaving] = useState(false);
|
||
const [shareModal,setShareModal]= useState(false);
|
||
const [textInput, setTextInput] = useState(null);
|
||
const [textPreview, setTextPreview] = useState(null); // Live-Vorschau während Tippen
|
||
const [selectedId,setSelectedId]= useState(null);
|
||
|
||
// Kollaboration
|
||
const [collab, setCollab] = useState([]);
|
||
const wsRef = useRef(null);
|
||
const cursorTimers = useRef({});
|
||
|
||
// Canvas Refs
|
||
const canvasRef = useRef(null);
|
||
const drawing = useRef(false);
|
||
const currentEl = useRef(null);
|
||
const startPos = useRef({x:0,y:0});
|
||
const isPanning = useRef(false);
|
||
const isDragging = useRef(false); // Element verschieben
|
||
const isResizing = useRef(false); // Element resize
|
||
const panStart = useRef({x:0,y:0});
|
||
const dragStart = useRef({x:0,y:0}); // Startpos für Drag
|
||
const vpRef = useRef({x:0,y:0,zoom:1});
|
||
const elementsRef = useRef([]);
|
||
const selectedRef = useRef(null);
|
||
const textPreviewRef = useRef(null);
|
||
|
||
// ── Hilfsfunktionen ──────────────────────────────────────────────────────
|
||
const syncVp = (vp) => { vpRef.current=vp; setViewport({...vp}); };
|
||
|
||
const toCanvas = (e) => {
|
||
const rect = canvasRef.current.getBoundingClientRect();
|
||
const sx = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left;
|
||
const sy = (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - rect.top;
|
||
return { x:(sx-vpRef.current.x)/vpRef.current.zoom, y:(sy-vpRef.current.y)/vpRef.current.zoom, sx, sy };
|
||
};
|
||
|
||
// Ist der Pointer auf dem Resize-Handle des selektierten Elements?
|
||
const onResizeHandle = (cx, cy) => {
|
||
if (!selectedRef.current) return false;
|
||
const el = elementsRef.current.find(e=>e.id===selectedRef.current);
|
||
if (!el) return false;
|
||
const b = getBounds(el); if (!b) return false;
|
||
const hxS = (b.x+b.w)*vpRef.current.zoom + vpRef.current.x;
|
||
const hyS = (b.y+b.h)*vpRef.current.zoom + vpRef.current.y;
|
||
const rect = canvasRef.current.getBoundingClientRect();
|
||
const mxS = cx + rect.left - rect.left;
|
||
const myS = cy + rect.top - rect.top;
|
||
// cx/cy sind schon in Screen-Koordinaten (sx,sy aus toCanvas)
|
||
return Math.hypot(cx - (b.x+b.w)*vpRef.current.zoom - vpRef.current.x,
|
||
cy - (b.y+b.h)*vpRef.current.zoom - vpRef.current.y) <= HANDLE_R + 4;
|
||
};
|
||
|
||
// ── Element-Operationen ──────────────────────────────────────────────────
|
||
const broadcast = (els) => {
|
||
wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'elements',elements:els}));
|
||
};
|
||
|
||
const pushElement = (el) => {
|
||
const next = [...elementsRef.current, el];
|
||
elementsRef.current = next;
|
||
setUndoStack(prev => [...prev, elementsRef.current.slice(0,-1)]);
|
||
setElements([...next]);
|
||
broadcast(next);
|
||
};
|
||
|
||
const updateLastElement = (el) => {
|
||
const next = [...elementsRef.current.slice(0,-1), el];
|
||
elementsRef.current = next;
|
||
setElements([...next]);
|
||
};
|
||
|
||
const finalizeLastElement = (el) => {
|
||
const next = [...elementsRef.current.slice(0,-1), el];
|
||
elementsRef.current = next;
|
||
setElements([...next]);
|
||
broadcast(next);
|
||
};
|
||
|
||
const updateElement = (id, updater) => {
|
||
const next = elementsRef.current.map(e => e.id===id ? updater(e) : e);
|
||
elementsRef.current = next;
|
||
setElements([...next]);
|
||
broadcast(next);
|
||
setUndoStack(prev => [...prev, elementsRef.current]);
|
||
};
|
||
|
||
const undo = () => {
|
||
if (!undoStack.length) return;
|
||
const prev = undoStack[undoStack.length-1];
|
||
setUndoStack(s => s.slice(0,-1));
|
||
elementsRef.current = prev;
|
||
setElements([...prev]);
|
||
broadcast(prev);
|
||
setSelectedId(null); selectedRef.current=null;
|
||
};
|
||
|
||
// ── Laden / Speichern ────────────────────────────────────────────────────
|
||
const loadBoards = useCallback(() => {
|
||
setLoading(true);
|
||
api('/tools/whiteboard').then(d=>setBoards(d.whiteboards||[])).catch(()=>toast('Fehler','error')).finally(()=>setLoading(false));
|
||
}, []);
|
||
|
||
const renameBoard = async (id, title) => {
|
||
if (!title?.trim()) return;
|
||
try {
|
||
await api(`/tools/whiteboard/${id}/title`,{method:'PATCH',body:{title:title.trim()}});
|
||
setBoards(prev=>prev.map(b=>b.id===id?{...b,title:title.trim()}:b));
|
||
if (current?.id===id) setCurrent(prev=>({...prev,title:title.trim()}));
|
||
} catch(e){ toast(e.message,'error'); } finally { setEditingId(null); }
|
||
};
|
||
|
||
useEffect(()=>{ loadBoards(); },[loadBoards]);
|
||
|
||
const openBoard = async (board) => {
|
||
try {
|
||
const d = await api(`/tools/whiteboard/${board.id}/data`);
|
||
const els = JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
|
||
const vp = JSON.parse(typeof d.viewport==='string'?d.viewport:JSON.stringify(d.viewport||{x:0,y:0,zoom:1}));
|
||
elementsRef.current=els; vpRef.current=vp;
|
||
setElements(els); setViewport(vp); setUndoStack([]);
|
||
setCurrent({id:board.id,title:d.title,role:d.role});
|
||
setView('canvas'); connectWs(board.id);
|
||
} catch(e){ toast(e.message,'error'); }
|
||
};
|
||
|
||
const closeBoard = () => {
|
||
disconnectWs(); setView('list'); setCurrent(null);
|
||
setElements([]); setUndoStack([]); setCollab([]);
|
||
setSelectedId(null); selectedRef.current=null;
|
||
loadBoards();
|
||
};
|
||
|
||
const save = async () => {
|
||
if (saving) return; setSaving(true);
|
||
try {
|
||
await api(`/tools/whiteboard/${current.id}/save`,{body:{elements:elementsRef.current,viewport:vpRef.current}});
|
||
toast('Gespeichert ✓');
|
||
} catch(e){ toast(e.message,'error'); } finally { setSaving(false); }
|
||
};
|
||
|
||
// ── WebSocket ─────────────────────────────────────────────────────────────
|
||
const connectWs = useCallback((wbId) => {
|
||
const token = localStorage.getItem('sk_token');
|
||
const proto = location.protocol==='https:'?'wss':'ws';
|
||
const ws = new WebSocket(`${proto}://${location.host}/ws/whiteboard?token=${token}&id=${wbId}`);
|
||
wsRef.current = ws;
|
||
const cc = {}; let ci=0;
|
||
ws.onmessage = (e) => {
|
||
const msg = JSON.parse(e.data);
|
||
switch(msg.type) {
|
||
case 'user_join':
|
||
if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length];
|
||
setCollab(prev=>[...prev.filter(c=>c.userId!==msg.userId),{userId:msg.userId,username:msg.username,color:cc[msg.userId],x:0,y:0}]); break;
|
||
case 'user_leave':
|
||
setCollab(prev=>prev.filter(c=>c.userId!==msg.userId)); break;
|
||
case 'active_users':
|
||
msg.users.forEach(u=>{ if(!cc[u.userId]) cc[u.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length]; });
|
||
setCollab(msg.users.map(u=>({...u,color:cc[u.userId]||CURSOR_COLORS[0],x:0,y:0}))); break;
|
||
case 'cursor':
|
||
if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length];
|
||
setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:msg.x,y:msg.y}:c));
|
||
clearTimeout(cursorTimers.current[msg.userId]);
|
||
cursorTimers.current[msg.userId]=setTimeout(()=>setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:-9999,y:-9999}:c)),3000); break;
|
||
case 'elements':
|
||
elementsRef.current=msg.elements; setElements([...msg.elements]); break;
|
||
}
|
||
};
|
||
ws.onclose=()=>setCollab([]); ws.onerror=()=>{};
|
||
},[]);
|
||
|
||
const disconnectWs = useCallback(()=>{ wsRef.current?.close(); wsRef.current=null; setCollab([]); },[]);
|
||
|
||
// ── Canvas-Rendering ──────────────────────────────────────────────────────
|
||
const render = useCallback(() => {
|
||
const canvas = canvasRef.current; if(!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
renderElements(ctx, elementsRef.current, vpRef.current, selectedRef.current, false);
|
||
// Cursor anderer User
|
||
for (const c of collab) {
|
||
if (c.x===-9999) continue;
|
||
const sx = c.x*vpRef.current.zoom+vpRef.current.x;
|
||
const sy = c.y*vpRef.current.zoom+vpRef.current.y;
|
||
ctx.save(); ctx.fillStyle=c.color; ctx.font='10px monospace';
|
||
ctx.fillText('▶ '+c.username, sx+6, sy-4); ctx.restore();
|
||
}
|
||
// Text-Vorschau während Tippen
|
||
if (textPreviewRef.current) {
|
||
const { text, x, y, fontSize, color } = textPreviewRef.current;
|
||
const vp = vpRef.current;
|
||
const sx = x * vp.zoom + vp.x;
|
||
const sy = y * vp.zoom + vp.y;
|
||
ctx.save();
|
||
ctx.font = `${fontSize * vp.zoom}px monospace`;
|
||
ctx.fillStyle = color;
|
||
ctx.globalAlpha = 0.85;
|
||
ctx.fillText(text || '|', sx, sy);
|
||
// Cursor-Blinken simulieren: kleiner Strich hinter dem Text
|
||
if (text) {
|
||
const tw = ctx.measureText(text).width;
|
||
ctx.fillRect(sx + tw + 1, sy - fontSize * vp.zoom * 0.8, 2, fontSize * vp.zoom);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
}, [collab, textPreview]);
|
||
|
||
useEffect(()=>{ render(); }, [elements, viewport, selectedId, collab, render]);
|
||
|
||
// Canvas-Resize
|
||
useEffect(()=>{
|
||
if(view!=='canvas') return;
|
||
const resize=()=>{ const c=canvasRef.current; if(!c) return; c.width=c.offsetWidth; c.height=c.offsetHeight; render(); };
|
||
resize(); window.addEventListener('resize',resize);
|
||
return ()=>window.removeEventListener('resize',resize);
|
||
},[view, render]);
|
||
|
||
// ── Pointer-Events ────────────────────────────────────────────────────────
|
||
const onPointerDown = (e) => {
|
||
if (current?.role==='view') return;
|
||
e.preventDefault();
|
||
const { x, y, sx, sy } = toCanvas(e);
|
||
|
||
// Panning: Mittelklick oder Leerzeichen-Drag (über CSS cursor:grab bei select-tool)
|
||
if (e.button===1) {
|
||
isPanning.current=true;
|
||
panStart.current={ x: sx-vpRef.current.x, y: sy-vpRef.current.y }; return;
|
||
}
|
||
|
||
// Select-Tool
|
||
if (tool==='select') {
|
||
// Resize-Handle?
|
||
if (onResizeHandle(sx, sy)) {
|
||
isResizing.current=true;
|
||
dragStart.current={x,y}; return;
|
||
}
|
||
// Element treffen?
|
||
const hit = [...elementsRef.current].reverse().find(el=>hitTest(el,x,y));
|
||
if (hit) {
|
||
selectedRef.current=hit.id; setSelectedId(hit.id);
|
||
isDragging.current=true;
|
||
dragStart.current={x,y}; return;
|
||
}
|
||
// Leerer Klick → Auswahl aufheben
|
||
selectedRef.current=null; setSelectedId(null); return;
|
||
}
|
||
|
||
// Text
|
||
if (tool==='text') {
|
||
textPreviewRef.current = { text:'', x, y, fontSize:FONT_SIZES[fontSize], color };
|
||
render();
|
||
setTextInput({ sx, sy, x, y }); return;
|
||
}
|
||
|
||
drawing.current=true; startPos.current={x,y};
|
||
|
||
if (tool==='pen'||tool==='eraser') {
|
||
const el={id:getId(),type:tool,color,size:SIZES[size],points:[{x,y}]};
|
||
currentEl.current=el; pushElement(el);
|
||
} else if (tool==='line') {
|
||
const el={id:getId(),type:'line',color,size:SIZES[size],x1:x,y1:y,x2:x,y2:y};
|
||
currentEl.current=el; pushElement(el);
|
||
} else if (tool==='rect') {
|
||
const el={id:getId(),type:'rect',color,size:SIZES[size],x,y,w:0,h:0};
|
||
currentEl.current=el; pushElement(el);
|
||
} else if (tool==='ellipse') {
|
||
const el={id:getId(),type:'ellipse',color,size:SIZES[size],cx:x,cy:y,rx:0,ry:0};
|
||
currentEl.current=el; pushElement(el);
|
||
}
|
||
};
|
||
|
||
const onPointerMove = (e) => {
|
||
const { x, y, sx, sy } = toCanvas(e);
|
||
wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'cursor',x,y}));
|
||
|
||
if (isPanning.current) {
|
||
syncVp({ ...vpRef.current, x: sx-panStart.current.x, y: sy-panStart.current.y }); return;
|
||
}
|
||
|
||
if (isDragging.current && selectedRef.current) {
|
||
const dx=x-dragStart.current.x, dy=y-dragStart.current.y;
|
||
dragStart.current={x,y};
|
||
const next=elementsRef.current.map(el=>el.id===selectedRef.current?moveElement(el,dx,dy):el);
|
||
elementsRef.current=next; setElements([...next]); return;
|
||
}
|
||
|
||
if (isResizing.current && selectedRef.current) {
|
||
const next=elementsRef.current.map(el=>el.id===selectedRef.current?resizeElement(el,x,y):el);
|
||
elementsRef.current=next; setElements([...next]); return;
|
||
}
|
||
|
||
if (!drawing.current||!currentEl.current) return;
|
||
const el=currentEl.current;
|
||
|
||
if (tool==='pen'||tool==='eraser') {
|
||
const updated={...el,points:[...el.points,{x,y}]};
|
||
currentEl.current=updated; updateLastElement(updated);
|
||
} else if (tool==='line') {
|
||
const updated={...el,x2:x,y2:y}; currentEl.current=updated; updateLastElement(updated);
|
||
} else if (tool==='rect') {
|
||
const updated={...el,w:x-el.x,h:y-el.y}; currentEl.current=updated; updateLastElement(updated);
|
||
} else if (tool==='ellipse') {
|
||
const updated={...el,rx:x-el.cx,ry:y-el.cy}; currentEl.current=updated; updateLastElement(updated);
|
||
}
|
||
};
|
||
|
||
const onPointerUp = () => {
|
||
isPanning.current=false;
|
||
if (isDragging.current||isResizing.current) {
|
||
broadcast(elementsRef.current);
|
||
setUndoStack(prev=>[...prev, elementsRef.current]);
|
||
}
|
||
isDragging.current=false; isResizing.current=false;
|
||
if (!drawing.current) return;
|
||
drawing.current=false;
|
||
if (currentEl.current) { finalizeLastElement(currentEl.current); currentEl.current=null; }
|
||
};
|
||
|
||
const onWheel = (e) => {
|
||
e.preventDefault();
|
||
const delta=e.deltaY>0?0.9:1.1;
|
||
const rect=canvasRef.current.getBoundingClientRect();
|
||
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
|
||
const nz=Math.max(0.1,Math.min(8, vpRef.current.zoom*delta));
|
||
syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom),
|
||
y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz });
|
||
};
|
||
|
||
const zoom = (factor) => {
|
||
const nz=Math.max(0.1,Math.min(8,vpRef.current.zoom*factor));
|
||
const canvas=canvasRef.current; if(!canvas) return;
|
||
const mx=canvas.width/2, my=canvas.height/2;
|
||
syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom),
|
||
y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz });
|
||
};
|
||
|
||
// Tastenkürzel
|
||
useEffect(()=>{
|
||
if(view!=='canvas') return;
|
||
const onKey=(e)=>{
|
||
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
|
||
if((e.ctrlKey||e.metaKey)&&e.key==='z'){ e.preventDefault(); undo(); }
|
||
if((e.ctrlKey||e.metaKey)&&e.key==='s'){ e.preventDefault(); save(); }
|
||
if((e.ctrlKey||e.metaKey)&&(e.key==='='||e.key==='+')){ e.preventDefault(); zoom(1.2); }
|
||
if((e.ctrlKey||e.metaKey)&&e.key==='-'){ e.preventDefault(); zoom(0.8); }
|
||
if((e.ctrlKey||e.metaKey)&&e.key==='0'){ e.preventDefault(); syncVp({x:0,y:0,zoom:1}); }
|
||
if(e.key==='Escape'){ setSelectedId(null); selectedRef.current=null; }
|
||
if((e.key==='Delete'||e.key==='Backspace')&&selectedRef.current&&e.target.tagName!=='INPUT'){
|
||
e.preventDefault();
|
||
const next=elementsRef.current.filter(el=>el.id!==selectedRef.current);
|
||
setUndoStack(prev=>[...prev,elementsRef.current]);
|
||
elementsRef.current=next; setElements([...next]); broadcast(next);
|
||
setSelectedId(null); selectedRef.current=null;
|
||
}
|
||
};
|
||
window.addEventListener('keydown',onKey);
|
||
return ()=>window.removeEventListener('keydown',onKey);
|
||
},[view, undoStack]);
|
||
|
||
// ── Cursor-Style ──────────────────────────────────────────────────────────
|
||
const getCursor = () => {
|
||
if (tool==='select') return isDragging.current?'grabbing':isResizing.current?'nwse-resize':'default';
|
||
if (tool==='eraser') return 'cell';
|
||
if (tool==='text') return 'text';
|
||
return 'crosshair';
|
||
};
|
||
|
||
// ── Render: Liste ─────────────────────────────────────────────────────────
|
||
if (view==='list') return (
|
||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px'}}>
|
||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
|
||
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>WHITEBOARD</h2>
|
||
<div style={{flex:1}}/>
|
||
{creating ? (
|
||
<div style={{display:'flex',gap:6}}>
|
||
<input autoFocus value={newTitle} onChange={e=>setNewTitle(e.target.value)}
|
||
onKeyDown={async e=>{
|
||
if(e.key==='Enter'&&newTitle.trim()){
|
||
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
|
||
catch(err){ toast(err.message,'error'); }
|
||
}
|
||
if(e.key==='Escape'){setCreating(false);setNewTitle('');}
|
||
}}
|
||
placeholder="Name des Whiteboards" style={{...S.inp,width:mobile?160:220,fontSize:12}}/>
|
||
<button onClick={async()=>{
|
||
if(!newTitle.trim()) return;
|
||
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
|
||
catch(err){ toast(err.message,'error'); }
|
||
}} style={S.btn('#4ECDC4',true)}>Erstellen</button>
|
||
<button onClick={()=>{setCreating(false);setNewTitle('');}} style={S.btn('#888888',true)}>✕</button>
|
||
</div>
|
||
) : (
|
||
<button onClick={()=>setCreating(true)} style={S.btn('#4ECDC4')}>+ Neu</button>
|
||
)}
|
||
</div>
|
||
|
||
{loading&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt…</div>}
|
||
{!loading&&boards.length===0&&(
|
||
<div style={{...S.card,textAlign:'center',padding:40,color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12}}>
|
||
Noch kein Whiteboard. Erstelle eines mit "+ Neu".
|
||
</div>
|
||
)}
|
||
|
||
{boards.map(b=>(
|
||
<div key={b.id} style={{...S.card,display:'flex',alignItems:'center',gap:12,marginBottom:8,cursor:'pointer'}}
|
||
onClick={()=>{ if(editingId!==b.id) openBoard(b); }}>
|
||
<div style={{fontSize:24}}>🖊</div>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
{editingId===b.id?(
|
||
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
|
||
onKeyDown={e=>{if(e.key==='Enter') renameBoard(b.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
|
||
onBlur={()=>renameBoard(b.id,editTitle)}
|
||
onClick={e=>e.stopPropagation()}
|
||
style={{...S.inp,fontSize:12,fontWeight:700,padding:'3px 8px',width:'100%'}}/>
|
||
):(
|
||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}
|
||
onDoubleClick={e=>{e.stopPropagation();if(b.role==='owner'||b.role==='edit'){setEditingId(b.id);setEditTitle(b.title);}}}>
|
||
{b.title}
|
||
</div>
|
||
)}
|
||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
|
||
{b.role==='owner'?'👑 Eigenes':b.role==='edit'?'✏ Bearbeiten':'👁 Lesen'}
|
||
{b.owner_name&&b.role!=='owner'&&` · von ${b.owner_name}`}
|
||
{' · '+new Date(b.updated_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})}
|
||
</div>
|
||
</div>
|
||
<div style={{display:'flex',gap:4,flexShrink:0}}>
|
||
<button onClick={e=>{e.stopPropagation();
|
||
api(`/tools/whiteboard/${b.id}/data`).then(d=>{
|
||
const els=JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
|
||
exportToPng(els,b.title);
|
||
}).catch(()=>toast('Export fehlgeschlagen','error'));
|
||
}} title="Als PNG exportieren"
|
||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}>⬇</button>
|
||
{(b.role==='owner'||b.role==='edit')&&(
|
||
<button onClick={e=>{e.stopPropagation();setEditingId(b.id);setEditTitle(b.title);}} title="Umbenennen"
|
||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}>✎</button>
|
||
)}
|
||
{b.role==='owner'&&(
|
||
<button onClick={e=>{e.stopPropagation();if(confirm(`"${b.title}" löschen?`))
|
||
api(`/tools/whiteboard/${b.id}`,{method:'DELETE'}).then(loadBoards).catch(err=>toast(err.message,'error'));
|
||
}} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:16,padding:'4px 6px'}}>✕</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
// ── Render: Canvas ────────────────────────────────────────────────────────
|
||
const canEdit = current?.role !== 'view';
|
||
|
||
return (
|
||
<div style={{display:'flex',flexDirection:'column',height:'100%',background:BG,position:'relative'}}>
|
||
|
||
{shareModal&¤t?.role==='owner'&&(
|
||
<ShareModal wbId={current.id} onClose={()=>setShareModal(false)} toast={toast}/>
|
||
)}
|
||
|
||
{textInput&&(
|
||
mobile ? (
|
||
<div style={{position:'fixed',bottom:0,left:0,right:0,zIndex:500,
|
||
background:'#1a1a1e',borderTop:'1px solid rgba(255,255,255,0.15)',
|
||
padding:'12px 16px',paddingBottom:'calc(12px + env(safe-area-inset-bottom,0px))'}}>
|
||
<div style={{fontSize:10,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',marginBottom:6}}>
|
||
TEXT EINGEBEN · Enter = bestätigen · Esc = abbrechen
|
||
</div>
|
||
<div style={{display:'flex',gap:8}}>
|
||
<input autoFocus
|
||
onChange={e=>{
|
||
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
|
||
render();
|
||
}}
|
||
onKeyDown={e=>{
|
||
if(e.key==='Enter'){
|
||
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
|
||
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
|
||
}
|
||
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
|
||
}}
|
||
placeholder="Text eingeben…"
|
||
style={{...S.inp,flex:1,fontSize:14,color}}/>
|
||
<button onMouseDown={e=>{
|
||
e.preventDefault();
|
||
const inp=e.currentTarget.previousSibling;
|
||
const v=inp?.value||''; textPreviewRef.current=null; setTextInput(null);
|
||
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
|
||
}} style={S.btn('#4ECDC4',true)}>✓</button>
|
||
<button onMouseDown={e=>{e.preventDefault();textPreviewRef.current=null;setTextInput(null);render();}} style={S.btn('#888888',true)}>✕</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{position:'absolute',left:textInput.sx,top:textInput.sy,zIndex:100,pointerEvents:'auto'}}>
|
||
<input autoFocus
|
||
onChange={e=>{
|
||
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
|
||
render();
|
||
}}
|
||
onKeyDown={e=>{
|
||
if(e.key==='Enter'){
|
||
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
|
||
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
|
||
}
|
||
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
|
||
}}
|
||
onBlur={e=>{
|
||
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
|
||
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
|
||
}}
|
||
style={{background:'rgba(0,0,0,0.7)',border:'none',
|
||
outline:`1px dashed ${color}`,borderRadius:4,padding:'2px 6px',
|
||
color,fontSize:FONT_SIZES[fontSize],fontFamily:'monospace',minWidth:120}}/>
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* ── Topbar ───────────────────────────────────────────────────────── */}
|
||
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
|
||
background:'rgba(0,0,0,0.6)',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,flexWrap:'wrap'}}>
|
||
<button onClick={closeBoard} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:18,padding:'2px 6px',lineHeight:1}}>←</button>
|
||
{editingId===current?.id?(
|
||
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
|
||
onKeyDown={e=>{if(e.key==='Enter') renameBoard(current.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
|
||
onBlur={()=>renameBoard(current.id,editTitle)}
|
||
style={{...S.inp,fontSize:13,fontWeight:700,flex:1,padding:'3px 8px'}}/>
|
||
):(
|
||
<span onDoubleClick={()=>{if(current?.role!=='view'){setEditingId(current.id);setEditTitle(current.title);}}}
|
||
title={current?.role!=='view'?'Doppelklick zum Umbenennen':''}
|
||
style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,flex:1,minWidth:0,
|
||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',cursor:current?.role!=='view'?'text':'default'}}>
|
||
{current?.title}
|
||
{current?.role==='view'&&<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,marginLeft:8}}>👁 nur lesen</span>}
|
||
</span>
|
||
)}
|
||
{collab.length>0&&(
|
||
<div style={{display:'flex',gap:4,alignItems:'center'}}>
|
||
{collab.map(c=>(
|
||
<span key={c.userId} style={{background:c.color,color:'#000',fontFamily:'monospace',fontSize:9,borderRadius:10,padding:'2px 7px',fontWeight:700}}>
|
||
{c.username[0].toUpperCase()}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<button onClick={()=>exportToPng(elementsRef.current,current?.title)}
|
||
style={{...S.btn('#60A5FA',true),fontSize:11}}>⬇ PNG</button>
|
||
{canEdit&&<button onClick={save} disabled={saving} style={{...S.btn('#4ECDC4',true),fontSize:11}}>{saving?'…':'💾 Speichern'}</button>}
|
||
{current?.role==='owner'&&(
|
||
<button onClick={()=>setShareModal(true)} style={{...S.btn('#A78BFA',true),fontSize:11}}>👥 Teilen</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
|
||
{canEdit&&(
|
||
<div style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
|
||
background:'rgba(0,0,0,0.45)',borderBottom:'1px solid rgba(255,255,255,0.06)',
|
||
flexShrink:0,flexWrap:'wrap'}}>
|
||
|
||
{/* Tools */}
|
||
<div style={{display:'flex',gap:3}}>
|
||
{TOOLS.map(t=>(
|
||
<button key={t.id} onClick={()=>{
|
||
setTool(t.id); setSelectedId(null); selectedRef.current=null;
|
||
textPreviewRef.current=null; setTextInput(null); render();
|
||
}} title={t.tip}
|
||
style={{background:tool===t.id?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||
border:`1px solid ${tool===t.id?'#4ECDC4':'rgba(255,255,255,0.1)'}`,
|
||
borderRadius:7,padding:'5px 9px',color:tool===t.id?'#4ECDC4':'rgba(255,255,255,0.7)',
|
||
cursor:'pointer',fontSize:14,lineHeight:1,fontFamily:'monospace'}}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||
|
||
{/* Farben */}
|
||
<div style={{display:'flex',gap:3}}>
|
||
{COLORS.map(c=>(
|
||
<button key={c} onClick={()=>setColor(c)}
|
||
style={{width:18,height:18,borderRadius:'50%',background:c,
|
||
border:color===c?'2px solid #fff':'1px solid rgba(255,255,255,0.2)',cursor:'pointer',padding:0,flexShrink:0}}/>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||
|
||
{/* Strichstärke */}
|
||
<div style={{display:'flex',gap:3,alignItems:'center'}}>
|
||
{SIZES.map((s,i)=>(
|
||
<button key={i} onClick={()=>setSize(i)}
|
||
style={{width:18,height:18,borderRadius:'50%',
|
||
background:size===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||
border:`1px solid ${size===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
|
||
cursor:'pointer',padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||
<div style={{width:s+2,height:s+2,borderRadius:'50%',background:size===i?'#4ECDC4':'#888'}}/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Textgröße — nur bei Text-Tool */}
|
||
{tool==='text'&&<>
|
||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9}}>TEXT</span>
|
||
<div style={{display:'flex',gap:3,alignItems:'center'}}>
|
||
{FONT_SIZES.map((fs,i)=>(
|
||
<button key={i} onClick={()=>setFontSize(i)}
|
||
style={{padding:'2px 6px',borderRadius:6,fontFamily:'monospace',fontSize:Math.min(14,fs/2)+1,
|
||
background:fontSize===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||
border:`1px solid ${fontSize===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
|
||
color:fontSize===i?'#4ECDC4':'rgba(255,255,255,0.6)',cursor:'pointer'}}>
|
||
{fs}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>}
|
||
|
||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||
|
||
{/* Undo + Löschen */}
|
||
<button onClick={undo} disabled={!undoStack.length} title="Rückgängig (Ctrl+Z)"
|
||
style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
|
||
borderRadius:7,padding:'5px 9px',color:undoStack.length?'rgba(255,255,255,0.7)':'rgba(255,255,255,0.2)',
|
||
cursor:undoStack.length?'pointer':'default',fontSize:12,fontFamily:'monospace'}}>↩</button>
|
||
|
||
{selectedId&&(
|
||
<button onClick={()=>{
|
||
const next=elementsRef.current.filter(el=>el.id!==selectedId);
|
||
setUndoStack(prev=>[...prev,elementsRef.current]);
|
||
elementsRef.current=next; setElements([...next]); broadcast(next);
|
||
setSelectedId(null); selectedRef.current=null;
|
||
}} style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.25)',
|
||
borderRadius:7,padding:'5px 9px',color:'#ff6b9d',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
|
||
✕ Löschen
|
||
</button>
|
||
)}
|
||
|
||
<button onClick={()=>{if(confirm('Alles löschen?')){
|
||
elementsRef.current=[];setElements([]);setUndoStack([]);broadcast([]);
|
||
setSelectedId(null);selectedRef.current=null;
|
||
}}} style={{background:'rgba(255,107,157,0.05)',border:'1px solid rgba(255,107,157,0.15)',
|
||
borderRadius:7,padding:'5px 9px',color:'rgba(255,107,157,0.5)',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
|
||
🗑
|
||
</button>
|
||
|
||
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
|
||
|
||
{/* Zoom */}
|
||
<button onClick={()=>zoom(1.2)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}>+</button>
|
||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,minWidth:32,textAlign:'center'}}>
|
||
{Math.round(viewport.zoom*100)}%
|
||
</span>
|
||
<button onClick={()=>zoom(0.8)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}>-</button>
|
||
<button onClick={()=>syncVp({x:0,y:0,zoom:1})}
|
||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontFamily:'monospace',fontSize:9}}>
|
||
Reset
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Canvas ───────────────────────────────────────────────────────── */}
|
||
<canvas ref={canvasRef}
|
||
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp} onPointerLeave={onPointerUp} onWheel={onWheel}
|
||
style={{flex:1,width:'100%',touchAction:'none',cursor:getCursor()}}
|
||
/>
|
||
|
||
{/* Hinweis: Select-Tool Aktionen */}
|
||
{tool==='select'&&selectedId&&!mobile&&(
|
||
<div style={{position:'absolute',bottom:12,left:'50%',transform:'translateX(-50%)',
|
||
background:'rgba(0,0,0,0.7)',border:'1px solid rgba(255,255,255,0.1)',
|
||
borderRadius:8,padding:'5px 12px',fontFamily:'monospace',fontSize:10,color:'rgba(255,255,255,0.4)',
|
||
pointerEvents:'none',whiteSpace:'nowrap'}}>
|
||
Ziehen: verschieben · Ecke (◉) ziehen: skalieren · Entf: löschen
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|