Liveblocks Beispiele

Kollaborative Infrastruktur-Beispiele mit Liveblocks für Echtzeit-Zusammenarbeit, Präsenz und Datensynchronisation

💻 Kollaborativer Editor Liveblocks typescript

🟡 intermediate ⭐⭐⭐⭐

Echtzeit-kollaborativer Texteditor mit Liveblocks und Yjs mit Live-Cursor, Kommentaren und Präsenz

⏱️ 30 min 🏷️ liveblocks, collaboration, editor, react
Prerequisites: React, TypeScript, Liveblocks basics
// Collaborative Text Editor using Liveblocks and Yjs
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createClient } from '@liveblocks/client';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { LiveblocksProvider, useRoom, useOthers } from '@liveblocks/react';

// Initialize Liveblocks client
const client = createClient({
    authEndpoint: '/api/liveblocks-auth',
    throttle: 16,
});

// Yjs Websocket Provider for real-time sync
const wsProvider = new WebsocketProvider(
    'wss://demos.yjs.dev',
    'liveblocks-editor-demo',
    new Y.Doc()
);

interface User {
    id: string;
    name: string;
    color: string;
    avatar?: string;
}

interface Comment {
    id: string;
    text: string;
    userId: string;
    userName: string;
    timestamp: Date;
    position: { x: number; y: number };
    resolved?: boolean;
}

const CollaborativeEditor: React.FC = () => {
    const [text, setText] = useState('');
    const [comments, setComments] = useState<Comment[]>([]);
    const [selectedText, setSelectedText] = useState('');
    const [showCommentDialog, setShowCommentDialog] = useState(false);
    const [newComment, setNewComment] = useState('');
    const [commentPosition, setCommentPosition] = useState({ x: 0, y: 0 });

    const editorRef = useRef<HTMLTextAreaElement>(null);
    const yjsDoc = wsProvider.doc;
    const yjsText = yjsDoc.getText('content');

    // Sync Yjs text with local state
    useEffect(() => {
        const handleUpdate = () => {
            setText(yjsText.toString());
        };

        yjsText.observe(handleUpdate);
        handleUpdate();

        return () => {
            yjsText.unobserve(handleUpdate);
        };
    }, [yjsText]);

    // Handle text changes and sync to Yjs
    const handleTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
        const newText = e.target.value;
        setText(newText);

        // Sync with Yjs
        yjsDoc.transact(() => {
            yjsText.delete(0, yjsText.length);
            yjsText.insert(0, newText);
        });
    }, [yjsDoc, yjsText]);

    // Handle text selection for comments
    const handleTextSelection = useCallback(() => {
        const textarea = editorRef.current;
        if (!textarea) return;

        const start = textarea.selectionStart;
        const end = textarea.selectionEnd;

        if (start !== end) {
            setSelectedText(text.substring(start, end));
        } else {
            setSelectedText('');
        }
    }, [text]);

    // Add comment at cursor position
    const addComment = useCallback(() => {
        if (!newComment.trim()) return;

        const comment: Comment = {
            id: Date.now().toString(),
            text: newComment.trim(),
            userId: 'current-user', // Would get from user context
            userName: 'Current User',
            timestamp: new Date(),
            position: commentPosition,
            resolved: false
        };

        // Store comment in Liveblocks storage
        // This would integrate with Liveblocks storage API
        setComments(prev => [...prev, comment]);
        setNewComment('');
        setShowCommentDialog(false);
        setSelectedText('');
    }, [newComment, commentPosition]);

    // Right-click handler for adding comments
    const handleContextMenu = useCallback((e: React.MouseEvent) => {
        e.preventDefault();
        const rect = editorRef.current?.getBoundingClientRect();
        if (rect) {
            setCommentPosition({
                x: e.clientX - rect.left,
                y: e.clientY - rect.top
            });
            setShowCommentDialog(true);
        }
    }, []);

    // Resolve comment
    const resolveComment = useCallback((commentId: string) => {
        setComments(prev =>
            prev.map(comment =>
                comment.id === commentId
                    ? { ...comment, resolved: true }
                    : comment
            )
        );
    }, []);

    // Delete comment
    const deleteComment = useCallback((commentId: string) => {
        setComments(prev => prev.filter(comment => comment.id !== commentId));
    }, []);

    return (
        <LiveblocksProvider client={client}>
            <div className="collaborative-editor">
                <div className="editor-header">
                    <h2>Collaborative Editor</h2>
                    <div className="active-users">
                        <ActiveUsers />
                    </div>
                </div>

                <div className="editor-container">
                    <div className="editor-wrapper">
                        <textarea
                            ref={editorRef}
                            value={text}
                            onChange={handleTextChange}
                            onSelect={handleTextSelection}
                            onContextMenu={handleContextMenu}
                            placeholder="Start typing to collaborate..."
                            className="editor-textarea"
                        />

                        {/* Live cursors overlay */}
                        <LiveCursors />

                        {/* Comments overlay */}
                        <CommentsOverlay
                            comments={comments}
                            onResolve={resolveComment}
                            onDelete={deleteComment}
                        />
                    </div>

                    {/* Comments sidebar */}
                    <div className="comments-sidebar">
                        <h3>Comments</h3>
                        {comments.filter(c => !c.resolved).map(comment => (
                            <div key={comment.id} className="comment-item">
                                <div className="comment-header">
                                    <strong>{comment.userName}</strong>
                                    <span className="comment-time">
                                        {comment.timestamp.toLocaleTimeString()}
                                    </span>
                                </div>
                                <div className="comment-text">
                                    {comment.text}
                                    {selectedText && (
                                        <div className="comment-selection">
                                            "{selectedText}"
                                        </div>
                                    )}
                                </div>
                                <div className="comment-actions">
                                    <button
                                        onClick={() => resolveComment(comment.id)}
                                        className="resolve-btn"
                                    >
                                        Resolve
                                    </button>
                                    <button
                                        onClick={() => deleteComment(comment.id)}
                                        className="delete-btn"
                                    >
                                        Delete
                                    </button>
                                </div>
                            </div>
                        ))}
                    </div>
                </div>

                {/* Comment dialog */}
                {showCommentDialog && (
                    <div
                        className="comment-dialog"
                        style={{ left: commentPosition.x, top: commentPosition.y }}
                    >
                        <div className="comment-dialog-content">
                            <h4>Add Comment</h4>
                            {selectedText && (
                                <div className="selected-text">
                                    <strong>Selected:</strong> "{selectedText}"
                                </div>
                            )}
                            <textarea
                                value={newComment}
                                onChange={(e) => setNewComment(e.target.value)}
                                placeholder="Write your comment..."
                                autoFocus
                            />
                            <div className="comment-dialog-actions">
                                <button
                                    onClick={() => {
                                        setShowCommentDialog(false);
                                        setNewComment('');
                                    }}
                                    className="cancel-btn"
                                >
                                    Cancel
                                </button>
                                <button
                                    onClick={addComment}
                                    disabled={!newComment.trim()}
                                    className="add-btn"
                                >
                                    Add Comment
                                </button>
                            </div>
                        </div>
                    </div>
                )}
            </div>
        </LiveblocksProvider>
    );
};

