UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

432 lines (371 loc) 13.4 kB
/** * Yjs WebSocket Provider for Block-Level Collaborative Editing * * Provides real-time synchronization between clients through WebSocket connection * to the collaboration server. Handles block-level document management, * user awareness, and automatic reconnection. * * Migrated from BMS components and adapted for ClarityKit patterns. */ import { WebsocketProvider } from 'y-websocket'; import * as Y from 'yjs'; export class YjsWebSocketProvider { constructor(config = {}) { this.config = { serverUrl: config.serverUrl || 'ws://localhost:8001', reconnectInterval: config.reconnectInterval || 3000, maxReconnectAttempts: config.maxReconnectAttempts || 10, heartbeatInterval: config.heartbeatInterval || 30000, ...config }; this.providers = new Map(); // blockId -> WebsocketProvider this.documents = new Map(); // blockId -> Y.Doc this.awareness = new Map(); // blockId -> awareness state this.reconnectAttempts = new Map(); // blockId -> attempt count this.connectionStates = new Map(); // blockId -> connection state this.eventHandlers = new Map(); // blockId -> event handlers this.userId = config.userId || this.generateUserId(); this.userInfo = config.userInfo || { name: 'Anonymous', color: this.generateUserColor() }; // Initialize event listeners this.listeners = {}; // Connection state tracking this.isOnline = navigator.onLine; this.setupNetworkListeners(); } /** * Connect to a block's collaborative session */ async connectToBlock(blockId, options = {}) { if (this.providers.has(blockId)) { console.warn(`Already connected to block ${blockId}`); return this.providers.get(blockId); } // Create Y.Doc for this block const ydoc = new Y.Doc(); this.documents.set(blockId, ydoc); try { // Create WebSocket provider for this specific block const provider = new WebsocketProvider( this.config.serverUrl, `block-${blockId}`, ydoc, { connect: true, ...options } ); // Store provider and related data this.providers.set(blockId, provider); this.awareness.set(blockId, provider.awareness); this.connectionStates.set(blockId, 'connecting'); this.reconnectAttempts.set(blockId, 0); // Set awareness user info after creation if (provider.awareness) { provider.awareness.setLocalState({ user: { id: this.userId, ...this.userInfo, cursor: null, selection: null, lastSeen: Date.now() } }); } // Set up event listeners for this block this.setupBlockEventListeners(blockId, provider, ydoc); return provider; } catch (error) { console.error('Failed to create WebsocketProvider:', error); this.connectionStates.set(blockId, 'error'); throw error; } } /** * Disconnect from a block's collaborative session */ disconnectFromBlock(blockId) { const provider = this.providers.get(blockId); if (!provider) { console.warn(`Not connected to block ${blockId}`); return; } try { provider.destroy(); } catch (error) { console.warn('Error destroying provider:', error); } // Clean up resources this.providers.delete(blockId); this.documents.delete(blockId); this.awareness.delete(blockId); this.reconnectAttempts.delete(blockId); this.connectionStates.delete(blockId); this.eventHandlers.delete(blockId); this.emit('block-disconnected', { blockId }); } /** * Get the Y.Doc for a specific block */ getDocument(blockId) { return this.documents.get(blockId); } /** * Get the awareness instance for a specific block */ getAwareness(blockId) { return this.awareness.get(blockId); } /** * Get connection state for a block */ getConnectionState(blockId) { return this.connectionStates.get(blockId) || 'disconnected'; } /** * Update user awareness information */ updateAwareness(blockId, updates) { const awareness = this.awareness.get(blockId); if (!awareness) { console.warn(`No awareness instance for block ${blockId}`); return; } const currentState = awareness.getLocalState() || {}; const newState = { ...currentState, ...updates, lastSeen: Date.now() }; try { awareness.setLocalState(newState); } catch (error) { console.warn('Failed to update awareness:', error); } } /** * Set cursor position for a block */ setCursor(blockId, cursor) { this.updateAwareness(blockId, { cursor }); } /** * Set selection range for a block */ setSelection(blockId, selection) { this.updateAwareness(blockId, { selection }); } /** * Get all connected users for a block */ getConnectedUsers(blockId) { const awareness = this.awareness.get(blockId); if (!awareness) return []; const users = []; try { awareness.getStates().forEach((state, clientId) => { if (clientId !== awareness.clientID && state.user) { users.push({ clientId, ...state.user, isActive: Date.now() - (state.lastSeen || 0) < 60000 // Active in last minute }); } }); } catch (error) { console.warn('Failed to get connected users:', error); } return users; } /** * Setup event listeners for a block's provider and document */ setupBlockEventListeners(blockId, provider, ydoc) { const handlers = { onConnectionChange: (event) => { const connected = event.status === 'connected'; const state = connected ? 'connected' : 'disconnected'; this.connectionStates.set(blockId, state); if (connected) { this.reconnectAttempts.set(blockId, 0); this.emit('block-connected', { blockId }); } else { this.emit('block-disconnected', { blockId }); this.handleReconnection(blockId); } }, onSync: (synced) => { if (synced) { this.connectionStates.set(blockId, 'synced'); this.emit('block-synced', { blockId }); } }, onDocumentUpdate: (update, origin) => { this.emit('document-updated', { blockId, update, origin }); }, onAwarenessUpdate: ({ added, updated, removed }) => { this.emit('awareness-updated', { blockId, added, updated, removed, users: this.getConnectedUsers(blockId) }); } }; try { // Bind event listeners - y-websocket uses different event patterns provider.on('status', handlers.onConnectionChange); provider.on('synced', handlers.onSync); ydoc.on('update', handlers.onDocumentUpdate); // Awareness events if (provider.awareness) { provider.awareness.on('update', handlers.onAwarenessUpdate); } this.eventHandlers.set(blockId, handlers); } catch (error) { console.error('Failed to setup event listeners:', error); } } /** * Handle automatic reconnection for a block */ async handleReconnection(blockId) { if (!this.isOnline) return; const attempts = this.reconnectAttempts.get(blockId) || 0; if (attempts >= this.config.maxReconnectAttempts) { this.emit('reconnection-failed', { blockId, attempts }); return; } this.reconnectAttempts.set(blockId, attempts + 1); this.connectionStates.set(blockId, 'reconnecting'); const delay = this.config.reconnectInterval * Math.pow(1.5, attempts); // Exponential backoff setTimeout(() => { const provider = this.providers.get(blockId); if (provider && !provider.wsconnected) { this.emit('reconnecting', { blockId, attempt: attempts + 1 }); try { provider.connect(); } catch (error) { console.warn('Reconnection attempt failed:', error); } } }, delay); } /** * Setup network connectivity listeners */ setupNetworkListeners() { const handleOnline = () => { this.isOnline = true; this.emit('network-online'); // Attempt to reconnect all providers this.providers.forEach((provider, blockId) => { if (!provider.wsconnected) { this.handleReconnection(blockId); } }); }; const handleOffline = () => { this.isOnline = false; this.emit('network-offline'); }; window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); // Store references for cleanup this._networkListeners = { handleOnline, handleOffline }; } /** * Get statistics about all connections */ getStats() { const stats = { totalBlocks: this.providers.size, connectedBlocks: 0, syncedBlocks: 0, totalUsers: 0, isOnline: this.isOnline }; this.connectionStates.forEach((state) => { if (state === 'connected' || state === 'synced') { stats.connectedBlocks++; } if (state === 'synced') { stats.syncedBlocks++; } }); this.awareness.forEach((awareness) => { try { stats.totalUsers += Math.max(0, awareness.getStates().size - 1); // Exclude self } catch (error) { console.warn('Failed to get awareness stats:', error); } }); return stats; } /** * Disconnect from all blocks and cleanup */ destroy() { // Disconnect from all blocks Array.from(this.providers.keys()).forEach(blockId => { this.disconnectFromBlock(blockId); }); // Remove network listeners if (this._networkListeners) { window.removeEventListener('online', this._networkListeners.handleOnline); window.removeEventListener('offline', this._networkListeners.handleOffline); } // Clear all maps this.providers.clear(); this.documents.clear(); this.awareness.clear(); this.reconnectAttempts.clear(); this.connectionStates.clear(); this.eventHandlers.clear(); this.emit('provider-destroyed'); } /** * Simple event emitter implementation */ emit(event, data) { if (this.listeners && this.listeners[event]) { this.listeners[event].forEach(callback => { try { callback(data); } catch (error) { console.error('Event listener error:', error); } }); } } on(event, callback) { if (!this.listeners) this.listeners = {}; if (!this.listeners[event]) this.listeners[event] = []; this.listeners[event].push(callback); } off(event, callback) { if (!this.listeners || !this.listeners[event]) return; const index = this.listeners[event].indexOf(callback); if (index > -1) { this.listeners[event].splice(index, 1); } } /** * Generate a unique user ID */ generateUserId() { return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Generate a random user color */ generateUserColor() { const colors = [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD', '#00D2D3', '#FF9F43', '#EE5A6F', '#0ABDE3' ]; return colors[Math.floor(Math.random() * colors.length)]; } } export default YjsWebSocketProvider;