Liveblocks 示例
使用 Liveblocks 的协作基础设施示例,用于实时协作、存在感知和数据同步
💻 Liveblocks 协作文本编辑器 typescript
🟡 intermediate
⭐⭐⭐⭐
使用 Liveblocks 和 Yjs 的实时协作文本编辑器,包含实时光标、评论和存在感知
⏱️ 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;
💻 Liveblocks 协作白板 typescript
🟡 intermediate
⭐⭐⭐⭐
带有绘图工具、形状和 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;
💻 Liveblocks 存在感知和评论系统 typescript
🔴 complex
⭐⭐⭐⭐⭐
完整的存在感知和线程评论系统,包含通知功能
⏱️ 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;