UNPKG

realtimecursor

Version:

Real-time collaboration system with cursor tracking and approval workflow

794 lines (659 loc) 22.4 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>RealtimeCursor Demo</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background-color: #f5f5f5; } .header { text-align: center; margin-bottom: 30px; } .header h1 { margin-bottom: 10px; color: #333; } .header p { color: #666; margin-bottom: 20px; } .demo-container { display: flex; flex-direction: column; gap: 20px; } .user-info { background-color: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .user-form { display: flex; gap: 10px; margin-bottom: 10px; } .user-form input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; flex: 1; } .user-form button { padding: 8px 16px; background-color: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer; } .user-form button:hover { background-color: #2563eb; } .editor-container { position: relative; height: 300px; background-color: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } #editor { width: 100%; height: 100%; padding: 20px; box-sizing: border-box; border: none; resize: none; outline: none; font-family: monospace; font-size: 14px; line-height: 1.5; } #cursors-container { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; } .connection-status { display: flex; align-items: center; margin-bottom: 10px; } .status-indicator { width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; } .connected { background-color: #10b981; } .disconnected { background-color: #ef4444; } .collaborators-panel { background-color: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .collaborators-panel h3 { margin-top: 0; margin-bottom: 15px; color: #333; } .footer { margin-top: 40px; text-align: center; color: #666; } .footer a { color: #3b82f6; text-decoration: none; } .footer a:hover { text-decoration: underline; } .instructions { background-color: #f0f9ff; border-left: 4px solid #3b82f6; padding: 15px; margin-bottom: 20px; border-radius: 4px; } .instructions h3 { margin-top: 0; color: #333; } .instructions ul { margin-bottom: 0; padding-left: 20px; } .instructions li { margin-bottom: 5px; } /* Cursor styles */ .realtimecursor-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 9999; } .realtimecursor-cursor { position: absolute; pointer-events: none; z-index: 9999; transition: transform 0.1s ease-out, left 0.1s ease-out, top 0.1s ease-out; } .realtimecursor-label { position: absolute; left: 16px; top: 8px; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: bold; white-space: nowrap; box-shadow: 0 1px 2px rgba(0,0,0,0.25); color: white; transition: opacity 0.3s ease; } .realtimecursor-typing-indicator { display: inline-block; animation: blink 1s infinite; } @keyframes blink { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } } .realtimecursor-collaborator { display: flex; align-items: center; margin-bottom: 8px; padding: 4px 8px; border-radius: 4px; } .realtimecursor-collaborator-avatar { width: 24px; height: 24px; border-radius: 50%; margin-right: 8px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 12px; } .realtimecursor-no-collaborators { color: #666; font-style: italic; } </style> </head> <body> <div class="header"> <h1>RealtimeCursor Demo</h1> <p>Experience real-time collaboration with cursor tracking and typing indicators</p> </div> <div class="instructions"> <h3>How to Test</h3> <ul> <li>Open this page in multiple browser windows</li> <li>Enter different user names in each window</li> <li>See cursors and typing indicators in real-time</li> <li>Edit the content and watch it sync across windows</li> </ul> </div> <div class="demo-container"> <div class="user-info"> <h3>Your Information</h3> <form id="user-form" class="user-form"> <input type="text" id="user-name" placeholder="Your Name" value="User"> <input type="color" id="user-color" value="#3b82f6"> <button type="submit">Update</button> </form> <div class="connection-status"> <div id="status-indicator" class="status-indicator disconnected"></div> <div id="status-text">Disconnected</div> </div> </div> <div class="editor-container"> <textarea id="editor" placeholder="Start typing...">Welcome to RealtimeCursor Demo! This is a collaborative editor with real-time cursor tracking and typing indicators. Try opening this page in multiple browser windows to see it in action. Features: - Real-time cursor tracking - Typing indicators - Content synchronization - Collaborator presence Start editing and see the magic happen!</textarea> <div id="cursors-container"></div> </div> <div class="collaborators-panel"> <h3>Active Collaborators (<span id="collaborator-count">0</span>)</h3> <div id="collaborators-container"></div> </div> </div> <div class="footer"> <p>RealtimeCursor Demo v1.2.0</p> </div> <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script> <script> // Generate a random user ID const userId = `user-${Math.floor(Math.random() * 10000)}`; // Get user name from form or generate a random one let userName = document.getElementById('user-name').value || `User ${Math.floor(Math.random() * 10000)}`; // Get user color from form or generate a random one let userColor = document.getElementById('user-color').value || getRandomColor(); // Socket.io connection let socket = io('http://localhost:3001'); // State let connected = false; let cursors = {}; let collaborators = []; let typingStatus = {}; let hasJoinedProject = false; let projectId = 'demo-project'; // Connect to the server socket.on('connect', () => { console.log('Connected to server'); connected = true; document.getElementById('status-indicator').classList.remove('disconnected'); document.getElementById('status-indicator').classList.add('connected'); document.getElementById('status-text').textContent = 'Connected'; // Join the project if (!hasJoinedProject) { socket.emit('join-project', { projectId: projectId, user: { id: userId, name: userName, color: userColor } }); hasJoinedProject = true; } }); socket.on('disconnect', () => { console.log('Disconnected from server'); connected = false; document.getElementById('status-indicator').classList.remove('connected'); document.getElementById('status-indicator').classList.add('disconnected'); document.getElementById('status-text').textContent = 'Disconnected'; }); // Handle room users socket.on('room-users', ({ users }) => { console.log('Room users:', users); // Filter out our own user and prevent duplicates const uniqueUsers = {}; users.forEach(user => { if (user.id !== userId) { uniqueUsers[user.id] = user; } }); collaborators = Object.values(uniqueUsers); renderCollaborators(collaborators); }); // Handle user joined socket.on('user-joined', ({ user }) => { console.log('User joined:', user); if (user.id === userId) { return; } // Check if user already exists to prevent duplicates const existingUserIndex = collaborators.findIndex(u => u.id === user.id); if (existingUserIndex === -1) { collaborators.push(user); renderCollaborators(collaborators); } }); // Handle user left socket.on('user-left', ({ userId: leftUserId, socketId }) => { console.log('User left:', leftUserId || socketId); const id = leftUserId || socketId; // Remove user from collaborators const index = collaborators.findIndex(user => user.id === id || user.socketId === socketId ); if (index !== -1) { collaborators.splice(index, 1); renderCollaborators(collaborators); } // Remove cursor if (cursors[id]) { delete cursors[id]; renderCursors(cursors); } // Remove typing status if (typingStatus[id]) { delete typingStatus[id]; updateTypingIndicators(typingStatus); } }); // Handle cursor update socket.on('cursor-update', (data) => { const { userId: cursorUserId, socketId, user, position, x, y, relativeX, relativeY, textPosition, timestamp } = data; const id = cursorUserId || (user && user.id) || socketId; if (!id || id === userId) { return; } cursors[id] = { id, position: position || { x, y, relativeX, relativeY, textPosition }, user: user || { id, name: 'Unknown' }, timestamp: timestamp || Date.now() }; renderCursors(cursors); }); // Handle content update socket.on('content-update', (data) => { if (data.userId === userId || data.socketId === socket.id) { return; } document.getElementById('editor').value = data.content; }); // Handle typing status socket.on('user-typing', (data) => { const { socketId, userId: typingUserId, isTyping, user } = data; const id = typingUserId || (user && user.id) || socketId; if (!id || id === userId) { return; } typingStatus[id] = { id, isTyping, user: user || { id, name: 'Unknown' }, timestamp: Date.now() }; updateTypingIndicators(typingStatus); }); // Update cursor position on mouse move document.querySelector('.editor-container').addEventListener('mousemove', (e) => { if (!connected) return; const rect = e.currentTarget.getBoundingClientRect(); const position = { x: e.clientX, y: e.clientY, relativeX: e.clientX - rect.left, relativeY: e.clientY - rect.top }; socket.emit('cursor-position', { projectId: projectId, position }); socket.emit('cursor-move', { x: position.x, y: position.y, relativeX: position.relativeX, relativeY: position.relativeY }); }); // Update content and typing status when changed let typingTimeout; document.getElementById('editor').addEventListener('input', (e) => { if (!connected) return; const content = e.target.value; socket.emit('content-update', { projectId: projectId, content, version: Date.now() }); socket.emit('content-change', { content }); socket.emit('user-typing', { isTyping: true }); // Reset typing status after 2 seconds clearTimeout(typingTimeout); typingTimeout = setTimeout(() => { socket.emit('user-typing', { isTyping: false }); }, 2000); }); // Update user info when form is submitted document.getElementById('user-form').addEventListener('submit', (e) => { e.preventDefault(); // Get new user info userName = document.getElementById('user-name').value || userName; userColor = document.getElementById('user-color').value || userColor; // Disconnect old socket socket.disconnect(); // Create new socket socket = io('http://localhost:3001'); // Reset state connected = false; hasJoinedProject = false; // Set up event handlers again socket.on('connect', () => { console.log('Connected to server'); connected = true; document.getElementById('status-indicator').classList.remove('disconnected'); document.getElementById('status-indicator').classList.add('connected'); document.getElementById('status-text').textContent = 'Connected'; // Join the project if (!hasJoinedProject) { socket.emit('join-project', { projectId: projectId, user: { id: userId, name: userName, color: userColor } }); hasJoinedProject = true; } }); socket.on('disconnect', () => { console.log('Disconnected from server'); connected = false; document.getElementById('status-indicator').classList.remove('connected'); document.getElementById('status-indicator').classList.add('disconnected'); document.getElementById('status-text').textContent = 'Disconnected'; }); socket.on('room-users', ({ users }) => { console.log('Room users:', users); // Filter out our own user and prevent duplicates const uniqueUsers = {}; users.forEach(user => { if (user.id !== userId) { uniqueUsers[user.id] = user; } }); collaborators = Object.values(uniqueUsers); renderCollaborators(collaborators); }); socket.on('user-joined', ({ user }) => { console.log('User joined:', user); if (user.id === userId) { return; } // Check if user already exists to prevent duplicates const existingUserIndex = collaborators.findIndex(u => u.id === user.id); if (existingUserIndex === -1) { collaborators.push(user); renderCollaborators(collaborators); } }); socket.on('user-left', ({ userId: leftUserId, socketId }) => { console.log('User left:', leftUserId || socketId); const id = leftUserId || socketId; // Remove user from collaborators const index = collaborators.findIndex(user => user.id === id || user.socketId === socketId ); if (index !== -1) { collaborators.splice(index, 1); renderCollaborators(collaborators); } // Remove cursor if (cursors[id]) { delete cursors[id]; renderCursors(cursors); } }); socket.on('cursor-update', (data) => { const { userId: cursorUserId, socketId, user, position, x, y, relativeX, relativeY, textPosition, timestamp } = data; const id = cursorUserId || (user && user.id) || socketId; if (!id || id === userId) { return; } cursors[id] = { id, position: position || { x, y, relativeX, relativeY, textPosition }, user: user || { id, name: 'Unknown' }, timestamp: timestamp || Date.now() }; renderCursors(cursors); }); socket.on('content-update', (data) => { if (data.userId === userId || data.socketId === socket.id) { return; } document.getElementById('editor').value = data.content; }); socket.on('user-typing', (data) => { const { socketId, userId: typingUserId, isTyping, user } = data; const id = typingUserId || (user && user.id) || socketId; if (!id || id === userId) { return; } typingStatus[id] = { id, isTyping, user: user || { id, name: 'Unknown' }, timestamp: Date.now() }; updateTypingIndicators(typingStatus); }); }); // Disconnect when the page is closed window.addEventListener('beforeunload', () => { socket.disconnect(); }); // Helper functions function renderCursors(cursors) { const container = document.getElementById('cursors-container'); container.innerHTML = ''; 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'; // 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; cursorElement.appendChild(label); container.appendChild(cursorElement); }); } function renderCollaborators(collaborators) { const container = document.getElementById('collaborators-container'); const count = document.getElementById('collaborator-count'); count.textContent = collaborators.length; container.innerHTML = ''; if (collaborators.length === 0) { container.innerHTML = '<div class="realtimecursor-no-collaborators">No active collaborators</div>'; return; } collaborators.forEach(user => { const userElement = document.createElement('div'); userElement.className = 'realtimecursor-collaborator'; userElement.innerHTML = ` <div class="realtimecursor-collaborator-avatar" style="background-color: ${user.color || '#3b82f6'}"> ${user.name ? user.name.charAt(0).toUpperCase() : '?'} </div> <div class="realtimecursor-collaborator-name" data-user-id="${user.id}"> ${user.name} </div> `; container.appendChild(userElement); }); } function updateTypingIndicators(typingStatus) { Object.entries(typingStatus).forEach(([userId, status]) => { if (!status.isTyping) return; const nameElement = document.querySelector(`.realtimecursor-collaborator-name[data-user-id="${userId}"]`); if (!nameElement) return; // Check if typing indicator already exists let typingIndicator = nameElement.querySelector('.typing-indicator'); if (!typingIndicator) { typingIndicator = document.createElement('span'); typingIndicator.className = 'typing-indicator'; typingIndicator.style.marginLeft = '5px'; typingIndicator.style.color = '#666'; typingIndicator.style.animation = 'blink 1s infinite'; typingIndicator.textContent = '✎ typing...'; nameElement.appendChild(typingIndicator); // Remove typing indicator after 3 seconds if not updated setTimeout(() => { if (typingIndicator && typingIndicator.parentNode) { typingIndicator.parentNode.removeChild(typingIndicator); } }, 3000); } }); } 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)]; } </script> </body> </html>