// Component for displaying active users
const ActiveUsers: React.FC = () => {
    const others = useOthers();

    return (
        <div className="active-users">
            <span className="users-label">Active users:</span>
            {Array.from(others.values()).map(user => (
                <div
                    key={user.id}
                    className="user-avatar"
                    title={user.info?.name || 'Anonymous'}
                    style={{ backgroundColor: user.info?.color || '#ccc' }}
                >
                    {user.info?.name?.[0] || 'A'}
                </div>
            ))}
        </div>
    );
};

// Component for displaying live cursors
const LiveCursors: React.FC = () => {
    const others = useOthers();

    return (
        <div className="live-cursors">
            {Array.from(others.values()).map(user => {
                const cursor = user.presence.cursor;
                if (!cursor) return null;

                return (
                    <div
                        key={user.id}
                        className="live-cursor"
                        style={{
                            left: cursor.x,
                            top: cursor.y,
                            borderColor: user.info?.color || '#666'
                        }}
                    >
                        <div className="cursor-name">
                            {user.info?.name || 'Anonymous'}
                        </div>
                    </div>
                );
            })}
        </div>
    );
};

// Component for displaying comments overlay
const CommentsOverlay: React.FC<{
    comments: Comment[];
    onResolve: (id: string) => void;
    onDelete: (id: string) => void;
}> = ({ comments, onResolve, onDelete }) => {
    return (
        <div className="comments-overlay">
            {comments.filter(c => !c.resolved).map(comment => (
                <div
                    key={comment.id}
                    className="comment-marker"
                    style={{
                        left: comment.position.x,
                        top: comment.position.y
                    }}
                    title={comment.text}
                >
                    💬
                </div>
            ))}
        </div>
    );
};

// Custom hook for Liveblocks presence management
const usePresence = () => {
    const room = useRoom();

    const updateCursor = useCallback((cursor: { x: number; y: number }) => {
        room.updatePresence({ cursor });
    }, [room]);

    const updateSelection = useCallback((selection: { start: number; end: number }) => {
        room.updatePresence({ selection });
    }, [room]);

    const clearPresence = useCallback(() => {
        room.updatePresence({ cursor: undefined, selection: undefined });
    }, [room]);

    return {
        updateCursor,
        updateSelection,
        clearPresence
    };
};

// Server-side authentication endpoint (Node.js/Express)
export const authEndpoint = `
// Liveblocks authentication endpoint
import express from 'express';
import { Liveblocks } from '@liveblocks/node';

const app = express();
const liveblocks = new Liveblocks({
    secret: process.env.LIVEBLOCKS_SECRET_KEY,
});

app.post('/api/liveblocks-auth', async (req, res) => {
    try {
        // Get user from your authentication system
        const user = await getUserFromToken(req.headers.authorization);

        if (!user) {
            return res.status(401).json({ error: 'Unauthorized' });
        }

        // Create Liveblocks session
        const session = liveblocks.prepareSession(user.id, {
            userInfo: {
                name: user.name,
                color: user.color || getRandomColor(),
                avatar: user.avatar,
            },
        });

        // Authorize the user
        const { status, body } = await session.authorize();

        res.status(status).end(body);
    } catch (error) {
        console.error('Liveblocks auth error:', error);
        res.status(500).json({ error: 'Authentication failed' });
    }
});

function getRandomColor() {
    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD'];
    return colors[Math.floor(Math.random() * colors.length)];
}
`;

