UNPKG

realtimecursor

Version:

Real-time collaboration system with cursor tracking and approval workflow

451 lines (399 loc) 11.7 kB
/** * RealtimeCursor SDK - Fixed Version (v1.1.0) * * This SDK provides real-time cursor tracking and collaboration features. * This version fixes the infinite loop issue and other problems. */ import io from 'socket.io-client'; import React, { useState, useEffect, useRef, useCallback } from 'react'; /** * RealtimeCursor class for vanilla JavaScript usage */ export class RealtimeCursor { constructor(options) { this.options = { apiUrl: 'http://localhost:3001', projectId: 'default', user: { id: 'anonymous', name: 'Anonymous', color: this.getRandomColor() }, ...options }; this.socket = null; this.cursors = {}; this.collaborators = []; this.connected = false; this.hasJoinedProject = false; // Track if we've joined the project } /** * Connect to the real-time service */ connect() { if (this.socket) { return; } this.socket = io(this.options.apiUrl, { query: { projectId: this.options.projectId, userId: this.options.user.id }, transports: ['websocket', 'polling'] }); this.socket.on('connect', () => { console.log('Connected to RealtimeCursor service'); this.connected = true; // Only join the project once if (!this.hasJoinedProject) { this.socket.emit('join-project', { projectId: this.options.projectId, user: this.options.user }); this.hasJoinedProject = true; } if (this.onConnect) { this.onConnect(); } }); this.socket.on('disconnect', () => { console.log('Disconnected from RealtimeCursor service'); this.connected = false; if (this.onDisconnect) { this.onDisconnect(); } }); this.socket.on('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); if (this.onCollaboratorsChange) { this.onCollaboratorsChange(this.collaborators); } }); this.socket.on('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); if (this.onCollaboratorsChange) { this.onCollaboratorsChange([...this.collaborators]); } if (this.onUserJoined) { this.onUserJoined(user); } } }); this.socket.on('user-left', ({ socketId, userId }) => { 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); if (this.onCollaboratorsChange) { this.onCollaboratorsChange([...this.collaborators]); } if (this.onUserLeft) { this.onUserLeft(user); } } // Remove cursor if (this.cursors[id]) { delete this.cursors[id]; if (this.onCursorsChange) { this.onCursorsChange({...this.cursors}); } } }); this.socket.on('cursor-update', (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() }; if (this.onCursorsChange) { this.onCursorsChange({...this.cursors}); } }); this.socket.on('content-update', (data) => { if (data.userId === this.options.user.id || data.socketId === this.socket.id) { return; } if (this.onContentUpdate) { this.onContentUpdate(data); } }); this.socket.on('error', (error) => { console.error('RealtimeCursor error:', error); if (this.onError) { this.onError(error); } }); } /** * Disconnect from the real-time service */ disconnect() { if (this.socket) { this.socket.disconnect(); this.socket = null; this.connected = false; this.hasJoinedProject = false; } } /** * Update cursor position */ updateCursor(position) { if (!this.socket || !this.connected) { return; } this.socket.emit('cursor-position', { projectId: this.options.projectId, position }); } /** * Update content */ updateContent(content, version) { if (!this.socket || !this.connected) { return; } this.socket.emit('content-update', { projectId: this.options.projectId, content, version }); } /** * 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 [cursors, setCursors] = useState({}); const [collaborators, setCollaborators] = useState([]); const [connected, setConnected] = useState(false); const cursorClientRef = useRef(null); useEffect(() => { // Initialize the cursor client const cursorClient = new RealtimeCursor(options); cursorClientRef.current = cursorClient; // Set up event handlers cursorClient.onConnect = () => setConnected(true); cursorClient.onDisconnect = () => setConnected(false); cursorClient.onCursorsChange = setCursors; cursorClient.onCollaboratorsChange = setCollaborators; return () => { cursorClient.disconnect(); }; }, [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, version) => { if (cursorClientRef.current) { cursorClientRef.current.updateContent(content, version); } }, []); return { cursors, collaborators, connected, connect, disconnect, updateCursor, updateContent }; } /** * CursorOverlay component for displaying cursors */ export const CursorOverlay = React.memo(function CursorOverlay({ cursors }) { return React.createElement( 'div', { className: 'realtimecursor-overlay', style: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, pointerEvents: 'none' } }, Object.values(cursors).map((cursor) => React.createElement( 'div', { key: cursor.id, className: 'realtimecursor-cursor', style: { position: 'absolute', left: cursor.position.x || cursor.position.relativeX || 0, top: cursor.position.y || cursor.position.relativeY || 0, pointerEvents: 'none', zIndex: 9999, transition: 'transform 0.1s ease-out' } }, [ React.createElement( 'svg', { width: '24', height: '24', viewBox: '0 0 24 24', fill: 'none', style: { transform: 'rotate(-45deg)', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))' } }, React.createElement( 'path', { d: 'M1 1L11 11V19L7 21V13L1 1Z', fill: cursor.user.color || '#3b82f6', stroke: 'white', strokeWidth: '1' } ) ), React.createElement( 'div', { className: 'realtimecursor-label', style: { position: 'absolute', left: '16px', top: '8px', backgroundColor: cursor.user.color || '#3b82f6', color: 'white', padding: '2px 6px', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold', whiteSpace: 'nowrap', boxShadow: '0 1px 2px rgba(0,0,0,0.25)' } }, cursor.user.name ) ] ) ) ); }); /** * CollaboratorsList component for displaying active collaborators */ export const CollaboratorsList = React.memo(function CollaboratorsList({ collaborators }) { return React.createElement( 'div', { className: 'realtimecursor-collaborators' }, collaborators.length > 0 ? React.createElement( 'div', { className: 'realtimecursor-collaborators-list' }, collaborators.map((user) => 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' }, user.name ) ] ) ) ) : React.createElement( 'div', { className: 'realtimecursor-no-collaborators' }, 'No active collaborators' ) ); }); export default { RealtimeCursor, useRealtimeCursor, CursorOverlay, CollaboratorsList };