realtimecursor
Version:
Real-time collaboration system with cursor tracking and approval workflow
444 lines (380 loc) • 14.1 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RealtimeCursor Test</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.editor-container {
position: relative;
margin-top: 20px;
}
textarea {
width: 100%;
height: 300px;
padding: 16px;
font-family: monospace;
font-size: 16px;
line-height: 1.5;
border: 1px solid #ccc;
border-radius: 8px;
}
.collaborators {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.collaborator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: #f0f9ff;
border-radius: 20px;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.debug {
margin-top: 20px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 8px;
font-family: monospace;
white-space: pre-wrap;
max-height: 200px;
overflow: auto;
}
</style>
</head>
<body>
<h1>RealtimeCursor Test</h1>
<p>This page tests the fixed version of the RealtimeCursor SDK.</p>
<div class="controls">
<button id="connect">Connect</button>
<button id="disconnect">Disconnect</button>
<span id="status">Disconnected</span>
</div>
<div class="collaborators" id="collaborators"></div>
<div class="editor-container">
<textarea id="editor" placeholder="Start typing to collaborate...">Welcome to RealtimeCursor SDK!
This is a test of the fixed version of the SDK.
Try opening this page in multiple browser windows to see the cursors of other users.
Features:
- Real-time cursor tracking
- Collaborative editing
- User presence indicators
- Typing indicators</textarea>
<div id="cursors-overlay"></div>
</div>
<div class="debug" id="debug">Debug info will appear here...</div>
<!-- Include socket.io -->
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script>
// Debug logger
const debugEl = document.getElementById('debug');
function log(message) {
const timestamp = new Date().toISOString().substring(11, 19);
debugEl.textContent = `[${timestamp}] ${message}\n` + debugEl.textContent;
}
// RealtimeCursor implementation (fixed version)
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;
log(`RealtimeCursor initialized with projectId: ${this.options.projectId}`);
}
connect() {
if (this.socket) {
this.disconnect();
}
log(`Connecting to ${this.options.socketUrl}...`);
// 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;
log(`Joining project: ${this.options.projectId}`);
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) {
log('Disconnecting...');
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);
}
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', () => {
log('Connected to server');
if (!this.projectJoined) {
this.joinProject();
}
});
this.socket.on('disconnect', () => {
log('Disconnected from server');
this.projectJoined = false;
});
this.socket.on('room-users', (users) => {
log(`Received room-users event with ${users.length} users`);
this.collaborators = users;
if (this.onCollaboratorsChange) this.onCollaboratorsChange(users);
});
this.socket.on('user-joined', ({ user }) => {
log(`User joined: ${user.name}`);
// 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 }) => {
log(`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) => {
log(`Content update from: ${data.user.name}`);
if (this.onContentUpdate) this.onContentUpdate(data);
});
this.socket.on('cursor-update', (data) => {
const { socketId, user } = data;
this.cursors[socketId] = data;
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));
});
}
getRandomColor() {
const colors = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b',
'#8b5cf6', '#06b6d4', '#f97316', '#84cc16',
'#ec4899', '#6366f1', '#14b8a6', '#f43f5e'
];
return colors[Math.floor(Math.random() * colors.length)];
}
}
// Generate a random user ID and name
const userId = 'user_' + Math.random().toString(36).substring(2, 10);
const userName = 'User ' + Math.floor(Math.random() * 1000);
// Create a color for the user
const colors = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b',
'#8b5cf6', '#06b6d4', '#f97316', '#84cc16',
'#ec4899', '#6366f1', '#14b8a6', '#f43f5e'
];
const userColor = colors[Math.floor(Math.random() * colors.length)];
// Initialize the cursor client
const cursorClient = new RealtimeCursor({
apiUrl: 'http://localhost:3001',
socketUrl: 'http://localhost:3001',
projectId: 'test-project',
user: {
id: userId,
name: userName,
color: userColor
}
});
// DOM elements
const editor = document.getElementById('editor');
const connectButton = document.getElementById('connect');
const disconnectButton = document.getElementById('disconnect');
const statusElement = document.getElementById('status');
const collaboratorsElement = document.getElementById('collaborators');
const cursorsOverlay = document.getElementById('cursors-overlay');
// Connect button
connectButton.addEventListener('click', () => {
cursorClient.connect();
statusElement.textContent = 'Connected';
statusElement.style.color = '#10b981';
});
// Disconnect button
disconnectButton.addEventListener('click', () => {
cursorClient.disconnect();
statusElement.textContent = 'Disconnected';
statusElement.style.color = '#ef4444';
collaboratorsElement.innerHTML = '';
cursorsOverlay.innerHTML = '';
});
// Update cursor position on mouse move
editor.addEventListener('mousemove', (e) => {
const rect = editor.getBoundingClientRect();
const relativeX = e.clientX - rect.left;
const relativeY = e.clientY - rect.top;
cursorClient.updateCursor({
x: e.clientX,
y: e.clientY
});
});
// Update content on change
editor.addEventListener('input', (e) => {
cursorClient.updateContent(e.target.value);
});
// Set typing indicator
let typingTimeout;
editor.addEventListener('keydown', () => {
cursorClient.setTyping(true);
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
cursorClient.setTyping(false);
}, 1000);
});
// Event handlers
cursorClient.onCollaboratorsChange = (collaborators) => {
updateCollaboratorsList(collaborators);
};
cursorClient.onCursorUpdate = (data) => {
updateCursor(data);
};
cursorClient.onContentUpdate = (data) => {
if (data.user.id !== userId) {
editor.value = data.content;
}
};
cursorClient.onTypingStatusChange = (typingUserIds) => {
updateTypingIndicators(typingUserIds);
};
// Helper functions
function updateCollaboratorsList(collaborators) {
collaboratorsElement.innerHTML = '';
collaborators.forEach(collab => {
if (collab.id === userId) return; // Skip current user
const collaboratorElement = document.createElement('div');
collaboratorElement.className = 'collaborator';
collaboratorElement.dataset.socketId = collab.socketId;
const avatarElement = document.createElement('div');
avatarElement.className = 'avatar';
avatarElement.style.backgroundColor = collab.color || '#3b82f6';
avatarElement.textContent = collab.name.charAt(0);
const nameElement = document.createElement('span');
nameElement.textContent = collab.name;
const typingIndicator = document.createElement('div');
typingIndicator.className = 'typing-indicator';
typingIndicator.style.display = 'none';
typingIndicator.innerHTML = '...';
collaboratorElement.appendChild(avatarElement);
collaboratorElement.appendChild(nameElement);
collaboratorElement.appendChild(typingIndicator);
collaboratorsElement.appendChild(collaboratorElement);
});
}
function updateCursor(data) {
const { socketId, user, x, y } = data;
// Skip current user
if (user.id === userId) return;
// Find or create cursor element
let cursorElement = document.getElementById(`cursor-${socketId}`);
if (!cursorElement) {
cursorElement = document.createElement('div');
cursorElement.id = `cursor-${socketId}`;
cursorElement.style.position = 'fixed';
cursorElement.style.zIndex = '9999';
cursorElement.style.pointerEvents = 'none';
cursorElement.innerHTML = `
<div style="width: 12px; height: 12px; border-radius: 50%; background-color: ${user.color}; box-shadow: 0 0 5px rgba(0,0,0,0.3);"></div>
<div style="padding: 2px 6px; border-radius: 4px; background-color: ${user.color}; color: #fff; font-size: 12px; margin-top: 4px; white-space: nowrap;">${user.name}</div>
`;
document.body.appendChild(cursorElement);
}
// Update cursor position
if (x && y) {
cursorElement.style.left = `${x}px`;
cursorElement.style.top = `${y}px`;
cursorElement.style.transform = 'translate(-50%, -50%)';
}
}
function updateTypingIndicators(typingUserIds) {
// Reset all typing indicators
document.querySelectorAll('.collaborator .typing-indicator').forEach(el => {
el.style.display = 'none';
});
// Show typing indicators for users who are typing
typingUserIds.forEach(socketId => {
const collaborator = document.querySelector(`.collaborator[data-socket-id="${socketId}"]`);
if (collaborator) {
const typingIndicator = collaborator.querySelector('.typing-indicator');
if (typingIndicator) {
typingIndicator.style.display = 'inline';
}
}
});
}
// Auto-connect on page load
connectButton.click();
</script>
</body>
</html>