UNPKG

realtimecursor

Version:

Real-time collaboration system with cursor tracking and approval workflow

382 lines (332 loc) 10.1 kB
// Fixed version of the RealtimeCursor SDK client import { io } from 'socket.io-client'; import { useState, useEffect, useRef, useCallback } from 'react'; /** * RealtimeCursor class for vanilla JavaScript usage */ export class RealtimeCursor { constructor(options) { this.options = { ...options, socketUrl: options.socketUrl || options.apiUrl }; this.socket = null; this.collaborators = []; this.cursors = {}; this.typingUsers = new Set(); this.connected = false; this.projectJoined = false; } connect() { if (this.socket) { this.disconnect(); } // Connect to socket server this.socket = io(this.options.socketUrl, { query: { projectId: this.options.projectId, userId: this.options.user.id }, auth: { apiKey: this.options.apiKey }, transports: ['websocket', 'polling'], reconnectionAttempts: 5, reconnectionDelay: 1000 }); // Set up event listeners this.setupEventListeners(); this.connected = true; // Join project room this.joinProject(); } joinProject() { if (!this.socket || this.projectJoined) return; this.socket.emit('join-project', { projectId: this.options.projectId, user: { id: this.options.user.id, name: this.options.user.name, color: this.options.user.color || this.getRandomColor() } }); this.projectJoined = true; } disconnect() { if (this.socket) { this.socket.disconnect(); this.socket = null; this.collaborators = []; this.cursors = {}; this.typingUsers.clear(); this.connected = false; this.projectJoined = false; } } updateCursor(position) { if (!this.socket || !this.connected) return; this.socket.emit('cursor-move', position); } updateCursorPosition(textPosition) { if (!this.socket || !this.connected) return; this.socket.emit('cursor-position', { textPosition }); } updateContent(content, cursorPosition) { if (!this.socket || !this.connected) return; this.socket.emit('content-change', { content, cursorPosition }); } setTyping(isTyping) { if (!this.socket || !this.connected) return; this.socket.emit('user-typing', { isTyping }); } setupEventListeners() { if (!this.socket) return; this.socket.on('connect', () => { console.log('Connected to RealtimeCursor server'); if (!this.projectJoined) { this.joinProject(); } }); this.socket.on('disconnect', () => { console.log('Disconnected from RealtimeCursor server'); this.projectJoined = false; }); this.socket.on('room-users', (users) => { this.collaborators = users; if (this.onCollaboratorsChange) this.onCollaboratorsChange(users); }); this.socket.on('user-joined', ({ user }) => { // Check if user already exists const existingUser = this.collaborators.find(u => u.id === user.id); if (!existingUser) { this.collaborators.push(user); if (this.onCollaboratorsChange) this.onCollaboratorsChange(this.collaborators); if (this.onUserJoined) this.onUserJoined(user); } }); this.socket.on('user-left', ({ socketId }) => { this.collaborators = this.collaborators.filter(u => u.socketId !== socketId); delete this.cursors[socketId]; this.typingUsers.delete(socketId); if (this.onCollaboratorsChange) this.onCollaboratorsChange(this.collaborators); if (this.onUserLeft) this.onUserLeft({ socketId }); }); this.socket.on('content-update', (data) => { if (this.onContentUpdate) this.onContentUpdate(data); }); this.socket.on('cursor-update', (data) => { const { socketId, user, x, y, textPosition } = data; this.cursors[socketId] = { x, y, user, textPosition }; if (this.onCursorUpdate) this.onCursorUpdate(data); }); this.socket.on('user-typing', ({ socketId, isTyping }) => { if (isTyping) { this.typingUsers.add(socketId); } else { this.typingUsers.delete(socketId); } if (this.onTypingStatusChange) this.onTypingStatusChange(Array.from(this.typingUsers)); }); this.socket.on('cursor-position', (data) => { const { socketId, textPosition, user } = data; if (this.cursors[socketId]) { this.cursors[socketId].textPosition = textPosition; this.cursors[socketId].user = user; } else { this.cursors[socketId] = { textPosition, user }; } if (this.onCursorPositionUpdate) this.onCursorPositionUpdate(data); }); } getRandomColor() { const colors = [ '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#06b6d4', '#f97316', '#84cc16', '#ec4899', '#6366f1', '#14b8a6', '#f43f5e' ]; return colors[Math.floor(Math.random() * colors.length)]; } getCollaborators() { return this.collaborators; } getCursors() { return this.cursors; } getTypingUsers() { return Array.from(this.typingUsers); } } /** * React hook for using RealtimeCursor */ export function useRealtimeCursor(options) { const [cursors, setCursors] = useState({}); const [collaborators, setCollaborators] = useState([]); const [typingUsers, setTypingUsers] = useState([]); const [isConnected, setIsConnected] = useState(false); // Use refs to prevent recreating the client on each render const clientRef = useRef(null); const optionsRef = useRef(options); // Update options ref when options change useEffect(() => { optionsRef.current = options; }, [options]); // Initialize the client useEffect(() => { if (!clientRef.current) { clientRef.current = new RealtimeCursor(optionsRef.current); } return () => { if (clientRef.current) { clientRef.current.disconnect(); } }; }, []); // Set up event handlers useEffect(() => { const client = clientRef.current; if (!client) return; client.onCursorUpdate = (data) => { setCursors(prev => ({ ...prev, [data.socketId]: { x: data.x, y: data.y, textPosition: data.textPosition, user: data.user } })); }; client.onCollaboratorsChange = (users) => { setCollaborators(users); }; client.onTypingStatusChange = (typingUserIds) => { setTypingUsers(typingUserIds); // Update collaborators with typing status setCollaborators(prev => prev.map(user => ({ ...user, isTyping: typingUserIds.includes(user.socketId) })) ); }; client.onUserLeft = ({ socketId }) => { setCursors(prev => { const newCursors = { ...prev }; delete newCursors[socketId]; return newCursors; }); }; }, []); // Connect function const connect = useCallback(() => { if (clientRef.current) { clientRef.current.connect(); setIsConnected(true); } }, []); // Disconnect function const disconnect = useCallback(() => { if (clientRef.current) { clientRef.current.disconnect(); setIsConnected(false); setCursors({}); setCollaborators([]); setTypingUsers([]); } }, []); // Update cursor position const updateCursor = useCallback((position) => { if (clientRef.current) { clientRef.current.updateCursor(position); } }, []); // Update content const updateContent = useCallback((content, cursorPosition) => { if (clientRef.current) { clientRef.current.updateContent(content, cursorPosition); } }, []); // Set typing status const setTyping = useCallback((isTyping) => { if (clientRef.current) { clientRef.current.setTyping(isTyping); } }, []); // Auto-connect if specified in options useEffect(() => { if (optionsRef.current.autoConnect) { connect(); } }, [connect]); return { cursors, collaborators, typingUsers, isConnected, connect, disconnect, updateCursor, updateContent, setTyping }; } /** * CursorOverlay component for React */ export function CursorOverlay({ cursors, containerRef, renderCursor }) { return ( <div className="realtimecursor-overlay" style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, pointerEvents: 'none' }}> {Object.entries(cursors).map(([socketId, cursor]) => { if (!cursor || !cursor.user) return null; if (renderCursor) { return renderCursor({ socketId, ...cursor }); } return ( <div key={socketId} className="realtimecursor-cursor" style={{ position: 'fixed', left: `${cursor.x}px`, top: `${cursor.y}px`, transform: 'translate(-50%, -50%)', zIndex: 9999, pointerEvents: 'none' }} > <div className="realtimecursor-pointer" style={{ width: '12px', height: '12px', borderRadius: '50%', backgroundColor: cursor.user.color || '#3b82f6', boxShadow: '0 0 5px rgba(0,0,0,0.3)' }} /> <div className="realtimecursor-name" style={{ padding: '2px 6px', borderRadius: '4px', backgroundColor: cursor.user.color || '#3b82f6', color: '#fff', fontSize: '12px', marginTop: '4px', whiteSpace: 'nowrap' }} > {cursor.user.name} </div> </div> ); })} </div> ); } export default { RealtimeCursor, useRealtimeCursor, CursorOverlay };