// CSS styles
export const styles = `
.collaborative-editor {
    display: flex;
    flex-direction: column;
    height: 100vh;
    font-family: 'Inter', sans-serif;
}

.editor-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 1rem;
    border-bottom: 1px solid #e2e8f0;
    background: white;
}

.editor-container {
    display: flex;
    flex: 1;
    overflow: hidden;
}

.editor-wrapper {
    flex: 1;
    position: relative;
    background: white;
}

.editor-textarea {
    width: 100%;
    height: 100%;
    border: none;
    outline: none;
    padding: 2rem;
    font-size: 16px;
    line-height: 1.6;
    resize: none;
    font-family: 'Monaco', 'Menlo', monospace;
}

.live-cursors {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    z-index: 10;
}

.live-cursor {
    position: absolute;
    width: 20px;
    height: 20px;
    border: 2px solid;
    border-radius: 50%;
    transform: translate(-50%, -50%);
    transition: all 0.1s ease;
    pointer-events: none;
}

.cursor-name {
    position: absolute;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    background: rgba(0, 0, 0, 0.8);
    color: white;
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 12px;
    white-space: nowrap;
    z-index: 11;
}

.comments-sidebar {
    width: 300px;
    border-left: 1px solid #e2e8f0;
    background: #f8fafc;
    padding: 1rem;
    overflow-y: auto;
}

.comments-sidebar h3 {
    margin: 0 0 1rem 0;
    color: #1a202c;
}

.comment-item {
    background: white;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 1rem;
}

.comment-header {
    display: flex;
    justify-content: space-between;
    margin-bottom: 0.5rem;
}

.comment-time {
    font-size: 12px;
    color: #718096;
}

.comment-text {
    margin-bottom: 0.5rem;
    color: #2d3748;
}

.comment-selection {
    font-style: italic;
    color: #4a5568;
    background: #f7fafc;
    padding: 0.5rem;
    border-radius: 4px;
    margin-top: 0.5rem;
}

.comment-actions {
    display: flex;
    gap: 0.5rem;
}

.resolve-btn, .delete-btn {
    padding: 4px 8px;
    border: none;
    border-radius: 4px;
    font-size: 12px;
    cursor: pointer;
}

.resolve-btn {
    background: #48bb78;
    color: white;
}

.delete-btn {
    background: #f56565;
    color: white;
}

.comment-dialog {
    position: absolute;
    background: white;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    z-index: 1000;
    min-width: 300px;
}

.comment-dialog-content {
    padding: 1rem;
}

.comment-dialog-content h4 {
    margin: 0 0 0.5rem 0;
    color: #1a202c;
}

.selected-text {
    background: #f7fafc;
    padding: 0.5rem;
    border-radius: 4px;
    margin-bottom: 0.5rem;
    font-size: 14px;
}

.comment-dialog-content textarea {
    width: 100%;
    height: 80px;
    border: 1px solid #e2e8f0;
    border-radius: 4px;
    padding: 0.5rem;
    resize: vertical;
    margin-bottom: 1rem;
}

.comment-dialog-actions {
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
}

.cancel-btn, .add-btn {
    padding: 6px 12px;
    border: none;
    border-radius: 4px;
    font-size: 14px;
    cursor: pointer;
}

.cancel-btn {
    background: #e2e8f0;
    color: #4a5568;
}

.add-btn {
    background: #3182ce;
    color: white;
}

.add-btn:disabled {
    background: #cbd5e0;
    cursor: not-allowed;
}

.comments-overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    z-index: 5;
}

.comment-marker {
    position: absolute;
    background: #3182ce;
    color: white;
    border-radius: 50%;
    width: 24px;
    height: 24px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 12px;
    cursor: pointer;
    transform: translate(-50%, -50%);
}

.active-users {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.users-label {
    font-size: 14px;
    color: #718096;
}

.user-avatar {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: bold;
    font-size: 14px;
    border: 2px solid white;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
`;

export default CollaborativeEditor;

💻 Kollaboratives Whiteboard Liveblocks typescript

🟡 intermediate ⭐⭐⭐⭐

Echtzeit-kollaboratives Whiteboard mit Zeichenwerkzeugen, Formen und Liveblocks-Präsenz

⏱️ 35 min 🏷️ liveblocks, whiteboard, drawing, collaboration
Prerequisites: React, Canvas API, Liveblocks
// Collaborative Whiteboard using Liveblocks
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { createClient } from '@liveblocks/client';
import { LiveblocksProvider, useRoom, useOthers, useStorage } from '@liveblocks/react';
import { useInfiniteScroll } from './hooks/useInfiniteScroll';

// Initialize Liveblocks client
const client = createClient({
    authEndpoint: '/api/liveblocks-auth',
});

interface Point {
    x: number;
    y: number;
}

interface DrawingElement {
    id: string;
    type: 'path' | 'rectangle' | 'circle' | 'text' | 'sticky';
    points?: Point[];
    startPoint?: Point;
    endPoint?: Point;
    color: string;
    strokeWidth: number;
    userId: string;
    userName?: string;
    text?: string;
    fontSize?: number;
}

interface User {
    id: string;
    name: string;
    color: string;
    cursor?: Point;
}

