UNPKG

realtimecursor

Version:

Real-time collaboration system with cursor tracking and approval workflow

890 lines (780 loc) 22.8 kB
/** * RealtimeCursor Enhanced SDK * A user-friendly SDK for real-time collaboration features * Version 1.2.0 */ import io from 'socket.io-client'; import { EventEmitter } from 'events'; /** * Main RealtimeCursor class */ export class RealtimeCursor extends EventEmitter { constructor(options) { super(); // Default options this.options = { apiUrl: 'http://localhost:3001', projectId: 'default-project', user: { id: `user-${Math.floor(Math.random() * 10000)}`, name: 'Anonymous', color: this.getRandomColor() }, debug: false, autoConnect: false, ...options }; // State this.socket = null; this.cursors = {}; this.collaborators = []; this.connected = false; this.hasJoinedProject = false; this.connectionAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 2000; this.content = ''; this.version = 0; this.lastCursorPosition = null; this.typingStatus = {}; // Auto-connect if enabled if (this.options.autoConnect) { this.connect(); } // Debug logging this.log = this.options.debug ? (...args) => console.log('[RealtimeCursor]', ...args) : () => {}; } /** * Connect to the real-time service */ connect() { if (this.socket) { this.log('Already connected or connecting'); return; } this.log('Connecting to', this.options.apiUrl); this.socket = io(this.options.apiUrl, { query: { projectId: this.options.projectId, userId: this.options.user.id }, transports: ['websocket', 'polling'], reconnectionAttempts: this.maxReconnectAttempts, reconnectionDelay: this.reconnectDelay }); // Connection events this.socket.on('connect', this.handleConnect.bind(this)); this.socket.on('disconnect', this.handleDisconnect.bind(this)); this.socket.on('connect_error', this.handleConnectError.bind(this)); this.socket.on('reconnect_attempt', this.handleReconnectAttempt.bind(this)); this.socket.on('reconnect_failed', this.handleReconnectFailed.bind(this)); // Project events this.socket.on('room-users', this.handleRoomUsers.bind(this)); this.socket.on('user-joined', this.handleUserJoined.bind(this)); this.socket.on('user-left', this.handleUserLeft.bind(this)); // Content events this.socket.on('content-update', this.handleContentUpdate.bind(this)); // Cursor events this.socket.on('cursor-update', this.handleCursorUpdate.bind(this)); // Typing events this.socket.on('user-typing', this.handleUserTyping.bind(this)); // Error events this.socket.on('error', this.handleError.bind(this)); } /** * Disconnect from the real-time service */ disconnect() { if (!this.socket) { return; } this.log('Disconnecting'); this.socket.disconnect(); this.socket = null; this.connected = false; this.hasJoinedProject = false; this.emit('disconnected'); } /** * Handle socket connect event */ handleConnect() { this.log('Connected'); this.connected = true; this.connectionAttempts = 0; // Only join the project once if (!this.hasJoinedProject) { this.log('Joining project', this.options.projectId); this.socket.emit('join-project', { projectId: this.options.projectId, user: this.options.user }); this.hasJoinedProject = true; } this.emit('connected'); } /** * Handle socket disconnect event */ handleDisconnect(reason) { this.log('Disconnected:', reason); this.connected = false; this.emit('disconnected', reason); } /** * Handle socket connect error */ handleConnectError(error) { this.log('Connection error:', error); this.connectionAttempts++; this.emit('connection-error', error); } /** * Handle socket reconnect attempt */ handleReconnectAttempt(attempt) { this.log('Reconnect attempt:', attempt); this.emit('reconnect-attempt', attempt); } /** * Handle socket reconnect failed */ handleReconnectFailed() { this.log('Reconnect failed'); this.emit('reconnect-failed'); } /** * Handle room users event */ handleRoomUsers({ users }) { this.log('Room users:', users); // Filter out our own user and prevent duplicates const uniqueUsers = {}; users.forEach(user => { if (user.id !== this.options.user.id) { uniqueUsers[user.id] = user; } }); this.collaborators = Object.values(uniqueUsers); this.emit('collaborators-changed', this.collaborators); } /** * Handle user joined event */ handleUserJoined({ user }) { this.log('User joined:', user); if (user.id === this.options.user.id) { return; } // Check if user already exists to prevent duplicates const existingUserIndex = this.collaborators.findIndex(u => u.id === user.id); if (existingUserIndex === -1) { this.collaborators.push(user); this.emit('collaborators-changed', this.collaborators); this.emit('user-joined', user); } } /** * Handle user left event */ handleUserLeft({ userId, socketId }) { this.log('User left:', userId || socketId); const id = userId || socketId; // Remove user from collaborators const index = this.collaborators.findIndex(user => user.id === id || user.socketId === socketId ); if (index !== -1) { const user = this.collaborators[index]; this.collaborators.splice(index, 1); this.emit('collaborators-changed', this.collaborators); this.emit('user-left', user); } // Remove cursor if (this.cursors[id]) { delete this.cursors[id]; this.emit('cursors-changed', this.cursors); } // Remove typing status if (this.typingStatus[id]) { delete this.typingStatus[id]; this.emit('typing-status-changed', this.typingStatus); } } /** * Handle content update event */ handleContentUpdate(data) { this.log('Content update:', data); if (data.userId === this.options.user.id || data.socketId === this.socket.id) { return; } this.emit('content-updated', data); } /** * Handle cursor update event */ handleCursorUpdate(data) { const { userId, socketId, user, position, x, y, relativeX, relativeY, textPosition, timestamp } = data; const id = userId || (user && user.id) || socketId; if (!id || id === this.options.user.id) { return; } this.cursors[id] = { id, position: position || { x, y, relativeX, relativeY, textPosition }, user: user || { id, name: 'Unknown' }, timestamp: timestamp || Date.now() }; this.emit('cursors-changed', this.cursors); } /** * Handle user typing event */ handleUserTyping({ socketId, userId, isTyping, user }) { const id = userId || (user && user.id) || socketId; if (!id || id === this.options.user.id) { return; } this.typingStatus[id] = { id, isTyping, user: user || { id, name: 'Unknown' }, timestamp: Date.now() }; this.emit('typing-status-changed', this.typingStatus); } /** * Handle error event */ handleError(error) { this.log('Error:', error); this.emit('error', error); } /** * Update cursor position */ updateCursor(position) { if (!this.socket || !this.connected) { return; } this.lastCursorPosition = position; this.socket.emit('cursor-position', { projectId: this.options.projectId, position }); this.socket.emit('cursor-move', { x: position.x, y: position.y, relativeX: position.relativeX, relativeY: position.relativeY, textPosition: position.textPosition }); } /** * Update content */ updateContent(content) { if (!this.socket || !this.connected) { return; } this.content = content; this.version++; this.socket.emit('content-update', { projectId: this.options.projectId, content, version: this.version }); this.socket.emit('content-change', { content, cursorPosition: this.lastCursorPosition }); } /** * Update typing status */ updateTypingStatus(isTyping) { if (!this.socket || !this.connected) { return; } this.socket.emit('user-typing', { isTyping }); } /** * Get all collaborators */ getCollaborators() { return this.collaborators; } /** * Get all cursors */ getCursors() { return this.cursors; } /** * Get typing status */ getTypingStatus() { return this.typingStatus; } /** * Get connection status */ isConnected() { return this.connected; } /** * Generate a random color */ getRandomColor() { const colors = [ '#3b82f6', // blue '#ef4444', // red '#10b981', // green '#f59e0b', // yellow '#8b5cf6', // purple '#ec4899', // pink '#06b6d4', // cyan '#f97316', // orange ]; return colors[Math.floor(Math.random() * colors.length)]; } } /** * React hook for using RealtimeCursor */ export function useRealtimeCursor(options) { const React = require('react'); const { useState, useEffect, useRef, useCallback } = React; const [cursors, setCursors] = useState({}); const [collaborators, setCollaborators] = useState([]); const [connected, setConnected] = useState(false); const [typingStatus, setTypingStatus] = useState({}); const cursorClientRef = useRef(null); useEffect(() => { // Initialize the cursor client const cursorClient = new RealtimeCursor({ ...options, autoConnect: false }); cursorClientRef.current = cursorClient; // Set up event handlers cursorClient.on('connected', () => setConnected(true)); cursorClient.on('disconnected', () => setConnected(false)); cursorClient.on('cursors-changed', setCursors); cursorClient.on('collaborators-changed', setCollaborators); cursorClient.on('typing-status-changed', setTypingStatus); return () => { cursorClient.disconnect(); cursorClient.removeAllListeners(); }; }, [options.apiUrl, options.projectId, options.user.id]); const connect = useCallback(() => { if (cursorClientRef.current) { cursorClientRef.current.connect(); } }, []); const disconnect = useCallback(() => { if (cursorClientRef.current) { cursorClientRef.current.disconnect(); } }, []); const updateCursor = useCallback((position) => { if (cursorClientRef.current) { cursorClientRef.current.updateCursor(position); } }, []); const updateContent = useCallback((content) => { if (cursorClientRef.current) { cursorClientRef.current.updateContent(content); } }, []); const updateTypingStatus = useCallback((isTyping) => { if (cursorClientRef.current) { cursorClientRef.current.updateTypingStatus(isTyping); } }, []); return { cursors, collaborators, connected, typingStatus, connect, disconnect, updateCursor, updateContent, updateTypingStatus }; } /** * React component for displaying cursors */ export function CursorOverlay({ cursors, containerRef }) { const React = require('react'); const { useRef, useEffect } = React; const overlayRef = useRef(null); useEffect(() => { if (!overlayRef.current) return; // Clear existing cursors while (overlayRef.current.firstChild) { overlayRef.current.removeChild(overlayRef.current.firstChild); } // Add new cursors Object.values(cursors).forEach(cursor => { const cursorElement = document.createElement('div'); cursorElement.className = 'realtimecursor-cursor'; cursorElement.style.position = 'absolute'; cursorElement.style.left = `${cursor.position.x || cursor.position.relativeX || 0}px`; cursorElement.style.top = `${cursor.position.y || cursor.position.relativeY || 0}px`; cursorElement.style.pointerEvents = 'none'; cursorElement.style.zIndex = '9999'; cursorElement.style.transition = 'transform 0.1s ease-out'; // Create cursor SVG const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '24'); svg.setAttribute('height', '24'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); svg.style.transform = 'rotate(-45deg)'; svg.style.filter = 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))'; const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'M1 1L11 11V19L7 21V13L1 1Z'); path.setAttribute('fill', cursor.user.color || '#3b82f6'); path.setAttribute('stroke', 'white'); path.setAttribute('stroke-width', '1'); svg.appendChild(path); cursorElement.appendChild(svg); // Create label const label = document.createElement('div'); label.className = 'realtimecursor-label'; label.style.position = 'absolute'; label.style.left = '16px'; label.style.top = '8px'; label.style.backgroundColor = cursor.user.color || '#3b82f6'; label.style.color = 'white'; label.style.padding = '2px 6px'; label.style.borderRadius = '4px'; label.style.fontSize = '12px'; label.style.fontWeight = 'bold'; label.style.whiteSpace = 'nowrap'; label.style.boxShadow = '0 1px 2px rgba(0,0,0,0.25)'; label.textContent = cursor.user.name; // Add typing indicator if user is typing if (cursor.isTyping) { const typingIndicator = document.createElement('span'); typingIndicator.className = 'realtimecursor-typing-indicator'; typingIndicator.textContent = ' ✎'; label.appendChild(typingIndicator); } cursorElement.appendChild(label); overlayRef.current.appendChild(cursorElement); }); }, [cursors]); return React.createElement('div', { ref: overlayRef, className: 'realtimecursor-overlay', style: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, pointerEvents: 'none', zIndex: 9999 } }); } /** * React component for displaying collaborators */ export function CollaboratorsList({ collaborators, typingStatus }) { const React = require('react'); return React.createElement( 'div', { className: 'realtimecursor-collaborators' }, collaborators.length > 0 ? React.createElement( 'div', { className: 'realtimecursor-collaborators-list' }, collaborators.map(user => { const isTyping = typingStatus && typingStatus[user.id] && typingStatus[user.id].isTyping; return React.createElement( 'div', { key: user.id, className: 'realtimecursor-collaborator', style: { display: 'flex', alignItems: 'center', marginBottom: '8px' } }, [ React.createElement( 'div', { className: 'realtimecursor-collaborator-avatar', style: { width: '24px', height: '24px', borderRadius: '50%', backgroundColor: user.color || '#3b82f6', marginRight: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 'bold', fontSize: '12px' } }, user.name ? user.name.charAt(0).toUpperCase() : '?' ), React.createElement( 'div', { className: 'realtimecursor-collaborator-name', style: { display: 'flex', alignItems: 'center' } }, [ user.name, isTyping && React.createElement( 'span', { className: 'realtimecursor-typing-indicator', style: { marginLeft: '5px', color: '#666' } }, '✎ typing...' ) ] ) ] ); }) ) : React.createElement( 'div', { className: 'realtimecursor-no-collaborators' }, 'No active collaborators' ) ); } /** * React component for a complete collaborative editor */ export function CollaborativeEditor({ apiUrl = 'http://localhost:3001', projectId = 'default-project', userId, userName, userColor, initialContent = '', onContentChange, height = '400px' }) { const React = require('react'); const { useState, useRef, useEffect } = React; const [content, setContent] = useState(initialContent); const [isTyping, setIsTyping] = useState(false); const editorRef = useRef(null); const typingTimeoutRef = useRef(null); // Generate random user info if not provided const user = { id: userId || `user-${Math.floor(Math.random() * 10000)}`, name: userName || `User ${Math.floor(Math.random() * 10000)}`, color: userColor || getRandomColor() }; const { cursors, collaborators, connected, typingStatus, connect, disconnect, updateCursor, updateContent: updateRemoteContent, updateTypingStatus } = useRealtimeCursor({ apiUrl, projectId, user }); // Connect on mount useEffect(() => { connect(); return () => disconnect(); }, [connect, disconnect]); // Update cursor position on mouse move const handleMouseMove = (e) => { if (!editorRef.current) return; const rect = editorRef.current.getBoundingClientRect(); updateCursor({ x: e.clientX, y: e.clientY, relativeX: e.clientX - rect.left, relativeY: e.clientY - rect.top }); }; // Update content when changed const handleContentChange = (e) => { const newContent = e.target.value; setContent(newContent); updateRemoteContent(newContent); if (onContentChange) { onContentChange(newContent); } // Update typing status if (!isTyping) { setIsTyping(true); updateTypingStatus(true); } // Clear previous timeout if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } // Set new timeout typingTimeoutRef.current = setTimeout(() => { setIsTyping(false); updateTypingStatus(false); }, 2000); }; // Generate random color function getRandomColor() { const colors = [ '#3b82f6', // blue '#ef4444', // red '#10b981', // green '#f59e0b', // yellow '#8b5cf6', // purple '#ec4899', // pink '#06b6d4', // cyan '#f97316', // orange ]; return colors[Math.floor(Math.random() * colors.length)]; } return React.createElement( 'div', { className: 'realtimecursor-editor', style: { display: 'flex', flexDirection: 'column', gap: '16px' } }, [ // Connection status React.createElement( 'div', { className: 'realtimecursor-connection-status', style: { display: 'flex', alignItems: 'center', gap: '8px' } }, [ React.createElement( 'div', { className: `realtimecursor-status-indicator ${connected ? 'connected' : 'disconnected'}`, style: { width: '10px', height: '10px', borderRadius: '50%', backgroundColor: connected ? '#10b981' : '#ef4444' } } ), React.createElement( 'div', { className: 'realtimecursor-status-text' }, connected ? 'Connected' : 'Disconnected' ) ] ), // Editor container React.createElement( 'div', { className: 'realtimecursor-editor-container', ref: editorRef, onMouseMove: handleMouseMove, style: { position: 'relative', height, border: '1px solid #ddd', borderRadius: '4px', overflow: 'hidden' } }, [ React.createElement( 'textarea', { className: 'realtimecursor-textarea', value: content, onChange: handleContentChange, placeholder: 'Start typing...', style: { width: '100%', height: '100%', padding: '16px', border: 'none', resize: 'none', outline: 'none', fontFamily: 'monospace', fontSize: '14px', lineHeight: '1.5' } } ), React.createElement(CursorOverlay, { cursors }) ] ), // Collaborators list React.createElement( 'div', { className: 'realtimecursor-collaborators-panel', style: { border: '1px solid #ddd', borderRadius: '4px', padding: '16px' } }, [ React.createElement( 'h3', { style: { margin: '0 0 16px 0', fontSize: '16px' } }, `Active Collaborators (${collaborators.length})` ), React.createElement(CollaboratorsList, { collaborators, typingStatus }) ] ) ] ); } export default { RealtimeCursor, useRealtimeCursor, CursorOverlay, CollaboratorsList, CollaborativeEditor };