realtimecursor
Version:
Real-time collaboration system with cursor tracking and approval workflow
237 lines (205 loc) • 6 kB
text/typescript
import { io, Socket } from 'socket.io-client';
export interface CursorPosition {
x: number;
y: number;
textPosition?: number;
}
export interface UserInfo {
id: string;
name: string;
color?: string;
}
export interface CollaboratorInfo {
id: string;
name: string;
color: string;
socketId: string;
}
export interface RealtimeCursorOptions {
apiUrl: string;
socketUrl?: string;
projectId: string;
user: UserInfo;
token?: string;
}
export interface CursorUpdateData {
socketId: string;
user: UserInfo;
x: number;
y: number;
textPosition?: number;
}
export interface ContentUpdateData {
content: string;
user: UserInfo;
cursorPosition?: number;
}
export interface CursorPositionUpdateData {
socketId: string;
textPosition: number;
user: UserInfo;
}
export class RealtimeCursor {
private socket: Socket | null = null;
public options: RealtimeCursorOptions;
private collaborators: CollaboratorInfo[] = [];
private cursors: Record<string, any> = {};
private typingUsers: Set<string> = new Set();
constructor(options: RealtimeCursorOptions) {
this.options = {
...options,
socketUrl: options.socketUrl || options.apiUrl
};
}
/**
* Connect to the real-time cursor service
*/
connect(): void {
if (this.socket) {
this.disconnect();
}
// Connect to socket server
this.socket = io(this.options.socketUrl as string, {
auth: {
token: this.options.token
}
});
// Join project room
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()
}
});
// Set up event listeners
this.setupEventListeners();
}
/**
* Disconnect from the real-time cursor service
*/
disconnect(): void {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
this.collaborators = [];
this.cursors = {};
this.typingUsers.clear();
}
}
/**
* Update cursor position
*/
updateCursor(position: CursorPosition): void {
if (!this.socket) throw new Error('Socket not connected');
this.socket.emit('cursor-move', position);
}
/**
* Update cursor position in text
*/
updateCursorPosition(textPosition: number): void {
if (!this.socket) throw new Error('Socket not connected');
this.socket.emit('cursor-position', { textPosition });
}
/**
* Update content
*/
updateContent(content: string, cursorPosition?: number): void {
if (!this.socket) throw new Error('Socket not connected');
this.socket.emit('content-change', { content, cursorPosition });
}
/**
* Set typing indicator
*/
setTyping(isTyping: boolean): void {
if (!this.socket) throw new Error('Socket not connected');
this.socket.emit('user-typing', { isTyping });
}
/**
* Register event handlers
*/
private setupEventListeners(): void {
if (!this.socket) return;
this.socket.on('room-users', (users: CollaboratorInfo[]) => {
this.collaborators = users;
this.onCollaboratorsChange?.(users);
});
this.socket.on('user-joined', ({ user }: { user: CollaboratorInfo }) => {
this.collaborators.push(user);
this.onCollaboratorsChange?.(this.collaborators);
this.onUserJoined?.(user);
});
this.socket.on('user-left', ({ socketId }: { socketId: string }) => {
this.collaborators = this.collaborators.filter(u => u.socketId !== socketId);
delete this.cursors[socketId];
this.typingUsers.delete(socketId);
this.onCollaboratorsChange?.(this.collaborators);
this.onUserLeft?.({ socketId });
});
this.socket.on('content-update', (data: ContentUpdateData) => {
this.onContentUpdate?.(data);
});
this.socket.on('cursor-update', (data: CursorUpdateData) => {
const { socketId, user, x, y, textPosition } = data;
this.cursors[socketId] = { x, y, user, textPosition };
this.onCursorUpdate?.(data);
});
this.socket.on('user-typing', ({ socketId, isTyping }: { socketId: string; isTyping: boolean }) => {
if (isTyping) {
this.typingUsers.add(socketId);
} else {
this.typingUsers.delete(socketId);
}
this.onTypingStatusChange?.(Array.from(this.typingUsers));
});
this.socket.on('cursor-position', (data: CursorPositionUpdateData) => {
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 };
}
this.onCursorPositionUpdate?.(data);
});
}
/**
* Get random color for cursor
*/
private getRandomColor(): string {
const colors = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b',
'#8b5cf6', '#06b6d4', '#f97316', '#84cc16',
'#ec4899', '#6366f1', '#14b8a6', '#f43f5e'
];
return colors[Math.floor(Math.random() * colors.length)];
}
/**
* Get current collaborators
*/
getCollaborators(): CollaboratorInfo[] {
return this.collaborators;
}
/**
* Get current cursors
*/
getCursors(): Record<string, any> {
return this.cursors;
}
/**
* Get typing users
*/
getTypingUsers(): string[] {
return Array.from(this.typingUsers);
}
// Event handlers that can be overridden by consumers
onCollaboratorsChange?: (collaborators: CollaboratorInfo[]) => void;
onUserJoined?: (user: CollaboratorInfo) => void;
onUserLeft?: (data: { socketId: string }) => void;
onContentUpdate?: (data: ContentUpdateData) => void;
onCursorUpdate?: (data: CursorUpdateData) => void;
onCursorPositionUpdate?: (data: CursorPositionUpdateData) => void;
onTypingStatusChange?: (typingUserIds: string[]) => void;
}
export default RealtimeCursor;