const CollaborativeWhiteboard: React.FC = () => {
    const canvasRef = useRef<HTMLCanvasElement>(null);
    const containerRef = useRef<HTMLDivElement>(null);
    const [isDrawing, setIsDrawing] = useState(false);
    const [currentTool, setCurrentTool] = useState<'pen' | 'rectangle' | 'circle' | 'eraser' | 'text' | 'sticky'>('pen');
    const [currentColor, setCurrentColor] = useState('#000000');
    const [strokeWidth, setStrokeWidth] = useState(2);
    const [currentPath, setCurrentPath] = useState<Point[]>([]);
    const [startPoint, setStartPoint] = useState<Point | null>(null);
    const [elements, setElements] = useState<DrawingElement[]>([]);
    const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 });
    const [isPanning, setIsPanning] = useState(false);
    const [lastPanPoint, setLastPanPoint] = useState<Point | null>(null);

    const room = useRoom();
    const others = useOthers();

    // Get elements from Liveblocks storage
    const storageElements = useStorage(root => root.elements) as DrawingElement[] || [];

    // Sync elements with storage
    useEffect(() => {
        setElements(storageElements);
    }, [storageElements]);

    // Canvas setup and redrawing
    useEffect(() => {
        const canvas = canvasRef.current;
        if (!canvas) return;

        const ctx = canvas.getContext('2d');
        if (!ctx) return;

        // Clear canvas
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        // Apply viewport transformation
        ctx.save();
        ctx.translate(viewport.x, viewport.y);
        ctx.scale(viewport.scale, viewport.scale);

        // Draw grid
        drawGrid(ctx, canvas.width, canvas.height);

        // Draw all elements
        elements.forEach(element => drawElement(ctx, element));

        // Draw current drawing
        if (currentPath.length > 0) {
            drawPath(ctx, currentPath, currentColor, strokeWidth);
        }

        if (startPoint) {
            // Preview shape being drawn
            const mousePos = getMousePos(canvas, { clientX: 0, clientY: 0 } as MouseEvent);

            if (currentTool === 'rectangle') {
                drawRectangle(ctx, startPoint, mousePos, currentColor, strokeWidth);
            } else if (currentTool === 'circle') {
                drawCircle(ctx, startPoint, mousePos, currentColor, strokeWidth);
            }
        }

        // Draw other users' cursors
        others.forEach(user => {
            const cursor = user.presence.cursor;
            if (cursor) {
                drawCursor(ctx, cursor, user.info?.color || '#666');
            }
        });

        ctx.restore();
    }, [elements, currentPath, startPoint, currentTool, currentColor, strokeWidth, viewport, others]);

    // Mouse event handlers
    const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
        const canvas = canvasRef.current;
        if (!canvas) return;

        const point = getMousePos(canvas, e);

        if (e.shiftKey || currentTool === 'pan') {
            setIsPanning(true);
            setLastPanPoint(point);
        } else if (currentTool === 'pen' || currentTool === 'eraser') {
            setIsDrawing(true);
            setCurrentPath([point]);
        } else if (currentTool === 'rectangle' || currentTool === 'circle') {
            setStartPoint(point);
        } else if (currentTool === 'text') {
            const text = prompt('Enter text:');
            if (text) {
                addTextElement(point, text);
            }
        } else if (currentTool === 'sticky') {
            const text = prompt('Enter sticky note text:');
            if (text) {
                addStickyNote(point, text);
            }
        }
    }, [currentTool]);

    const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
        const canvas = canvasRef.current;
        if (!canvas) return;

        const point = getMousePos(canvas, e);

        // Update cursor position in Liveblocks
        room.updatePresence({ cursor: point });

        if (isPanning && lastPanPoint) {
            const deltaX = point.x - lastPanPoint.x;
            const deltaY = point.y - lastPanPoint.y;

            setViewport(prev => ({
                ...prev,
                x: prev.x + deltaX,
                y: prev.y + deltaY
            }));

            setLastPanPoint(point);
        } else if (isDrawing) {
            setCurrentPath(prev => [...prev, point]);
        }
    }, [isDrawing, isPanning, lastPanPoint, room]);

    const handleMouseUp = useCallback(() => {
        if (isDrawing && currentPath.length > 1) {
            const element: DrawingElement = {
                id: Date.now().toString(),
                type: 'path',
                points: currentPath,
                color: currentColor,
                strokeWidth,
                userId: room.id
            };

            addElement(element);
        }

        if (startPoint && (currentTool === 'rectangle' || currentTool === 'circle')) {
            const canvas = canvasRef.current;
            if (canvas) {
                const endPoint = getMousePos(canvas, {
                    clientX: canvas.width / 2,
                    clientY: canvas.height / 2
                } as MouseEvent);

                const element: DrawingElement = {
                    id: Date.now().toString(),
                    type: currentTool,
                    startPoint,
                    endPoint,
                    color: currentColor,
                    strokeWidth,
                    userId: room.id
                };

                addElement(element);
            }
        }

        setIsDrawing(false);
        setIsPanning(false);
        setCurrentPath([]);
        setStartPoint(null);
        setLastPanPoint(null);
    }, [isDrawing, currentPath, startPoint, currentTool, currentColor, strokeWidth, room]);

    // Drawing functions
    const drawGrid = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
        ctx.strokeStyle = '#f0f0f0';
        ctx.lineWidth = 1;

        const gridSize = 20;
        for (let x = 0; x <= width; x += gridSize) {
            ctx.beginPath();
            ctx.moveTo(x, 0);
            ctx.lineTo(x, height);
            ctx.stroke();
        }

        for (let y = 0; y <= height; y += gridSize) {
            ctx.beginPath();
            ctx.moveTo(0, y);
            ctx.lineTo(width, y);
            ctx.stroke();
        }
    };

    const drawElement = (ctx: CanvasRenderingContext2D, element: DrawingElement) => {
        ctx.strokeStyle = element.color;
        ctx.fillStyle = element.color;
        ctx.lineWidth = element.strokeWidth;

        switch (element.type) {
            case 'path':
                if (element.points && element.points.length > 1) {
                    drawPath(ctx, element.points, element.color, element.strokeWidth);
                }
                break;
            case 'rectangle':
                if (element.startPoint && element.endPoint) {
                    drawRectangle(ctx, element.startPoint, element.endPoint, element.color, element.strokeWidth);
                }
                break;
            case 'circle':
                if (element.startPoint && element.endPoint) {
                    drawCircle(ctx, element.startPoint, element.endPoint, element.color, element.strokeWidth);
                }
                break;
            case 'text':
                if (element.startPoint && element.text) {
                    ctx.font = `${element.fontSize || 16}px Arial`;
                    ctx.fillText(element.text, element.startPoint.x, element.startPoint.y);
                }
                break;
            case 'sticky':
                if (element.startPoint && element.text) {
                    drawStickyNote(ctx, element.startPoint, element.text, element.color);
                }
                break;
        }
    };

    const drawPath = (ctx: CanvasRenderingContext2D, points: Point[], color: string, width: number) => {
        ctx.strokeStyle = color;
        ctx.lineWidth = width;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';

        ctx.beginPath();
        ctx.moveTo(points[0].x, points[0].y);

        for (let i = 1; i < points.length; i++) {
            ctx.lineTo(points[i].x, points[i].y);
        }

        ctx.stroke();
    };

    const drawRectangle = (ctx: CanvasRenderingContext2D, start: Point, end: Point, color: string, width: number) => {
        ctx.strokeStyle = color;
        ctx.lineWidth = width;

        const widthVal = end.x - start.x;
        const heightVal = end.y - start.y;

        ctx.strokeRect(start.x, start.y, widthVal, heightVal);
    };

    const drawCircle = (ctx: CanvasRenderingContext2D, start: Point, end: Point, color: string, width: number) => {
        ctx.strokeStyle = color;
        ctx.lineWidth = width;

        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();
    };

    const drawStickyNote = (ctx: CanvasRenderingContext2D, point: Point, text: string, color: string) => {
        const width = 120;
        const height = 100;

        // Draw sticky note background
        ctx.fillStyle = '#ffeb3b';
        ctx.fillRect(point.x, point.y, width, height);

        // Draw border
        ctx.strokeStyle = color;
        ctx.lineWidth = 2;
        ctx.strokeRect(point.x, point.y, width, height);

        // Draw text
        ctx.fillStyle = '#000';
        ctx.font = '12px Arial';

        // Word wrap for sticky note text
        const words = text.split(' ');
        let line = '';
        let y = point.y + 20;

        for (let word of words) {
            const testLine = line + word + ' ';
            const metrics = ctx.measureText(testLine);

            if (metrics.width > width - 20 && line !== '') {
                ctx.fillText(line, point.x + 10, y);
                line = word + ' ';
                y += 15;
            } else {
                line = testLine;
            }
        }
        ctx.fillText(line, point.x + 10, y);
    };

    const drawCursor = (ctx: CanvasRenderingContext2D, point: Point, color: string) => {
        ctx.strokeStyle = color;
        ctx.fillStyle = color;
        ctx.lineWidth = 2;

        // Draw cursor
        ctx.beginPath();
        ctx.moveTo(point.x - 10, point.y);
        ctx.lineTo(point.x, point.y);
        ctx.lineTo(point.x, point.y + 10);
        ctx.stroke();

        // Draw circle
        ctx.beginPath();
        ctx.arc(point.x, point.y, 3, 0, 2 * Math.PI);
        ctx.fill();
    };

    // Helper functions
    const getMousePos = (canvas: HTMLCanvasElement, e: MouseEvent): Point => {
        const rect = canvas.getBoundingClientRect();
        return {
            x: (e.clientX - rect.left - viewport.x) / viewport.scale,
            y: (e.clientY - rect.top - viewport.y) / viewport.scale
        };
    };

    const addElement = useCallback((element: DrawingElement) => {
        // Add element to Liveblocks storage
        room.storage.set('elements', [...elements, element]);
    }, [elements, room]);

    const addTextElement = (point: Point, text: string) => {
        const element: DrawingElement = {
            id: Date.now().toString(),
            type: 'text',
            startPoint: point,
            text,
            color: currentColor,
            strokeWidth: 16, // font size
            userId: room.id
        };

        addElement(element);
    };

    const addStickyNote = (point: Point, text: string) => {
        const element: DrawingElement = {
            id: Date.now().toString(),
            type: 'sticky',
            startPoint: point,
            text,
            color: currentColor,
            strokeWidth: 2,
            userId: room.id
        };

        addElement(element);
    };

    const clearCanvas = () => {
        room.storage.set('elements', []);
    };

    const downloadCanvas = () => {
        const canvas = canvasRef.current;
        if (!canvas) return;

        const link = document.createElement('a');
        link.download = 'whiteboard.png';
        link.href = canvas.toDataURL();
        link.click();
    };

    const handleZoom = (delta: number) => {
        setViewport(prev => ({
            ...prev,
            scale: Math.max(0.1, Math.min(5, prev.scale + delta))
        }));
    };

    return (
        <LiveblocksProvider client={client}>
            <div className="whiteboard-container">
                <div className="toolbar">
                    <div className="tool-group">
                        <button
                            className={currentTool === 'pen' ? 'active' : ''}
                            onClick={() => setCurrentTool('pen')}
                        >
                            ✏️ Pen
                        </button>
                        <button
                            className={currentTool === 'rectangle' ? 'active' : ''}
                            onClick={() => setCurrentTool('rectangle')}
                        >
                            ▢ Rectangle
                        </button>
                        <button
                            className={currentTool === 'circle' ? 'active' : ''}
                            onClick={() => setCurrentTool('circle')}
                        >
                            ○ Circle
                        </button>
                        <button
                            className={currentTool === 'text' ? 'active' : ''}
                            onClick={() => setCurrentTool('text')}
                        >
                            📝 Text
                        </button>
                        <button
                            className={currentTool === 'sticky' ? 'active' : ''}
                            onClick={() => setCurrentTool('sticky')}
                        >
                            📌 Sticky
                        </button>
                        <button
                            className={currentTool === 'eraser' ? 'active' : ''}
                            onClick={() => setCurrentTool('eraser')}
                        >
                            🧹 Eraser
                        </button>
                        <button
                            className={currentTool === 'pan' ? 'active' : ''}
                            onClick={() => setCurrentTool('pan')}
                        >
                            ✋ Pan
                        </button>
                    </div>

                    <div className="tool-group">
                        <label>Color:</label>
                        <input
                            type="color"
                            value={currentColor}
                            onChange={(e) => setCurrentColor(e.target.value)}
                        />
                        <label>Size:</label>
                        <input
                            type="range"
                            min="1"
                            max="20"
                            value={strokeWidth}
                            onChange={(e) => setStrokeWidth(Number(e.target.value))}
                        />
                    </div>

                    <div className="tool-group">
                        <button onClick={() => handleZoom(0.1)}>🔍+</button>
                        <button onClick={() => handleZoom(-0.1)}>🔍-</button>
                        <button onClick={() => setViewport({ x: 0, y: 0, scale: 1 })}>🏠</button>
                        <button onClick={clearCanvas}>🗑️ Clear</button>
                        <button onClick={downloadCanvas}>💾 Save</button>
                    </div>

                    <div className="active-users">
                        <ActiveUsersIndicator />
                    </div>
                </div>

                <div
                    className="canvas-container"
                    ref={containerRef}
                    onWheel={(e) => {
                        e.preventDefault();
                        handleZoom(e.deltaY > 0 ? -0.1 : 0.1);
                    }}
                >
                    <canvas
                        ref={canvasRef}
                        width={2000}
                        height={2000}
                        onMouseDown={handleMouseDown}
                        onMouseMove={handleMouseMove}
                        onMouseUp={handleMouseUp}
                        onMouseLeave={handleMouseUp}
                    />
                </div>
            </div>
        </LiveblocksProvider>
    );
};

