UNPKG

realtimecursor

Version:

Real-time collaboration system with cursor tracking and approval workflow

196 lines (173 loc) 5.4 kB
import { useState, useEffect, useCallback, useRef } from 'react'; import { io } from 'socket.io-client'; const throttle = (func, delay) => { let timeoutId; let lastExecTime = 0; return function (...args) { const currentTime = Date.now(); if (currentTime - lastExecTime > delay) { func.apply(this, args); lastExecTime = currentTime; } else { clearTimeout(timeoutId); timeoutId = setTimeout(() => { func.apply(this, args); lastExecTime = Date.now(); }, delay - (currentTime - lastExecTime)); } }; }; export const useRealtimeInput = (projectId, user, inputId = 'default') => { const [content, setContent] = useState(''); const [cursors, setCursors] = useState(new Map()); const [isConnected, setIsConnected] = useState(false); const [typingUsers, setTypingUsers] = useState(new Set()); const socketRef = useRef(null); const inputRef = useRef(null); const isLocalChange = useRef(false); const throttledContentChange = useCallback( throttle((newContent, cursorPosition) => { if (socketRef.current && isConnected && !isLocalChange.current) { socketRef.current.emit('input-change', { inputId, content: newContent, cursorPosition, timestamp: Date.now() }); } }, 100), [isConnected, inputId] ); const throttledCursorMove = useCallback( throttle((position) => { if (socketRef.current && isConnected) { socketRef.current.emit('cursor-position', { inputId, position, timestamp: Date.now() }); } }, 50), [isConnected, inputId] ); const handleContentChange = useCallback((newContent, cursorPosition) => { setContent(newContent); throttledContentChange(newContent, cursorPosition); }, [throttledContentChange]); const handleCursorMove = useCallback((position) => { throttledCursorMove(position); }, [throttledCursorMove]); const startTyping = useCallback(() => { if (socketRef.current && isConnected) { socketRef.current.emit('typing-start', { inputId }); } }, [isConnected, inputId]); const stopTyping = useCallback(() => { if (socketRef.current && isConnected) { socketRef.current.emit('typing-stop', { inputId }); } }, [isConnected, inputId]); useEffect(() => { if (!projectId || !user) return; const socket = io('http://localhost:3000'); socketRef.current = socket; socket.on('connect', () => { console.log('Connected to realtime input service'); socket.emit('join-room', { roomId: projectId, user: { id: user.id, name: user.fullName, email: user.email } }); }); socket.on('room-users', () => { setIsConnected(true); }); socket.on('input-changed', ({ inputId: changedInputId, content: newContent, cursorPosition, user: changeUser }) => { if (changedInputId === inputId && changeUser.id !== user.id) { isLocalChange.current = true; setContent(newContent); // Preserve local cursor position if possible setTimeout(() => { isLocalChange.current = false; }, 100); } }); socket.on('cursor-moved', ({ inputId: changedInputId, position, user: cursorUser }) => { if (changedInputId === inputId && cursorUser.id !== user.id) { setCursors(prev => { const updated = new Map(prev); updated.set(cursorUser.id, { ...cursorUser, position, lastSeen: Date.now() }); return updated; }); } }); socket.on('user-typing', ({ inputId: typingInputId, user: typingUser, isTyping }) => { if (typingInputId === inputId && typingUser.id !== user.id) { setTypingUsers(prev => { const updated = new Set(prev); if (isTyping) { updated.add(typingUser.name); } else { updated.delete(typingUser.name); } return updated; }); } }); socket.on('user-left', ({ userId }) => { setCursors(prev => { const updated = new Map(prev); updated.delete(userId); return updated; }); setTypingUsers(prev => { const updated = new Set(prev); // Remove user from typing (we don't have name here, so clear all) return new Set(); }); }); socket.on('disconnect', () => { console.log('Disconnected from realtime input service'); setIsConnected(false); setCursors(new Map()); setTypingUsers(new Set()); }); return () => { socket.disconnect(); }; }, [projectId, user, inputId]); // Cleanup stale cursors useEffect(() => { const interval = setInterval(() => { const now = Date.now(); setCursors(prev => { const updated = new Map(); prev.forEach((cursor, userId) => { if (now - cursor.lastSeen < 10000) { // 10 seconds updated.set(userId, cursor); } }); return updated; }); }, 5000); return () => clearInterval(interval); }, []); return { content, setContent: handleContentChange, cursors, isConnected, typingUsers, inputRef, handleCursorMove, startTyping, stopTyping }; };