UNPKG

sourabhrealtime

Version:

ROBUST RICH TEXT EDITOR: Single-pane contentEditable with direct text selection formatting, speech features, undo/redo, professional UI - Perfect TipTap alternative

710 lines (595 loc) 19.4 kB
import { io } from 'socket.io-client'; import { EventEmitter } from 'events'; // User roles export const UserRole = { ADMIN: 'admin', EDITOR: 'editor', VIEWER: 'viewer' }; // Permission types export const Permission = { EDIT: 'edit', COMMENT: 'comment', VIEW: 'view' }; export class RealtimeCursor extends EventEmitter { constructor(options) { super(); this.socket = null; this.options = { apiUrl: 'http://localhost:3002', debug: false, ...options }; this.cursors = {}; this.collaborators = []; this.connected = false; this.hasJoinedProject = false; this.typingStatus = {}; this._lastContent = null; this._lastVersion = null; this._invitations = []; this._projectSettings = {}; this._userRole = options.user?.role || UserRole.EDITOR; this._permissions = new Set([Permission.EDIT, Permission.COMMENT, Permission.VIEW]); } connect() { if (this.socket) { return; } this.socket = io(this.options.apiUrl, { query: { projectId: this.options.projectId, userId: this.options.user.id, role: this._userRole }, transports: ['websocket', 'polling'], reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 5000, timeout: 20000, withCredentials: false, forceNew: true, autoConnect: true, reconnection: true }); this.socket.on('connect', () => { this.log('Connected to RealtimeCursor service'); this.connected = true; if (!this.hasJoinedProject) { this.socket.emit('join-project', { projectId: this.options.projectId, user: this.options.user }); this.hasJoinedProject = true; } this.emit('connect'); }); this.socket.on('disconnect', () => { this.log('Disconnected from RealtimeCursor service'); this.connected = false; this.emit('disconnect'); }); this.socket.on('room-users', ({ users }) => { 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); }); this.socket.on('user-joined', ({ user }) => { if (user.id === this.options.user.id) { return; } 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); } }); this.socket.on('user-left', ({ userId, socketId }) => { const id = userId || socketId; 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); } if (this.cursors[id]) { delete this.cursors[id]; this.emit('cursors-changed', this.cursors); } if (this.typingStatus[id]) { delete this.typingStatus[id]; this.emit('typing-status-changed', this.typingStatus); } }); 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() }; this.emit('cursors-changed', this.cursors); }); this.socket.on('content-update', (data) => { // Skip if this is our own update based on version if (data.version === this._lastVersion) { return; } // Skip if this is our own update based on user ID if ((data.userId && data.userId === this.options.user.id) || (data.user && data.user.id === this.options.user.id) || data.socketId === this.socket.id) { return; } // Log content update if debug is enabled this.log('Received content update from:', data.userId || (data.user && data.user.id) || data.socketId); // Store the latest content if (data.content) { this._lastContent = data.content; } // Emit content update event with all data this.emit('content-updated', data); }); this.socket.on('content-change', (data) => { // Skip if this is our own update based on user ID if ((data.userId && data.userId === this.options.user.id) || (data.user && data.user.id === this.options.user.id) || data.socketId === this.socket.id) { return; } // Log content change if debug is enabled this.log('Received content change from:', (data.user && data.user.id) || data.userId || data.socketId); // Emit content updated event this.emit('content-updated', data); }); this.socket.on('content-state', (data) => { if (!data || !data.content) return; this.log('Received initial content state'); this.emit('content-updated', data); }); this.socket.on('user-typing', (data) => { const { socketId, userId, isTyping, user } = data; const id = userId || (user && user.id) || socketId; if (!id || id === this.options.user.id) { return; } this.typingStatus[id] = { isTyping, user: user || { id, name: 'Unknown' }, timestamp: Date.now() }; this.emit('typing-status-changed', this.typingStatus); }); this.socket.on('error', (error) => { this.log('Error:', error); this.emit('error', error); }); // Handle heartbeat acknowledgment this.socket.on('heartbeat-ack', (data) => { this.log('Heartbeat acknowledged:', data); }); // Handle invitation events this.socket.on('invitation-received', (data) => { this._invitations.push(data); this.emit('invitation-received', data); }); this.socket.on('invitation-accepted', (data) => { this._invitations = this._invitations.filter(inv => inv.id !== data.id); this.emit('invitation-accepted', data); }); this.socket.on('invitation-rejected', (data) => { this._invitations = this._invitations.filter(inv => inv.id !== data.id); this.emit('invitation-rejected', data); }); // Handle role and permission changes this.socket.on('role-changed', (data) => { if (data.userId === this.options.user.id) { this._userRole = data.role; this._updatePermissions(); this.emit('role-changed', data); } else { // Update collaborator's role const collaborator = this.collaborators.find(c => c.id === data.userId); if (collaborator) { collaborator.role = data.role; this.emit('collaborators-changed', this.collaborators); } } }); this.socket.on('project-settings-updated', (data) => { this._projectSettings = data.settings; this.emit('project-settings-updated', data.settings); }); } disconnect() { if (!this.socket) { return; } this.socket.disconnect(); this.socket = null; this.connected = false; this.hasJoinedProject = false; } updateCursor(position) { if (!this.socket || !this.connected) { return; } // Don't emit heartbeats as regular cursor updates if (position.heartbeat) { this.socket.emit('heartbeat', { timestamp: Date.now() }); return; } 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 }); } updateContent(content, cursorPosition = null) { if (!this.socket || !this.connected) { return; } // Check if user has edit permission if (!this.hasPermission(Permission.EDIT)) { this.log('No edit permission'); this.emit('permission-denied', { action: 'edit' }); return; } // Create a unique version ID for this update const version = Date.now(); const updateData = { projectId: this.options.projectId, content, version, cursorPosition, userId: this.options.user.id, userName: this.options.user.name, timestamp: Date.now() }; // Store the content locally first to ensure consistency this._lastContent = content; this._lastVersion = version; // Send content update with more metadata this.socket.emit('content-update', updateData); // For backward compatibility this.socket.emit('content-change', { content, cursorPosition, user: this.options.user, version, timestamp: Date.now() }); // Log if debug is enabled this.log('Content updated:', content.substring(0, 50) + (content.length > 50 ? '...' : '')); // Emit a local content-updated event to ensure consistency this.emit('content-local-update', updateData); return updateData; } updateTypingStatus(isTyping) { if (!this.socket || !this.connected) { return; } this.socket.emit('user-typing', { isTyping }); } // Admin features inviteUser(email, role = UserRole.EDITOR) { if (!this.socket || !this.connected) { return; } // Check if user has admin permission if (this._userRole !== UserRole.ADMIN) { this.log('No admin permission'); this.emit('permission-denied', { action: 'invite' }); return; } const invitationData = { projectId: this.options.projectId, email, role, invitedBy: this.options.user.id, timestamp: Date.now() }; this.socket.emit('invite-user', invitationData); return invitationData; } removeUser(userId) { if (!this.socket || !this.connected) { return; } // Check if user has admin permission if (this._userRole !== UserRole.ADMIN) { this.log('No admin permission'); this.emit('permission-denied', { action: 'remove-user' }); return; } this.socket.emit('remove-user', { projectId: this.options.projectId, userId, removedBy: this.options.user.id }); } changeUserRole(userId, newRole) { if (!this.socket || !this.connected) { return; } // Check if user has admin permission if (this._userRole !== UserRole.ADMIN) { this.log('No admin permission'); this.emit('permission-denied', { action: 'change-role' }); return; } this.socket.emit('change-role', { projectId: this.options.projectId, userId, role: newRole, changedBy: this.options.user.id }); } updateProjectSettings(settings) { if (!this.socket || !this.connected) { return; } // Check if user has admin permission if (this._userRole !== UserRole.ADMIN) { this.log('No admin permission'); this.emit('permission-denied', { action: 'update-settings' }); return; } this.socket.emit('update-project-settings', { projectId: this.options.projectId, settings, updatedBy: this.options.user.id }); this._projectSettings = { ...this._projectSettings, ...settings }; } // Invitation handling acceptInvitation(invitationId) { if (!this.socket || !this.connected) { return; } this.socket.emit('accept-invitation', { invitationId, userId: this.options.user.id }); } rejectInvitation(invitationId) { if (!this.socket || !this.connected) { return; } this.socket.emit('reject-invitation', { invitationId, userId: this.options.user.id }); } // Permission handling hasPermission(permission) { return this._permissions.has(permission); } _updatePermissions() { // Update permissions based on role this._permissions.clear(); switch (this._userRole) { case UserRole.ADMIN: this._permissions.add(Permission.EDIT); this._permissions.add(Permission.COMMENT); this._permissions.add(Permission.VIEW); break; case UserRole.EDITOR: this._permissions.add(Permission.EDIT); this._permissions.add(Permission.COMMENT); this._permissions.add(Permission.VIEW); break; case UserRole.VIEWER: this._permissions.add(Permission.VIEW); break; default: this._permissions.add(Permission.VIEW); } } log(...args) { if (this.options.debug) { console.log('[RealtimeCursor]', ...args); } } // Getters getCollaborators() { return this.collaborators; } getCursors() { return this.cursors; } getTypingStatus() { return this.typingStatus; } isConnected() { return this.connected; } getInvitations() { return this._invitations; } getUserRole() { return this._userRole; } getProjectSettings() { return this._projectSettings; } } // React hook implementation export function useRealtimeCursor(options) { // Check if React is available in the global scope const React = typeof window !== 'undefined' && window.React ? window.React : null; if (!React) { throw new Error('React is not defined. Make sure to import React before using this hook.'); } const { useState, useEffect, useRef, useCallback } = React; const [cursors, setCursors] = useState({}); const [collaborators, setCollaborators] = useState([]); const [connected, setConnected] = useState(false); const [typingStatus, setTypingStatus] = useState({}); const [invitations, setInvitations] = useState([]); const [userRole, setUserRole] = useState(options.user?.role || UserRole.EDITOR); const [projectSettings, setProjectSettings] = useState({}); const cursorClientRef = useRef(null); useEffect(() => { // Initialize the cursor client const cursorClient = new RealtimeCursor(options); cursorClientRef.current = cursorClient; // Set up event handlers cursorClient.on('connect', () => setConnected(true)); cursorClient.on('disconnect', () => setConnected(false)); cursorClient.on('cursors-changed', setCursors); cursorClient.on('collaborators-changed', setCollaborators); cursorClient.on('typing-status-changed', setTypingStatus); cursorClient.on('invitation-received', (invitation) => { setInvitations(prev => [...prev, invitation]); }); cursorClient.on('invitation-accepted', (invitation) => { setInvitations(prev => prev.filter(inv => inv.id !== invitation.id)); }); cursorClient.on('invitation-rejected', (invitation) => { setInvitations(prev => prev.filter(inv => inv.id !== invitation.id)); }); cursorClient.on('role-changed', (data) => { if (data.userId === options.user.id) { setUserRole(data.role); } }); cursorClient.on('project-settings-updated', setProjectSettings); // Connect immediately cursorClient.connect(); // Send heartbeat every 5 seconds to keep connection alive const heartbeatInterval = setInterval(() => { if (cursorClient && cursorClient.isConnected()) { cursorClient.updateCursor({ heartbeat: true }); } }, 5000); return () => { clearInterval(heartbeatInterval); 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, cursorPosition = null) => { if (cursorClientRef.current) { return cursorClientRef.current.updateContent(content, cursorPosition); } }, []); const updateTypingStatus = useCallback((isTyping) => { if (cursorClientRef.current) { cursorClientRef.current.updateTypingStatus(isTyping); } }, []); // Admin functions const inviteUser = useCallback((email, role = UserRole.EDITOR) => { if (cursorClientRef.current) { return cursorClientRef.current.inviteUser(email, role); } }, []); const removeUser = useCallback((userId) => { if (cursorClientRef.current) { cursorClientRef.current.removeUser(userId); } }, []); const changeUserRole = useCallback((userId, newRole) => { if (cursorClientRef.current) { cursorClientRef.current.changeUserRole(userId, newRole); } }, []); const updateProjectSettings = useCallback((settings) => { if (cursorClientRef.current) { cursorClientRef.current.updateProjectSettings(settings); } }, []); // Invitation handling const acceptInvitation = useCallback((invitationId) => { if (cursorClientRef.current) { cursorClientRef.current.acceptInvitation(invitationId); } }, []); const rejectInvitation = useCallback((invitationId) => { if (cursorClientRef.current) { cursorClientRef.current.rejectInvitation(invitationId); } }, []); // Permission checking const hasPermission = useCallback((permission) => { if (cursorClientRef.current) { return cursorClientRef.current.hasPermission(permission); } return false; }, []); return { cursors, collaborators, connected, typingStatus, invitations, userRole, projectSettings, connect, disconnect, updateCursor, updateContent, updateTypingStatus, inviteUser, removeUser, changeUserRole, updateProjectSettings, acceptInvitation, rejectInvitation, hasPermission, cursorClient: cursorClientRef.current }; } // Enhanced RealtimeEditor component with admin features export { default as RealtimeEditor } from './RealtimeEditor'; export { default as AdminPanel } from './AdminPanel'; export { default as InvitationManager } from './InvitationManager'; export default { RealtimeCursor, useRealtimeCursor, RealtimeEditor: require('./RealtimeEditor').default, AdminPanel: require('./AdminPanel').default, InvitationManager: require('./InvitationManager').default, UserRole, Permission };