sourabhrealtime
Version:
ROBUST RICH TEXT EDITOR: Single-pane contentEditable with direct text selection formatting, speech features, undo/redo, professional UI - Perfect TipTap alternative
280 lines (241 loc) • 7 kB
JavaScript
// Real-time collaboration engine with advanced features
export class RealtimeEngine {
constructor() {
this.cursors = new Map();
this.selections = new Map();
this.typingUsers = new Map();
this.operations = new Map();
this.awareness = new Map();
}
// Track user cursor position
updateCursor(projectId, userId, position, user) {
if (!this.cursors.has(projectId)) {
this.cursors.set(projectId, new Map());
}
const projectCursors = this.cursors.get(projectId);
projectCursors.set(userId, {
userId,
user,
position,
timestamp: Date.now(),
color: user.color || this.generateUserColor(userId)
});
return {
type: 'cursor-update',
projectId,
userId,
position,
user,
timestamp: Date.now()
};
}
// Track text selection
updateSelection(projectId, userId, selection, user) {
if (!this.selections.has(projectId)) {
this.selections.set(projectId, new Map());
}
const projectSelections = this.selections.get(projectId);
projectSelections.set(userId, {
userId,
user,
selection,
timestamp: Date.now(),
color: user.color || this.generateUserColor(userId)
});
return {
type: 'selection-update',
projectId,
userId,
selection,
user,
timestamp: Date.now()
};
}
// Track typing status
updateTyping(projectId, userId, isTyping, user) {
if (!this.typingUsers.has(projectId)) {
this.typingUsers.set(projectId, new Map());
}
const projectTyping = this.typingUsers.get(projectId);
if (isTyping) {
projectTyping.set(userId, {
userId,
user,
startedAt: Date.now()
});
} else {
projectTyping.delete(userId);
}
return {
type: 'typing-update',
projectId,
userId,
isTyping,
user,
timestamp: Date.now()
};
}
// Process content operation (for operational transformation)
processOperation(projectId, userId, operation, user) {
if (!this.operations.has(projectId)) {
this.operations.set(projectId, []);
}
const projectOps = this.operations.get(projectId);
const op = {
id: 'op_' + Math.random().toString(36).substr(2, 9),
userId,
user,
operation,
timestamp: Date.now(),
applied: false
};
projectOps.push(op);
// Keep only last 100 operations
if (projectOps.length > 100) {
projectOps.splice(0, projectOps.length - 100);
}
return {
type: 'operation',
projectId,
operation: op,
timestamp: Date.now()
};
}
// Update user awareness (viewport, focus, etc.)
updateAwareness(projectId, userId, awareness, user) {
if (!this.awareness.has(projectId)) {
this.awareness.set(projectId, new Map());
}
const projectAwareness = this.awareness.get(projectId);
projectAwareness.set(userId, {
userId,
user,
...awareness,
timestamp: Date.now()
});
return {
type: 'awareness-update',
projectId,
userId,
awareness,
user,
timestamp: Date.now()
};
}
// Get project state
getProjectState(projectId) {
return {
cursors: Array.from(this.cursors.get(projectId)?.values() || []),
selections: Array.from(this.selections.get(projectId)?.values() || []),
typingUsers: Array.from(this.typingUsers.get(projectId)?.values() || []),
awareness: Array.from(this.awareness.get(projectId)?.values() || []),
operations: this.operations.get(projectId) || []
};
}
// Clean up user data when they leave
removeUser(projectId, userId) {
this.cursors.get(projectId)?.delete(userId);
this.selections.get(projectId)?.delete(userId);
this.typingUsers.get(projectId)?.delete(userId);
this.awareness.get(projectId)?.delete(userId);
return {
type: 'user-left',
projectId,
userId,
timestamp: Date.now()
};
}
// Generate consistent color for user
generateUserColor(userId) {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F8C471', '#82E0AA', '#F1948A', '#85C1E9', '#D7BDE2'
];
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
// Apply operational transformation
transformOperation(op1, op2) {
// Simplified OT - in production, use a proper OT library
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position <= op2.position) {
return {
...op2,
position: op2.position + op1.text.length
};
}
}
if (op1.type === 'delete' && op2.type === 'insert') {
if (op1.position < op2.position) {
return {
...op2,
position: op2.position - op1.length
};
}
}
return op2;
}
// Get typing indicators for display
getTypingIndicators(projectId, excludeUserId = null) {
const typing = this.typingUsers.get(projectId);
if (!typing) return [];
const now = Date.now();
const indicators = [];
for (const [userId, data] of typing.entries()) {
if (userId !== excludeUserId && now - data.startedAt < 3000) {
indicators.push({
userId,
user: data.user,
duration: now - data.startedAt
});
}
}
return indicators;
}
// Get active cursors for display
getActiveCursors(projectId, excludeUserId = null) {
const cursors = this.cursors.get(projectId);
if (!cursors) return [];
const now = Date.now();
const activeCursors = [];
for (const [userId, data] of cursors.entries()) {
if (userId !== excludeUserId && now - data.timestamp < 10000) {
activeCursors.push(data);
}
}
return activeCursors;
}
// Clean up stale data
cleanup() {
const now = Date.now();
const staleThreshold = 30000; // 30 seconds
// Clean up stale cursors
for (const [projectId, cursors] of this.cursors.entries()) {
for (const [userId, data] of cursors.entries()) {
if (now - data.timestamp > staleThreshold) {
cursors.delete(userId);
}
}
}
// Clean up stale typing indicators
for (const [projectId, typing] of this.typingUsers.entries()) {
for (const [userId, data] of typing.entries()) {
if (now - data.startedAt > 5000) {
typing.delete(userId);
}
}
}
// Clean up stale awareness
for (const [projectId, awareness] of this.awareness.entries()) {
for (const [userId, data] of awareness.entries()) {
if (now - data.timestamp > staleThreshold) {
awareness.delete(userId);
}
}
}
}
}