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
JavaScript
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]);