UNPKG

realtimecursor

Version:

Real-time collaboration system with cursor tracking and approval workflow

237 lines (205 loc) 6 kB
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;