UNPKG

sourabhrealtime

Version:

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

279 lines (238 loc) 7.45 kB
import { useState, useEffect, useRef, useCallback } from 'react'; import { io } from 'socket.io-client'; export function useRealtimeCursors({ roomId, userId, userName, userColor = '#3b82f6', wsUrl = 'ws://localhost:1234', debug = false }) { // State const [cursors, setCursors] = useState({}); const [collaborators, setCollaborators] = useState([]); const [typingStatus, setTypingStatus] = useState({}); const [connected, setConnected] = useState(false); // Refs const socketRef = useRef(null); const heartbeatIntervalRef = useRef(null); const typingTimeoutsRef = useRef({}); // Extract the socket.io URL from the WebSocket URL const socketUrl = wsUrl.replace('ws://', 'http://').replace('wss://', 'https://'); // Initialize socket connection useEffect(() => { if (debug) console.log('Initializing socket connection'); // Create socket connection const socket = io(socketUrl, { query: { roomId, userId }, transports: ['websocket', 'polling'], reconnectionAttempts: 10, reconnectionDelay: 1000, reconnectionDelayMax: 5000, timeout: 20000 }); socketRef.current = socket; // Handle connection events socket.on('connect', () => { if (debug) console.log('Socket connected'); setConnected(true); // Join room socket.emit('join-room', { roomId, user: { id: userId, name: userName, color: userColor } }); }); socket.on('disconnect', () => { if (debug) console.log('Socket disconnected'); setConnected(false); }); // Handle room events socket.on('room-users', ({ users }) => { if (debug) console.log('Room users:', users); const filteredUsers = users.filter(user => user.id !== userId); setCollaborators(filteredUsers); }); socket.on('user-joined', ({ user }) => { if (debug) console.log('User joined:', user); if (user.id === userId) return; setCollaborators(prev => { const exists = prev.some(u => u.id === user.id); if (exists) return prev; return [...prev, user]; }); }); socket.on('user-left', ({ userId: leftUserId }) => { if (debug) console.log('User left:', leftUserId); setCollaborators(prev => prev.filter(user => user.id !== leftUserId)); setCursors(prev => { const newCursors = { ...prev }; delete newCursors[leftUserId]; return newCursors; }); setTypingStatus(prev => { const newStatus = { ...prev }; delete newStatus[leftUserId]; return newStatus; }); }); // Handle cursor events socket.on('cursor-update', (data) => { const { userId: cursorUserId, position } = data; if (cursorUserId === userId) return; setCursors(prev => ({ ...prev, [cursorUserId]: { id: cursorUserId, position, user: data.user || collaborators.find(u => u.id === cursorUserId) || { id: cursorUserId, name: 'Unknown' }, timestamp: Date.now() } })); }); // Handle typing events socket.on('typing-status', (data) => { const { userId: typingUserId, isTyping } = data; if (typingUserId === userId) return; setTypingStatus(prev => ({ ...prev, [typingUserId]: { isTyping, user: data.user || collaborators.find(u => u.id === typingUserId) || { id: typingUserId, name: 'Unknown' }, timestamp: Date.now() } })); }); // Set up heartbeat heartbeatIntervalRef.current = setInterval(() => { if (socket.connected) { socket.emit('heartbeat', { timestamp: Date.now() }); } }, 5000); // Cleanup return () => { if (debug) console.log('Cleaning up socket connection'); clearInterval(heartbeatIntervalRef.current); Object.values(typingTimeoutsRef.current).forEach(timeout => { clearTimeout(timeout); }); socket.disconnect(); }; }, [roomId, userId, userName, userColor, socketUrl, debug]); // Update cursor position const updateCursor = useCallback((position) => { if (!socketRef.current || !socketRef.current.connected) return; // Handle heartbeat separately if (position.heartbeat) { socketRef.current.emit('heartbeat', { timestamp: Date.now() }); return; } // Send cursor position update socketRef.current.emit('cursor-position', { roomId, userId, position: { ...position, timestamp: Date.now() } }); // Also send cursor-move for compatibility socketRef.current.emit('cursor-move', { x: position.x, y: position.y, relativeX: position.relativeX, relativeY: position.relativeY, textPosition: position.textPosition }); }, [roomId, userId]); // Update typing status with debounce const updateTypingStatus = useCallback((isTyping) => { if (!socketRef.current || !socketRef.current.connected) return; // Clear existing timeout if (typingTimeoutsRef.current[userId]) { clearTimeout(typingTimeoutsRef.current[userId]); } // Send typing status socketRef.current.emit('typing-status', { roomId, userId, isTyping, user: { id: userId, name: userName, color: userColor } }); // Set timeout to reset typing status if (isTyping) { typingTimeoutsRef.current[userId] = setTimeout(() => { if (socketRef.current && socketRef.current.connected) { socketRef.current.emit('typing-status', { roomId, userId, isTyping: false, user: { id: userId, name: userName, color: userColor } }); } }, 5000); } }, [roomId, userId, userName, userColor]); // Register a user const registerUser = useCallback((user) => { if (!socketRef.current || !socketRef.current.connected) return; socketRef.current.emit('register-user', { roomId, user }); }, [roomId]); // Unregister a user const unregisterUser = useCallback((userId) => { if (!socketRef.current || !socketRef.current.connected) return; socketRef.current.emit('unregister-user', { roomId, userId }); }, [roomId]); return { cursors, collaborators, typingStatus, connected, updateCursor, updateTypingStatus, registerUser, unregisterUser }; } // Update content with cursor position const updateContent = useCallback((content, cursorPosition = null) => { if (!socketRef.current || !socketRef.current.connected) return; // Send content update with version and cursor position socketRef.current.emit('content-update', { roomId, userId, content, version: Date.now(), cursorPosition }); // Also send content-change for compatibility socketRef.current.emit('content-change', { content, cursorPosition, user: { id: userId, name: userName, color: userColor } }); }, [roomId, userId, userName, userColor]);