Files
ihk-projekt/Smartes-Klassenzimmer-Frontend/src/routes/whiteboard/+page.svelte
2025-12-10 20:20:39 +01:00

881 lines
21 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte.js';
import { whiteboardStore, type DrawingData } from '$lib/stores/whiteboard.svelte.js';
import { goto } from '$app/navigation';
type Tool = 'pen' | 'rectangle' | 'circle' | 'line' | 'text' | 'eraser' | 'select';
interface DrawnElement {
id?: string;
type: 'text' | 'pen' | 'line' | 'rectangle' | 'circle';
x: number;
y: number;
text?: string;
fontSize?: number;
color: string;
lineWidth?: number;
points?: { x: number; y: number }[];
width?: number;
height?: number;
radius?: number;
}
let canvas: HTMLCanvasElement | undefined;
let ctx: CanvasRenderingContext2D | null = null;
let isDrawing = $state(false);
let currentTool = $state<Tool>('pen');
let currentColor = $state('#000000');
let lineWidth = $state(2);
let startX = 0;
let startY = 0;
let snapshot: ImageData | null = null;
let textInput = $state('');
let showTextInput = $state(false);
let textInputX = $state(0);
let textInputY = $state(0);
let fontSize = $state(20);
let textInputElement = $state<HTMLTextAreaElement>();
let elements = $state<DrawnElement[]>([]);
let selectedElement = $state<DrawnElement | null>(null);
let isDragging = $state(false);
let dragOffsetX = 0;
let dragOffsetY = 0;
let currentDrawingPoints: { x: number; y: number }[] = [];
// Reaktiver Effekt: Zeichne Canvas neu, wenn sich Zeichnungen ändern
$effect(() => {
// Zugriff auf drawings triggert den Effekt
const drawings = whiteboardStore.drawings;
// Nur neu zeichnen, wenn gerade nicht aktiv gezeichnet wird
if (drawings.length >= 0 && !isDrawing) {
redrawCanvas();
}
});
onMount(async () => {
// Warte bis Auth-Store geladen ist
while (authStore.isLoading) {
await new Promise(resolve => setTimeout(resolve, 50));
}
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
if (!canvas) return;
ctx = canvas.getContext('2d');
if (ctx) {
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// WebSocket-Verbindung herstellen
const whiteboardId = 'classroom-1'; // Oder dynamisch aus URL
const userId = authStore.user?.username || 'anonymous';
whiteboardStore.connect(whiteboardId, userId);
});
onDestroy(() => {
window.removeEventListener('resize', resizeCanvas);
whiteboardStore.disconnect();
});
function resizeCanvas() {
if (!canvas) return;
const container = canvas.parentElement;
if (container) {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
redrawCanvas();
}
}
function redrawCanvas() {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Get IDs of local elements to skip
const localIds = new Set(elements.map(e => e.id).filter(Boolean));
// Zeichne alle Remote-Zeichnungen vom Server
whiteboardStore.drawings.forEach((drawing) => {
// Skip if this drawing is locally managed (interactive)
if (drawing.id && localIds.has(drawing.id)) return;
drawRemoteDrawing(drawing);
});
// Zeichne alle lokalen gespeicherten Elemente
elements.forEach((element) => {
if (element.type === 'text' && element.text) {
ctx!.font = `${element.fontSize}px sans-serif`;
ctx!.fillStyle = element.color;
const lines = element.text.split('\n');
lines.forEach((line, index) => {
ctx!.fillText(line, element.x, element.y + index * (element.fontSize || 20) * 1.2);
});
}
});
}
function drawRemoteDrawing(drawing: DrawingData) {
if (!ctx || drawing.points.length === 0) return;
console.log('🖌️ Drawing remote:', drawing.tool, 'points:', drawing.points.length);
if (drawing.tool === 'pen') {
ctx.strokeStyle = drawing.color || '#000000';
ctx.lineWidth = drawing.lineWidth || 2;
ctx.beginPath();
ctx.moveTo(drawing.points[0].x, drawing.points[0].y);
drawing.points.forEach(point => {
ctx!.lineTo(point.x, point.y);
});
ctx.stroke();
} else if (drawing.tool === 'eraser') {
// Richtiger Radierer: Lösche tatsächlich Pixel
ctx.globalCompositeOperation = 'destination-out';
ctx.lineWidth = (drawing.lineWidth || 2) * 3;
ctx.beginPath();
ctx.moveTo(drawing.points[0].x, drawing.points[0].y);
drawing.points.forEach(point => {
ctx!.lineTo(point.x, point.y);
});
ctx.stroke();
// Setze zurück auf normal
ctx.globalCompositeOperation = 'source-over';
} else if (drawing.tool === 'rectangle' && drawing.points.length >= 2) {
ctx.strokeStyle = drawing.color || '#000000';
ctx.lineWidth = drawing.lineWidth || 2;
ctx.fillStyle = (drawing.color || '#000000') + '30';
const start = drawing.points[0];
const end = drawing.points[drawing.points.length - 1];
ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y);
ctx.fillRect(start.x, start.y, end.x - start.x, end.y - start.y);
} else if (drawing.tool === 'circle' && drawing.points.length >= 2) {
ctx.strokeStyle = drawing.color || '#000000';
ctx.lineWidth = drawing.lineWidth || 2;
ctx.fillStyle = (drawing.color || '#000000') + '30';
const start = drawing.points[0];
const end = drawing.points[drawing.points.length - 1];
const radius = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
ctx.beginPath();
ctx.arc(start.x, start.y, radius, 0, 2 * Math.PI);
ctx.stroke();
ctx.fill();
} else if (drawing.tool === 'line' && drawing.points.length >= 2) {
ctx.strokeStyle = drawing.color || '#000000';
ctx.lineWidth = drawing.lineWidth || 2;
const start = drawing.points[0];
const end = drawing.points[drawing.points.length - 1];
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
} else if (drawing.tool === 'text' && drawing.text) {
const start = drawing.points[0];
ctx.font = `20px sans-serif`;
ctx.fillStyle = drawing.color || '#000000';
ctx.fillText(drawing.text, start.x, start.y);
}
}
function startDrawing(e: MouseEvent) {
if (!ctx || !canvas) return;
const rect = canvas.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
if (currentTool === 'select') {
// Prüfe, ob ein Text-Element angeklickt wurde
const clickedElement = findElementAtPoint(startX, startY);
if (clickedElement) {
selectedElement = clickedElement;
isDragging = true;
dragOffsetX = startX - clickedElement.x;
dragOffsetY = startY - clickedElement.y;
}
return;
}
if (currentTool === 'text') {
// Text-Werkzeug: Zeige Eingabefeld an der Klickposition
textInputX = e.clientX;
textInputY = e.clientY;
textInput = '';
showTextInput = true;
// Focus auf das Textfeld nach einem kurzen Delay
setTimeout(() => {
if (textInputElement) {
textInputElement.focus();
}
}, 0);
return;
}
isDrawing = true;
currentDrawingPoints = [{ x: startX, y: startY }];
if (currentTool === 'pen' || currentTool === 'eraser') {
ctx.beginPath();
ctx.moveTo(startX, startY);
} else {
snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
}
}
function findElementAtPoint(x: number, y: number): DrawnElement | null {
// Gehe durch alle Elemente rückwärts (zuletzt gezeichnete zuerst)
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
if (element.type === 'text' && element.text) {
const fontSize = element.fontSize || 20;
const lines = element.text.split('\n');
const textHeight = lines.length * fontSize * 1.2;
// Schätze die Textbreite (sehr grob)
const textWidth = Math.max(...lines.map((l) => l.length)) * fontSize * 0.6;
if (
x >= element.x &&
x <= element.x + textWidth &&
y >= element.y - fontSize &&
y <= element.y + textHeight
) {
return element;
}
}
}
return null;
}
function draw(e: MouseEvent) {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
// Sende Cursor-Position an andere Benutzer
if (whiteboardStore.connected) {
whiteboardStore.sendCursorPosition(currentX, currentY);
}
// Wenn wir ein Element verschieben
if (isDragging && selectedElement) {
selectedElement.x = currentX - dragOffsetX;
selectedElement.y = currentY - dragOffsetY;
redrawCanvas();
return;
}
if (!isDrawing || !ctx) return;
// Füge Punkt zur aktuellen Zeichnung hinzu
currentDrawingPoints.push({ x: currentX, y: currentY });
if (currentTool === 'pen') {
// Für Stift: Zeichne nur das neue Segment (nicht das ganze Canvas neu)
ctx.strokeStyle = currentColor;
ctx.lineWidth = lineWidth;
ctx.globalCompositeOperation = 'source-over';
ctx.lineTo(currentX, currentY);
ctx.stroke();
} else if (currentTool === 'eraser') {
// Für Radierer: Lösche tatsächlich (nicht nur weiße Farbe)
ctx.globalCompositeOperation = 'destination-out';
ctx.lineWidth = lineWidth * 3;
ctx.lineTo(currentX, currentY);
ctx.stroke();
} else {
// Für Formen: Zeichne Canvas neu mit allen Remote-Zeichnungen + aktuelle Form
redrawCanvas();
// Zeichne die aktuelle Form darüber
ctx.strokeStyle = currentColor;
ctx.lineWidth = lineWidth;
ctx.fillStyle = currentColor + '30';
if (currentTool === 'rectangle') {
ctx.strokeRect(startX, startY, currentX - startX, currentY - startY);
ctx.fillRect(startX, startY, currentX - startX, currentY - startY);
} else if (currentTool === 'circle') {
const radius = Math.sqrt(Math.pow(currentX - startX, 2) + Math.pow(currentY - startY, 2));
ctx.beginPath();
ctx.arc(startX, startY, radius, 0, 2 * Math.PI);
ctx.stroke();
ctx.fill();
} else if (currentTool === 'line') {
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(currentX, currentY);
ctx.stroke();
}
}
}
function stopDrawing() {
if (isDrawing && currentDrawingPoints.length > 0 && currentTool !== 'select') {
// Sende die abgeschlossene Zeichnung an den Server
whiteboardStore.sendDrawing(
currentTool as any,
currentDrawingPoints,
currentColor,
lineWidth
);
}
isDrawing = false;
isDragging = false;
snapshot = null;
currentDrawingPoints = [];
// Zeichne Canvas neu, damit alle Zeichnungen (inkl. eigene) vom Server korrekt angezeigt werden
setTimeout(() => {
redrawCanvas();
}, 50);
}
function clearCanvas() {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
elements = [];
whiteboardStore.clearWhiteboard();
}
function downloadCanvas() {
if (!canvas) return;
const link = document.createElement('a');
link.download = `whiteboard-${Date.now()}.png`;
link.href = canvas.toDataURL();
link.click();
}
function addText() {
if (!ctx || !canvas || !textInput.trim()) {
showTextInput = false;
return;
}
const rect = canvas.getBoundingClientRect();
const canvasX = textInputX - rect.left;
const canvasY = textInputY - rect.top;
const id = `${Date.now()}-${Math.random()}`;
// Füge Text als Element hinzu
const textElement: DrawnElement = {
id,
type: 'text',
x: canvasX,
y: canvasY,
text: textInput,
fontSize: fontSize,
color: currentColor
};
elements.push(textElement);
// Zeichne den Text auf dem Canvas
ctx.font = `${fontSize}px sans-serif`;
ctx.fillStyle = currentColor;
// Multi-line Text Support
const lines = textInput.split('\n');
lines.forEach((line, index) => {
ctx!.fillText(line, canvasX, canvasY + index * fontSize * 1.2);
});
// Sende Text an den Server
whiteboardStore.sendDrawing(
'text',
[{ x: canvasX, y: canvasY }],
currentColor,
lineWidth,
textInput,
id
);
// Reset
textInput = '';
showTextInput = false;
}
function handleTextKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
textInput = '';
showTextInput = false;
}
// Bei Enter ohne Shift: Text hinzufügen
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
addText();
}
}
</script>
<div class="whiteboard-page">
<div class="whiteboard-container">
<!-- Floating Toolbar - Excalidraw Style -->
<div class="floating-toolbar">
<button
class="tool-btn"
class:active={currentTool === 'select'}
onclick={() => (currentTool = 'select')}
title="Auswählen & Verschieben"
>
👆
</button>
<button
class="tool-btn"
class:active={currentTool === 'pen'}
onclick={() => (currentTool = 'pen')}
title="Stift"
>
✏️
</button>
<button
class="tool-btn"
class:active={currentTool === 'line'}
onclick={() => (currentTool = 'line')}
title="Linie"
>
📏
</button>
<button
class="tool-btn"
class:active={currentTool === 'rectangle'}
onclick={() => (currentTool = 'rectangle')}
title="Rechteck"
>
</button>
<button
class="tool-btn"
class:active={currentTool === 'circle'}
onclick={() => (currentTool = 'circle')}
title="Kreis"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="8" fill="currentColor" opacity="0.2"/>
<circle cx="12" cy="12" r="8"/>
</svg>
</button>
<button
class="tool-btn"
class:active={currentTool === 'text'}
onclick={() => (currentTool = 'text')}
title="Text"
>
📝
</button>
<button
class="tool-btn"
class:active={currentTool === 'eraser'}
onclick={() => (currentTool = 'eraser')}
title="Radiergummi"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.5L13 21"/>
<path d="M22 21H7"/>
<path d="m5 11 9 9"/>
</svg>
</button>
<div class="divider"></div>
<div class="color-picker-wrapper">
<button class="color-indicator" style="background: {currentColor};" title="Farbe wählen"></button>
<input type="color" bind:value={currentColor} class="color-input" />
</div>
<div class="divider"></div>
<button class="tool-btn" onclick={clearCanvas} title="Alles löschen">🗑️</button>
<button class="tool-btn" onclick={downloadCanvas} title="Herunterladen">💾</button>
</div>
<!-- Property Panel - Excalidraw Style -->
<div class="property-panel">
<div class="property-item">
<span class="property-label">Stärke:</span>
<input type="range" bind:value={lineWidth} min="1" max="20" class="slider" />
<span class="property-value">{lineWidth}</span>
<div class="size-preview">
<div
class="size-indicator"
style="width: {currentTool === 'eraser' ? lineWidth * 3 : lineWidth}px; height: {currentTool === 'eraser' ? lineWidth * 3 : lineWidth}px; background: {currentTool === 'eraser' ? '#e0e0e0' : currentColor};"
></div>
</div>
</div>
<div class="property-item">
<span class="property-label">Schrift:</span>
<input type="range" bind:value={fontSize} min="12" max="72" class="slider" />
<span class="property-value">{fontSize}</span>
</div>
</div>
<!-- Canvas -->
<canvas
bind:this={canvas}
onmousedown={startDrawing}
onmousemove={draw}
onmouseup={stopDrawing}
onmouseleave={stopDrawing}
class="drawing-canvas"
class:select-mode={currentTool === 'select'}
></canvas>
<!-- Remote Cursors -->
{#each Array.from(whiteboardStore.remoteCursors.values()) as cursor}
<div
class="remote-cursor"
style="left: {cursor.x}px; top: {cursor.y}px;"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path
d="M5.65376 12.3673L10.3941 16.2024L12 17.8135L15.4885 10.6213L15.8524 9.73688L5.65376 12.3673Z"
fill="#667eea"
/>
</svg>
<span class="cursor-label">{cursor.userId}</span>
</div>
{/each}
<!-- Active Users Panel -->
{#if whiteboardStore.connected}
<div class="active-users-panel">
<div class="panel-header">
<span class="status-indicator"></span>
Online: {whiteboardStore.activeUsers.length}
</div>
<div class="users-list">
{#each whiteboardStore.activeUsers as user}
<div class="user-badge">{user}</div>
{/each}
</div>
</div>
{/if}
<!-- Text Input Overlay - Excalidraw Style -->
{#if showTextInput}
<textarea
bind:this={textInputElement}
bind:value={textInput}
onkeydown={handleTextKeyDown}
onblur={addText}
class="text-input-direct"
style="left: {textInputX}px; top: {textInputY}px; font-size: {fontSize}px; color: {currentColor};"
placeholder="Text eingeben..."
rows="1"
></textarea>
{/if}
</div>
</div>
<style>
.whiteboard-page {
min-height: 100vh;
background: #f5f7fa;
display: flex;
flex-direction: column;
}
.whiteboard-container {
flex: 1;
position: relative;
background: #ffffff;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Floating Toolbar - Excalidraw Style */
.floating-toolbar {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 8px;
display: flex;
align-items: center;
gap: 4px;
z-index: 100;
}
.tool-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 1.3rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.tool-btn:hover {
background: #f0f0f0;
}
.tool-btn.active {
background: #e3f2fd;
color: #1976d2;
}
.divider {
width: 1px;
height: 30px;
background: #e0e0e0;
margin: 0 4px;
}
.color-picker-wrapper {
position: relative;
width: 40px;
height: 40px;
}
.color-indicator {
width: 40px;
height: 40px;
border: 2px solid #ddd;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.color-indicator:hover {
transform: scale(1.1);
}
.color-input {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
opacity: 0;
cursor: pointer;
}
/* Property Panel - Excalidraw Style */
.property-panel {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 12px 16px;
display: flex;
gap: 20px;
z-index: 100;
}
.property-item {
display: flex;
align-items: center;
gap: 8px;
}
.property-label {
font-size: 0.9rem;
color: #666;
font-weight: 500;
}
.slider {
width: 100px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #ddd;
border-radius: 2px;
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #667eea;
border-radius: 50%;
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
background: #667eea;
border-radius: 50%;
cursor: pointer;
border: none;
}
.property-value {
font-size: 0.9rem;
color: #333;
font-weight: 600;
min-width: 24px;
}
.size-preview {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.size-indicator {
border-radius: 50%;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Canvas */
.drawing-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: crosshair;
display: block;
background: #fafafa;
}
.drawing-canvas.select-mode {
cursor: default;
}
/* Text Input - Excalidraw Style */
.text-input-direct {
position: fixed;
background: transparent;
border: 2px solid #667eea;
border-radius: 4px;
padding: 4px 8px;
font-family: sans-serif;
resize: none;
overflow: hidden;
z-index: 200;
min-width: 100px;
line-height: 1.2;
outline: none;
}
.text-input-direct::placeholder {
color: #999;
opacity: 0.6;
}
/* Remote Cursors */
.remote-cursor {
position: absolute;
pointer-events: none;
z-index: 150;
transition: left 0.1s ease, top 0.1s ease;
}
.cursor-label {
position: absolute;
left: 28px;
top: 0;
background: #667eea;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
white-space: nowrap;
font-weight: 500;
}
/* Active Users Panel */
.active-users-panel {
position: absolute;
top: 20px;
right: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 12px 16px;
z-index: 100;
min-width: 150px;
}
.panel-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.status-indicator {
width: 8px;
height: 8px;
background: #4caf50;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.users-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-badge {
background: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
}
@media (max-width: 768px) {
.floating-toolbar {
flex-wrap: wrap;
max-width: 90%;
}
.property-panel {
flex-direction: column;
gap: 12px;
}
.slider {
width: 80px;
}
}
</style>