realtimecursor
Version:
Real-time collaboration system with cursor tracking and approval workflow
890 lines (780 loc) • 22.8 kB
JavaScript
/**
* 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
};