realtimecursor
Version:
Real-time collaboration system with cursor tracking and approval workflow
382 lines (332 loc) • 10.1 kB
JSX
// Fixed version of the RealtimeCursor SDK client
import { io } from 'socket.io-client';
import { useState, useEffect, useRef, useCallback } from 'react';
/**
* RealtimeCursor class for vanilla JavaScript usage
*/
export 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;
}
connect() {
if (this.socket) {
this.disconnect();
}
// 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;
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) {
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);
}
updateCursorPosition(textPosition) {
if (!this.socket || !this.connected) return;
this.socket.emit('cursor-position', { textPosition });
}
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', () => {
console.log('Connected to RealtimeCursor server');
if (!this.projectJoined) {
this.joinProject();
}
});
this.socket.on('disconnect', () => {
console.log('Disconnected from RealtimeCursor server');
this.projectJoined = false;
});
this.socket.on('room-users', (users) => {
this.collaborators = users;
if (this.onCollaboratorsChange) this.onCollaboratorsChange(users);
});
this.socket.on('user-joined', ({ user }) => {
// 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 }) => {
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) => {
if (this.onContentUpdate) this.onContentUpdate(data);
});
this.socket.on('cursor-update', (data) => {
const { socketId, user, x, y, textPosition } = data;
this.cursors[socketId] = { x, y, user, textPosition };
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));
});
this.socket.on('cursor-position', (data) => {
const { socketId, textPosition, user } = data;
if (this.cursors[socketId]) {
this.cursors[socketId].textPosition = textPosition;
this.cursors[socketId].user = user;
} else {
this.cursors[socketId] = { textPosition, user };
}
if (this.onCursorPositionUpdate) this.onCursorPositionUpdate(data);
});
}
getRandomColor() {
const colors = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b',
'#8b5cf6', '#06b6d4', '#f97316', '#84cc16',
'#ec4899', '#6366f1', '#14b8a6', '#f43f5e'
];
return colors[Math.floor(Math.random() * colors.length)];
}
getCollaborators() {
return this.collaborators;
}
getCursors() {
return this.cursors;
}
getTypingUsers() {
return Array.from(this.typingUsers);
}
}
/**
* React hook for using RealtimeCursor
*/
export function useRealtimeCursor(options) {
const [cursors, setCursors] = useState({});
const [collaborators, setCollaborators] = useState([]);
const [typingUsers, setTypingUsers] = useState([]);
const [isConnected, setIsConnected] = useState(false);
// Use refs to prevent recreating the client on each render
const clientRef = useRef(null);
const optionsRef = useRef(options);
// Update options ref when options change
useEffect(() => {
optionsRef.current = options;
}, [options]);
// Initialize the client
useEffect(() => {
if (!clientRef.current) {
clientRef.current = new RealtimeCursor(optionsRef.current);
}
return () => {
if (clientRef.current) {
clientRef.current.disconnect();
}
};
}, []);
// Set up event handlers
useEffect(() => {
const client = clientRef.current;
if (!client) return;
client.onCursorUpdate = (data) => {
setCursors(prev => ({
...prev,
[data.socketId]: {
x: data.x,
y: data.y,
textPosition: data.textPosition,
user: data.user
}
}));
};
client.onCollaboratorsChange = (users) => {
setCollaborators(users);
};
client.onTypingStatusChange = (typingUserIds) => {
setTypingUsers(typingUserIds);
// Update collaborators with typing status
setCollaborators(prev =>
prev.map(user => ({
...user,
isTyping: typingUserIds.includes(user.socketId)
}))
);
};
client.onUserLeft = ({ socketId }) => {
setCursors(prev => {
const newCursors = { ...prev };
delete newCursors[socketId];
return newCursors;
});
};
}, []);
// Connect function
const connect = useCallback(() => {
if (clientRef.current) {
clientRef.current.connect();
setIsConnected(true);
}
}, []);
// Disconnect function
const disconnect = useCallback(() => {
if (clientRef.current) {
clientRef.current.disconnect();
setIsConnected(false);
setCursors({});
setCollaborators([]);
setTypingUsers([]);
}
}, []);
// Update cursor position
const updateCursor = useCallback((position) => {
if (clientRef.current) {
clientRef.current.updateCursor(position);
}
}, []);
// Update content
const updateContent = useCallback((content, cursorPosition) => {
if (clientRef.current) {
clientRef.current.updateContent(content, cursorPosition);
}
}, []);
// Set typing status
const setTyping = useCallback((isTyping) => {
if (clientRef.current) {
clientRef.current.setTyping(isTyping);
}
}, []);
// Auto-connect if specified in options
useEffect(() => {
if (optionsRef.current.autoConnect) {
connect();
}
}, [connect]);
return {
cursors,
collaborators,
typingUsers,
isConnected,
connect,
disconnect,
updateCursor,
updateContent,
setTyping
};
}
/**
* CursorOverlay component for React
*/
export function CursorOverlay({ cursors, containerRef, renderCursor }) {
return (
<div className="realtimecursor-overlay" style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, pointerEvents: 'none' }}>
{Object.entries(cursors).map(([socketId, cursor]) => {
if (!cursor || !cursor.user) return null;
if (renderCursor) {
return renderCursor({ socketId, ...cursor });
}
return (
<div
key={socketId}
className="realtimecursor-cursor"
style={{
position: 'fixed',
left: `${cursor.x}px`,
top: `${cursor.y}px`,
transform: 'translate(-50%, -50%)',
zIndex: 9999,
pointerEvents: 'none'
}}
>
<div
className="realtimecursor-pointer"
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: cursor.user.color || '#3b82f6',
boxShadow: '0 0 5px rgba(0,0,0,0.3)'
}}
/>
<div
className="realtimecursor-name"
style={{
padding: '2px 6px',
borderRadius: '4px',
backgroundColor: cursor.user.color || '#3b82f6',
color: '#fff',
fontSize: '12px',
marginTop: '4px',
whiteSpace: 'nowrap'
}}
>
{cursor.user.name}
</div>
</div>
);
})}
</div>
);
}
export default {
RealtimeCursor,
useRealtimeCursor,
CursorOverlay
};