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
text/typescript
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
};