🎯 Exemples recommandés
Balanced sample collections from various categories for you to explore
Exemples Liveblocks
Exemples d'infrastructure collaborative utilisant Liveblocks pour la collaboration en temps réel, la présence et la synchronisation de données
💻 Éditeur Collaboratif Liveblocks typescript
🟡 intermediate
⭐⭐⭐⭐
Éditeur de texte collaboratif en temps réel utilisant Liveblocks et Yjs avec curseurs en direct, commentaires et présence
⏱️ 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;
💻 Tableau Blanc Collaboratif Liveblocks typescript
🟡 intermediate
⭐⭐⭐⭐
Tableau blanc collaboratif en temps réel avec outils de dessin, formes et présence Liveblocks
⏱️ 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;
💻 Système de Présence et Commentaires Liveblocks typescript
🔴 complex
⭐⭐⭐⭐⭐
Système complet de conscience de présence et commentaires filés avec notifications
⏱️ 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;