const ActiveUsersIndicator: React.FC = () => {
    const others = useOthers();

    return (
        <div className="active-users">
            <span>Active: {others.size + 1}</span>
            {Array.from(others.values()).map(user => (
                <div
                    key={user.id}
                    className="user-cursor"
                    style={{ backgroundColor: user.info?.color || '#666' }}
                    title={user.info?.name || 'Anonymous'}
                >
                    {user.info?.name?.[0] || 'A'}
                </div>
            ))}
        </div>
    );
};

export default CollaborativeWhiteboard;

💻 Liveblocks Präsenz- und Kommentar-System typescript

🔴 complex ⭐⭐⭐⭐⭐

Vollständiges Präsenzbewusstseins- und Threaded-Kommentar-System mit Benachrichtigungen

⏱️ 40 min 🏷️ liveblocks, presence, comments, threads
Prerequisites: React, TypeScript, Liveblocks advanced
// Advanced Liveblocks Presence and Comments System
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { createClient } from '@liveblocks/client';
import {
    LiveblocksProvider,
    useRoom,
    useOthers,
    useSelf,
    useStorage,
    useMutation,
    useInfiniteComments,
    useThreads
} from '@liveblocks/react';

// Initialize Liveblocks client with advanced configuration
const client = createClient({
    authEndpoint: '/api/liveblocks-auth',
    throttle: 16,
    lostConnectionTimeout: 5000,
});

