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