UNPKG

sourabhrealtime

Version:

ROBUST RICH TEXT EDITOR: Single-pane contentEditable with direct text selection formatting, speech features, undo/redo, professional UI - Perfect TipTap alternative

280 lines (241 loc) 7 kB
// Real-time collaboration engine with advanced features export class RealtimeEngine { constructor() { this.cursors = new Map(); this.selections = new Map(); this.typingUsers = new Map(); this.operations = new Map(); this.awareness = new Map(); } // Track user cursor position updateCursor(projectId, userId, position, user) { if (!this.cursors.has(projectId)) { this.cursors.set(projectId, new Map()); } const projectCursors = this.cursors.get(projectId); projectCursors.set(userId, { userId, user, position, timestamp: Date.now(), color: user.color || this.generateUserColor(userId) }); return { type: 'cursor-update', projectId, userId, position, user, timestamp: Date.now() }; } // Track text selection updateSelection(projectId, userId, selection, user) { if (!this.selections.has(projectId)) { this.selections.set(projectId, new Map()); } const projectSelections = this.selections.get(projectId); projectSelections.set(userId, { userId, user, selection, timestamp: Date.now(), color: user.color || this.generateUserColor(userId) }); return { type: 'selection-update', projectId, userId, selection, user, timestamp: Date.now() }; } // Track typing status updateTyping(projectId, userId, isTyping, user) { if (!this.typingUsers.has(projectId)) { this.typingUsers.set(projectId, new Map()); } const projectTyping = this.typingUsers.get(projectId); if (isTyping) { projectTyping.set(userId, { userId, user, startedAt: Date.now() }); } else { projectTyping.delete(userId); } return { type: 'typing-update', projectId, userId, isTyping, user, timestamp: Date.now() }; } // Process content operation (for operational transformation) processOperation(projectId, userId, operation, user) { if (!this.operations.has(projectId)) { this.operations.set(projectId, []); } const projectOps = this.operations.get(projectId); const op = { id: 'op_' + Math.random().toString(36).substr(2, 9), userId, user, operation, timestamp: Date.now(), applied: false }; projectOps.push(op); // Keep only last 100 operations if (projectOps.length > 100) { projectOps.splice(0, projectOps.length - 100); } return { type: 'operation', projectId, operation: op, timestamp: Date.now() }; } // Update user awareness (viewport, focus, etc.) updateAwareness(projectId, userId, awareness, user) { if (!this.awareness.has(projectId)) { this.awareness.set(projectId, new Map()); } const projectAwareness = this.awareness.get(projectId); projectAwareness.set(userId, { userId, user, ...awareness, timestamp: Date.now() }); return { type: 'awareness-update', projectId, userId, awareness, user, timestamp: Date.now() }; } // Get project state getProjectState(projectId) { return { cursors: Array.from(this.cursors.get(projectId)?.values() || []), selections: Array.from(this.selections.get(projectId)?.values() || []), typingUsers: Array.from(this.typingUsers.get(projectId)?.values() || []), awareness: Array.from(this.awareness.get(projectId)?.values() || []), operations: this.operations.get(projectId) || [] }; } // Clean up user data when they leave removeUser(projectId, userId) { this.cursors.get(projectId)?.delete(userId); this.selections.get(projectId)?.delete(userId); this.typingUsers.get(projectId)?.delete(userId); this.awareness.get(projectId)?.delete(userId); return { type: 'user-left', projectId, userId, timestamp: Date.now() }; } // Generate consistent color for user generateUserColor(userId) { const colors = [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9', '#F8C471', '#82E0AA', '#F1948A', '#85C1E9', '#D7BDE2' ]; let hash = 0; for (let i = 0; i < userId.length; i++) { hash = userId.charCodeAt(i) + ((hash << 5) - hash); } return colors[Math.abs(hash) % colors.length]; } // Apply operational transformation transformOperation(op1, op2) { // Simplified OT - in production, use a proper OT library if (op1.type === 'insert' && op2.type === 'insert') { if (op1.position <= op2.position) { return { ...op2, position: op2.position + op1.text.length }; } } if (op1.type === 'delete' && op2.type === 'insert') { if (op1.position < op2.position) { return { ...op2, position: op2.position - op1.length }; } } return op2; } // Get typing indicators for display getTypingIndicators(projectId, excludeUserId = null) { const typing = this.typingUsers.get(projectId); if (!typing) return []; const now = Date.now(); const indicators = []; for (const [userId, data] of typing.entries()) { if (userId !== excludeUserId && now - data.startedAt < 3000) { indicators.push({ userId, user: data.user, duration: now - data.startedAt }); } } return indicators; } // Get active cursors for display getActiveCursors(projectId, excludeUserId = null) { const cursors = this.cursors.get(projectId); if (!cursors) return []; const now = Date.now(); const activeCursors = []; for (const [userId, data] of cursors.entries()) { if (userId !== excludeUserId && now - data.timestamp < 10000) { activeCursors.push(data); } } return activeCursors; } // Clean up stale data cleanup() { const now = Date.now(); const staleThreshold = 30000; // 30 seconds // Clean up stale cursors for (const [projectId, cursors] of this.cursors.entries()) { for (const [userId, data] of cursors.entries()) { if (now - data.timestamp > staleThreshold) { cursors.delete(userId); } } } // Clean up stale typing indicators for (const [projectId, typing] of this.typingUsers.entries()) { for (const [userId, data] of typing.entries()) { if (now - data.startedAt > 5000) { typing.delete(userId); } } } // Clean up stale awareness for (const [projectId, awareness] of this.awareness.entries()) { for (const [userId, data] of awareness.entries()) { if (now - data.timestamp > staleThreshold) { awareness.delete(userId); } } } } }