// Type definitions
interface UserPresence {
    cursor?: { x: number; y: number };
    selection?: { start: number; end: number };
    status: 'online' | 'away' | 'busy';
    currentDocument?: string;
    lastSeen?: Date;
}

interface CommentThread {
    id: string;
    documentId: string;
    userId: string;
    userName: string;
    content: string;
    position: { x: number; y: number };
    timestamp: Date;
    resolved: boolean;
    replies: CommentReply[];
    attachments?: string[];
    mentions?: string[];
}

interface CommentReply {
    id: string;
    userId: string;
    userName: string;
    content: string;
    timestamp: Date;
    attachments?: string[];
    mentions?: string[];
}

interface NotificationSettings {
    mentions: boolean;
    replies: boolean;
    documentUpdates: boolean;
    presenceUpdates: boolean;
}

const AdvancedCollaborationSystem: React.FC = () => {
    const [activeDocument, setActiveDocument] = useState<string | null>(null);
    const [showCommentsPanel, setShowCommentsPanel] = useState(false);
    const [unreadCount, setUnreadCount] = useState(0);
    const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
        mentions: true,
        replies: true,
        documentUpdates: false,
        presenceUpdates: false
    });

    const room = useRoom();
    const others = useOthers();
    const self = useSelf();
    const threads = useThreads();
    const infiniteComments = useInfiniteComments();

    // Get presence data from storage
    const presenceData = useStorage(root => root.presence) as Map<string, UserPresence>;

    // Get comments for current document
    const documentComments = useStorage(root => {
        if (!activeDocument) return [];
        return root.comments?.filter((comment: CommentThread) =>
            comment.documentId === activeDocument && !comment.resolved
        ) || [];
    });

    // Update user presence
    const updatePresence = useCallback((updates: Partial<UserPresence>) => {
        const currentPresence = {
            cursor: undefined,
            selection: undefined,
            status: 'online' as const,
            currentDocument: activeDocument,
            lastSeen: new Date(),
            ...updates
        };

        room.updatePresence(currentPresence);

        // Store persistent presence data
        room.storage.set(`presence_${self.id}`, currentPresence);
    }, [room, self.id, activeDocument]);

    // Auto-away status
    useEffect(() => {
        let awayTimeout: NodeJS.Timeout;
        let lastActivity = Date.now();

        const resetActivity = () => {
            lastActivity = Date.now();
            updatePresence({ status: 'online' });
        };

        const checkAwayStatus = () => {
            if (Date.now() - lastActivity > 300000) { // 5 minutes
                updatePresence({ status: 'away' });
            }
        };

        const handleActivity = () => {
            resetActivity();
        };

        // Activity listeners
        document.addEventListener('mousemove', handleActivity);
        document.addEventListener('keypress', handleActivity);
        document.addEventListener('click', handleActivity);

        awayTimeout = setInterval(checkAwayStatus, 60000); // Check every minute

        return () => {
            document.removeEventListener('mousemove', handleActivity);
            document.removeEventListener('keypress', handleActivity);
            document.removeEventListener('click', handleActivity);
            clearInterval(awayTimeout);
        };
    }, [updatePresence]);

    // Handle document switching
    const switchDocument = useCallback((documentId: string) => {
        setActiveDocument(documentId);
        updatePresence({
            currentDocument: documentId,
            selection: undefined // Clear selection when switching docs
        });

        // Leave old document room and join new one
        room.broadcastEvent({
            type: 'document_switch',
            data: { fromDocument: activeDocument, toDocument: documentId }
        });
    }, [activeDocument, room, updatePresence]);

    // Comment management
    const createCommentThread = useMutation(({ storage }, commentData: Omit<CommentThread, 'id' | 'timestamp'>) => {
        if (!storage.get('comments')) {
            storage.set('comments', []);
        }

        const comments = storage.get('comments')!;
        const newThread: CommentThread = {
            ...commentData,
            id: generateId(),
            timestamp: new Date()
        };

        comments.push(newThread);

        // Send notifications for mentions
        if (commentData.mentions && commentData.mentions.length > 0) {
            commentData.mentions.forEach(userId => {
                sendNotification(userId, {
                    type: 'mention',
                    content: `${self.info?.name || 'Someone'} mentioned you in a comment`,
                    documentId: commentData.documentId,
                    threadId: newThread.id
                });
            });
        }

        return newThread.id;
    }, [self.info?.name]);

    const replyToThread = useMutation(({ storage }, threadId: string, replyData: Omit<CommentReply, 'id' | 'timestamp'>) => {
        const comments = storage.get('comments');
        if (!comments) return null;

        const thread = comments.find((t: CommentThread) => t.id === threadId);
        if (!thread) return null;

        const newReply: CommentReply = {
            ...replyData,
            id: generateId(),
            timestamp: new Date()
        };

        thread.replies.push(newReply);

        // Send notification to thread author
        if (thread.userId !== self.id) {
            sendNotification(thread.userId, {
                type: 'reply',
                content: `${self.info?.name || 'Someone'} replied to your comment`,
                documentId: thread.documentId,
                threadId
            });
        }

        // Send notifications for mentions in reply
        if (replyData.mentions && replyData.mentions.length > 0) {
            replyData.mentions.forEach(userId => {
                sendNotification(userId, {
                    type: 'mention',
                    content: `${self.info?.name || 'Someone'} mentioned you in a reply`,
                    documentId: thread.documentId,
                    threadId
                });
            });
        }

        return newReply.id;
    }, [self.id, self.info?.name]);

    const resolveThread = useMutation(({ storage }, threadId: string) => {
        const comments = storage.get('comments');
        if (!comments) return false;

        const thread = comments.find((t: CommentThread) => t.id === threadId);
        if (!thread) return false;

        thread.resolved = true;

        // Notify thread participants
        const notifiedUsers = new Set([thread.userId, ...thread.replies.map(r => r.userId)]);
        notifiedUsers.forEach(userId => {
            if (userId !== self.id) {
                sendNotification(userId, {
                    type: 'resolved',
                    content: `${self.info?.name || 'Someone'} resolved a comment thread`,
                    documentId: thread.documentId,
                    threadId
                });
            }
        });

        return true;
    }, [self.id, self.info?.name]);

    // Notification system
    const sendNotification = useCallback((userId: string, notification: any) => {
        // Store notification in database
        // This would integrate with your notification system
        console.log('Notification sent to', userId, notification);

        // For demo, we could use Liveblocks broadcast or external service
        room.broadcastEvent({
            type: 'notification',
            data: { userId, notification }
        });
    }, [room]);

    // Real-time collaboration features
    const handleTextSelection = useCallback((start: number, end: number) => {
        updatePresence({ selection: { start, end } });

        // Broadcast selection change for real-time collaboration
        room.broadcastEvent({
            type: 'selection_change',
            data: { documentId: activeDocument, selection: { start, end } }
        });
    }, [updatePresence, room, activeDocument]);

    const handleCursorPosition = useCallback((x: number, y: number) => {
        updatePresence({ cursor: { x, y } });
    }, [updatePresence]);

    // Component for displaying user presence
    const UserPresenceIndicator: React.FC<{ userId: string }> = ({ userId }) => {
        const user = others.get(userId);
        if (!user) return null;

        const presence = user.presence as UserPresence;

        return (
            <div className="user-presence">
                <div
                    className="presence-avatar"
                    style={{ backgroundColor: user.info?.color || '#666' }}
                    title={user.info?.name || 'Anonymous'}
                >
                    {user.info?.name?.[0] || 'A'}
                </div>
                <div className="presence-status">
                    <div className={`status-indicator ${presence.status}`} />
                    <span className="status-text">
                        {presence.status === 'online' ? 'Active' :
                         presence.status === 'away' ? 'Away' : 'Busy'}
                    </span>
                    {presence.currentDocument && (
                        <span className="current-document">
                            in {presence.currentDocument}
                        </span>
                    )}
                </div>
            </div>
        );
    };

    // Component for threaded comments
    const CommentThreadComponent: React.FC<{ thread: CommentThread }> = ({ thread }) => {
        const [replyText, setReplyText] = useState('');
        const [isExpanded, setIsExpanded] = useState(false);

        const handleReply = () => {
            if (!replyText.trim()) return;

            replyToThread(thread.id, {
                userId: self.id,
                userName: self.info?.name || 'Anonymous',
                content: replyText.trim(),
                mentions: extractMentions(replyText)
            });

            setReplyText('');
        };

        return (
            <div className="comment-thread">
                <div className="comment-header">
                    <div className="comment-author">{thread.userName}</div>
                    <div className="comment-timestamp">
                        {thread.timestamp.toLocaleString()}
                    </div>
                </div>
                <div className="comment-content">{thread.content}</div>

                {thread.replies.length > 0 && (
                    <div className="replies">
                        <button
                            className="toggle-replies"
                            onClick={() => setIsExpanded(!isExpanded)}
                        >
                            {thread.replies.length} replies {isExpanded ? '▼' : '▶'}
                        </button>

                        {isExpanded && thread.replies.map(reply => (
                            <div key={reply.id} className="reply">
                                <div className="reply-header">
                                    <strong>{reply.userName}</strong>
                                    <span>{reply.timestamp.toLocaleString()}</span>
                                </div>
                                <div className="reply-content">{reply.content}</div>
                            </div>
                        ))}
                    </div>
                )}

                <div className="reply-form">
                    <input
                        type="text"
                        placeholder="Reply..."
                        value={replyText}
                        onChange={(e) => setReplyText(e.target.value)}
                        onKeyPress={(e) => e.key === 'Enter' && handleReply()}
                    />
                    <button onClick={handleReply} disabled={!replyText.trim()}>
                        Reply
                    </button>
                </div>

                {!thread.resolved && (
                    <button
                        className="resolve-btn"
                        onClick={() => resolveThread(thread.id)}
                    >
                        Resolve
                    </button>
                )}
            </div>
        );
    };

    // Utility functions
    const generateId = () => Math.random().toString(36).substr(2, 9);

    const extractMentions = (text: string): string[] => {
        const mentionRegex = /@([a-zA-Z0-9_]+)/g;
        const mentions = [];
        let match;

        while ((match = mentionRegex.exec(text)) !== null) {
            mentions.push(match[1]);
        }

        return mentions;
    };

    return (
        <LiveblocksProvider client={client}>
            <div className="collaboration-system">
                <div className="sidebar">
                    <div className="presence-panel">
                        <h3>Active Users</h3>
                        <div className="user-list">
                            {Array.from(others.values()).map(user => (
                                <UserPresenceIndicator key={user.id} userId={user.id} />
                            ))}
                        </div>
                    </div>

                    <div className="comments-panel">
                        <div className="panel-header">
                            <h3>Comments</h3>
                            <button
                                onClick={() => setShowCommentsPanel(!showCommentsPanel)}
                            >
                                {showCommentsPanel ? '▼' : '▶'}
                            </button>
                        </div>

                        {showCommentsPanel && (
                            <div className="comments-list">
                                {documentComments.map(thread => (
                                    <CommentThreadComponent key={thread.id} thread={thread} />
                                ))}
                            </div>
                        )}
                    </div>

                    <div className="notifications-settings">
                        <h4>Notifications</h4>
                        <label>
                            <input
                                type="checkbox"
                                checked={notificationSettings.mentions}
                                onChange={(e) => setNotificationSettings(prev => ({
                                    ...prev,
                                    mentions: e.target.checked
                                }))}
                            />
                            Mentions
                        </label>
                        <label>
                            <input
                                type="checkbox"
                                checked={notificationSettings.replies}
                                onChange={(e) => setNotificationSettings(prev => ({
                                    ...prev,
                                    replies: e.target.checked
                                }))}
                            />
                            Replies
                        </label>
                        <label>
                            <input
                                type="checkbox"
                                checked={notificationSettings.documentUpdates}
                                onChange={(e) => setNotificationSettings(prev => ({
                                    ...prev,
                                    documentUpdates: e.target.checked
                                }))}
                            />
                            Document Updates
                        </label>
                    </div>
                </div>

                <div className="main-content">
                    <div className="document-switcher">
                        <select
                            value={activeDocument || ''}
                            onChange={(e) => switchDocument(e.target.value)}
                        >
                            <option value="">Select Document</option>
                            <option value="doc1">Document 1</option>
                            <option value="doc2">Document 2</option>
                            <option value="doc3">Document 3</option>
                        </select>
                    </div>

                    {activeDocument ? (
                        <div className="document-editor">
                            <textarea
                                placeholder="Start collaborating..."
                                onSelect={(e) => {
                                    const start = e.currentTarget.selectionStart;
                                    const end = e.currentTarget.selectionEnd;
                                    handleTextSelection(start, end);
                                }}
                                onMouseMove={(e) => {
                                    const rect = e.currentTarget.getBoundingClientRect();
                                    const x = e.clientX - rect.left;
                                    const y = e.clientY - rect.top;
                                    handleCursorPosition(x, y);
                                }}
                            />

                            <div className="live-cursors">
                                {Array.from(others.values()).map(user => {
                                    const presence = user.presence as UserPresence;
                                    if (!presence.cursor || presence.currentDocument !== activeDocument) return null;

                                    return (
                                        <div
                                            key={user.id}
                                            className="live-cursor"
                                            style={{
                                                left: presence.cursor.x,
                                                top: presence.cursor.y,
                                                borderColor: user.info?.color || '#666'
                                            }}
                                        >
                                            <span>{user.info?.name || 'A'}</span>
                                        </div>
                                    );
                                })}
                            </div>
                        </div>
                    ) : (
                        <div className="welcome-screen">
                            <h2>Welcome to Advanced Collaboration</h2>
                            <p>Select a document to start collaborating</p>
                        </div>
                    )}
                </div>
            </div>
        </LiveblocksProvider>
    );
};

export default AdvancedCollaborationSystem;