881 lines
21 KiB
Svelte
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>
|