Markdown Collaborative Editing and Real-Time Synchronization: Complete Guide for Team-Based Documentation Workflows
Collaborative Markdown editing with real-time synchronization transforms traditional documentation workflows into dynamic, multi-user environments where teams can simultaneously create, edit, and review content with immediate visibility of changes. By implementing sophisticated operational transformation algorithms, conflict resolution mechanisms, and synchronized state management, teams can achieve seamless collaborative editing experiences that maintain document integrity while enabling productive parallel workflows across distributed development environments.
Why Master Collaborative Markdown Editing?
Real-time collaborative editing provides essential capabilities for modern documentation teams:
- Simultaneous Editing: Enable multiple team members to work on the same document without conflicts
- Real-Time Visibility: See changes from other users instantly as they type and edit content
- Conflict Resolution: Automatically resolve editing conflicts while preserving all contributions
- Version Control Integration: Maintain comprehensive change history and branch management
- Distributed Teams: Support remote collaboration with synchronized editing experiences
Foundation Collaborative Architecture
Core Synchronization Engine
Building a robust real-time synchronization system for collaborative Markdown editing:
// collaborative-markdown-engine.js - Real-time collaborative editing system
const EventEmitter = require('events');
const { WebSocketServer } = require('ws');
class CollaborativeMarkdownEngine extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
port: options.port || 3001,
enablePersistence: options.enablePersistence !== false,
conflictResolution: options.conflictResolution || 'operational-transform',
heartbeatInterval: options.heartbeatInterval || 30000,
maxConcurrentUsers: options.maxConcurrentUsers || 50,
documentTimeout: options.documentTimeout || 3600000, // 1 hour
enableVersioning: options.enableVersioning !== false,
enableLocking: options.enableLocking !== false,
...options
};
this.documents = new Map(); // documentId -> DocumentState
this.users = new Map(); // userId -> UserSession
this.connections = new Map(); // connectionId -> WebSocket
this.operationQueue = new Map(); // documentId -> Operation[]
this.wss = null;
this.heartbeatTimer = null;
this.setupWebSocketServer();
this.setupOperationalTransform();
this.startHeartbeat();
console.log(`Collaborative Markdown Engine initialized on port ${this.options.port}`);
}
setupWebSocketServer() {
this.wss = new WebSocketServer({
port: this.options.port,
perMessageDeflate: true,
maxPayload: 1024 * 1024 // 1MB
});
this.wss.on('connection', (ws, request) => {
this.handleConnection(ws, request);
});
this.wss.on('error', (error) => {
console.error('WebSocket server error:', error);
this.emit('error', error);
});
}
handleConnection(ws, request) {
const connectionId = this.generateId();
const userAgent = request.headers['user-agent'] || '';
const ip = request.socket.remoteAddress;
console.log(`New connection: ${connectionId} from ${ip}`);
this.connections.set(connectionId, {
ws,
connectionId,
userId: null,
documentId: null,
userAgent,
ip,
connectedAt: Date.now(),
lastActivity: Date.now()
});
ws.on('message', (data) => {
this.handleMessage(connectionId, data);
});
ws.on('close', () => {
this.handleDisconnection(connectionId);
});
ws.on('error', (error) => {
console.error(`Connection error for ${connectionId}:`, error);
this.handleDisconnection(connectionId);
});
// Send connection acknowledgment
this.sendToConnection(connectionId, {
type: 'connection-ack',
connectionId,
serverTime: Date.now()
});
}
async handleMessage(connectionId, data) {
try {
const connection = this.connections.get(connectionId);
if (!connection) return;
connection.lastActivity = Date.now();
const message = JSON.parse(data.toString());
const { type, payload } = message;
console.log(`Message from ${connectionId}: ${type}`);
switch (type) {
case 'join-document':
await this.handleJoinDocument(connectionId, payload);
break;
case 'leave-document':
await this.handleLeaveDocument(connectionId, payload);
break;
case 'operation':
await this.handleOperation(connectionId, payload);
break;
case 'cursor-update':
await this.handleCursorUpdate(connectionId, payload);
break;
case 'selection-update':
await this.handleSelectionUpdate(connectionId, payload);
break;
case 'lock-request':
await this.handleLockRequest(connectionId, payload);
break;
case 'unlock-request':
await this.handleUnlockRequest(connectionId, payload);
break;
case 'heartbeat':
this.handleHeartbeat(connectionId, payload);
break;
default:
console.warn(`Unknown message type: ${type}`);
}
} catch (error) {
console.error(`Error handling message from ${connectionId}:`, error);
this.sendToConnection(connectionId, {
type: 'error',
error: 'Message processing failed',
originalMessage: data.toString()
});
}
}
async handleJoinDocument(connectionId, payload) {
const { documentId, userId, userInfo } = payload;
const connection = this.connections.get(connectionId);
if (!connection || !documentId || !userId) {
return this.sendError(connectionId, 'Invalid join request');
}
// Initialize document if it doesn't exist
if (!this.documents.has(documentId)) {
await this.initializeDocument(documentId);
}
const document = this.documents.get(documentId);
// Check user limits
if (document.activeUsers.size >= this.options.maxConcurrentUsers) {
return this.sendError(connectionId, 'Document user limit reached');
}
// Update connection info
connection.userId = userId;
connection.documentId = documentId;
// Add user to document
const userSession = {
userId,
connectionId,
userInfo: {
name: userInfo?.name || `User ${userId}`,
color: userInfo?.color || this.generateUserColor(),
avatar: userInfo?.avatar || null,
...userInfo
},
cursor: { line: 0, column: 0 },
selection: null,
joinedAt: Date.now(),
lastActivity: Date.now()
};
document.activeUsers.set(userId, userSession);
this.users.set(userId, userSession);
// Send document state to new user
this.sendToConnection(connectionId, {
type: 'document-state',
document: {
id: documentId,
content: document.content,
version: document.version,
metadata: document.metadata
},
activeUsers: Array.from(document.activeUsers.values()).map(user => ({
userId: user.userId,
userInfo: user.userInfo,
cursor: user.cursor,
selection: user.selection
})),
locks: Array.from(document.locks.entries())
});
// Notify other users about new user
this.broadcastToDocument(documentId, {
type: 'user-joined',
user: {
userId,
userInfo: userSession.userInfo,
cursor: userSession.cursor
}
}, connectionId);
console.log(`User ${userId} joined document ${documentId}`);
this.emit('user-joined', { documentId, userId, userInfo: userSession.userInfo });
}
async handleLeaveDocument(connectionId, payload) {
const connection = this.connections.get(connectionId);
if (!connection || !connection.documentId) return;
const { documentId, userId } = connection;
await this.removeUserFromDocument(documentId, userId, connectionId);
}
async handleOperation(connectionId, payload) {
const { documentId, operation, version } = payload;
const connection = this.connections.get(connectionId);
if (!connection || connection.documentId !== documentId) {
return this.sendError(connectionId, 'Invalid operation context');
}
const document = this.documents.get(documentId);
if (!document) {
return this.sendError(connectionId, 'Document not found');
}
try {
// Apply operational transformation
const transformedOperation = await this.transformOperation(
document,
operation,
version
);
// Apply operation to document
const result = await this.applyOperation(document, transformedOperation);
if (result.success) {
// Broadcast transformed operation to all users
this.broadcastToDocument(documentId, {
type: 'operation',
operation: transformedOperation,
version: document.version,
author: connection.userId,
timestamp: Date.now()
}, connectionId);
// Send acknowledgment to sender
this.sendToConnection(connectionId, {
type: 'operation-ack',
operationId: operation.id,
version: document.version,
success: true
});
// Persist if enabled
if (this.options.enablePersistence) {
await this.persistDocument(document);
}
this.emit('document-changed', {
documentId,
operation: transformedOperation,
author: connection.userId,
version: document.version
});
} else {
this.sendError(connectionId, `Operation failed: ${result.error}`);
}
} catch (error) {
console.error(`Operation processing failed:`, error);
this.sendError(connectionId, `Operation processing failed: ${error.message}`);
}
}
async handleCursorUpdate(connectionId, payload) {
const { documentId, cursor } = payload;
const connection = this.connections.get(connectionId);
if (!connection || connection.documentId !== documentId) return;
const document = this.documents.get(documentId);
const user = document?.activeUsers.get(connection.userId);
if (user) {
user.cursor = cursor;
user.lastActivity = Date.now();
// Broadcast cursor update to other users
this.broadcastToDocument(documentId, {
type: 'cursor-update',
userId: connection.userId,
cursor
}, connectionId);
}
}
async handleSelectionUpdate(connectionId, payload) {
const { documentId, selection } = payload;
const connection = this.connections.get(connectionId);
if (!connection || connection.documentId !== documentId) return;
const document = this.documents.get(documentId);
const user = document?.activeUsers.get(connection.userId);
if (user) {
user.selection = selection;
user.lastActivity = Date.now();
// Broadcast selection update to other users
this.broadcastToDocument(documentId, {
type: 'selection-update',
userId: connection.userId,
selection
}, connectionId);
}
}
async handleLockRequest(connectionId, payload) {
const { documentId, range, lockType = 'edit' } = payload;
const connection = this.connections.get(connectionId);
if (!this.options.enableLocking || !connection) return;
const document = this.documents.get(documentId);
if (!document) return;
const lockId = this.generateId();
const lock = {
lockId,
userId: connection.userId,
range,
lockType,
timestamp: Date.now(),
expiresAt: Date.now() + (5 * 60 * 1000) // 5 minutes
};
// Check for conflicting locks
const hasConflict = Array.from(document.locks.values()).some(existingLock =>
this.rangesOverlap(range, existingLock.range)
);
if (!hasConflict) {
document.locks.set(lockId, lock);
this.sendToConnection(connectionId, {
type: 'lock-acquired',
lockId,
range,
lockType
});
this.broadcastToDocument(documentId, {
type: 'lock-created',
lock
}, connectionId);
// Auto-release lock after expiry
setTimeout(() => {
if (document.locks.has(lockId)) {
document.locks.delete(lockId);
this.broadcastToDocument(documentId, {
type: 'lock-released',
lockId
});
}
}, lock.expiresAt - Date.now());
} else {
this.sendError(connectionId, 'Lock conflict detected');
}
}
async handleUnlockRequest(connectionId, payload) {
const { documentId, lockId } = payload;
const connection = this.connections.get(connectionId);
if (!connection) return;
const document = this.documents.get(documentId);
const lock = document?.locks.get(lockId);
if (lock && lock.userId === connection.userId) {
document.locks.delete(lockId);
this.broadcastToDocument(documentId, {
type: 'lock-released',
lockId
});
}
}
handleHeartbeat(connectionId, payload) {
const connection = this.connections.get(connectionId);
if (connection) {
connection.lastActivity = Date.now();
this.sendToConnection(connectionId, {
type: 'heartbeat-ack',
serverTime: Date.now()
});
}
}
handleDisconnection(connectionId) {
console.log(`Connection closed: ${connectionId}`);
const connection = this.connections.get(connectionId);
if (connection) {
if (connection.documentId && connection.userId) {
this.removeUserFromDocument(
connection.documentId,
connection.userId,
connectionId
);
}
this.connections.delete(connectionId);
}
}
async removeUserFromDocument(documentId, userId, connectionId) {
const document = this.documents.get(documentId);
if (!document) return;
const user = document.activeUsers.get(userId);
if (user) {
document.activeUsers.delete(userId);
this.users.delete(userId);
// Release any locks held by this user
const userLocks = Array.from(document.locks.entries())
.filter(([_, lock]) => lock.userId === userId);
userLocks.forEach(([lockId, _]) => {
document.locks.delete(lockId);
});
// Notify other users
this.broadcastToDocument(documentId, {
type: 'user-left',
userId,
locksReleased: userLocks.map(([lockId]) => lockId)
}, connectionId);
console.log(`User ${userId} left document ${documentId}`);
this.emit('user-left', { documentId, userId });
// Clean up empty document
if (document.activeUsers.size === 0) {
setTimeout(() => {
if (this.documents.has(documentId) &&
this.documents.get(documentId).activeUsers.size === 0) {
this.documents.delete(documentId);
console.log(`Document ${documentId} cleaned up`);
}
}, this.options.documentTimeout);
}
}
}
setupOperationalTransform() {
this.operationTransformer = new OperationalTransform();
}
async transformOperation(document, operation, clientVersion) {
// Get operations that happened after client's version
const missedOperations = this.getOperationsSince(document, clientVersion);
let transformedOp = { ...operation };
// Transform against each missed operation
for (const missedOp of missedOperations) {
transformedOp = this.operationTransformer.transform(
transformedOp,
missedOp,
'left'
);
}
return transformedOp;
}
async applyOperation(document, operation) {
try {
const { type, position, content, length } = operation;
switch (type) {
case 'insert':
document.content = this.insertText(
document.content,
position,
content
);
break;
case 'delete':
document.content = this.deleteText(
document.content,
position,
length
);
break;
case 'replace':
document.content = this.replaceText(
document.content,
position,
length,
content
);
break;
default:
throw new Error(`Unknown operation type: ${type}`);
}
// Update version and add to history
document.version++;
operation.version = document.version;
document.operationHistory.push(operation);
// Keep history bounded
if (document.operationHistory.length > 1000) {
document.operationHistory = document.operationHistory.slice(-800);
}
document.lastModified = Date.now();
return { success: true, version: document.version };
} catch (error) {
return { success: false, error: error.message };
}
}
insertText(content, position, text) {
return content.slice(0, position) + text + content.slice(position);
}
deleteText(content, position, length) {
return content.slice(0, position) + content.slice(position + length);
}
replaceText(content, position, length, newText) {
return content.slice(0, position) + newText + content.slice(position + length);
}
getOperationsSince(document, version) {
return document.operationHistory.filter(op => op.version > version);
}
async initializeDocument(documentId, initialContent = '') {
const document = {
id: documentId,
content: initialContent,
version: 0,
operationHistory: [],
activeUsers: new Map(),
locks: new Map(),
metadata: {
createdAt: Date.now(),
lastModified: Date.now(),
title: `Document ${documentId}`
}
};
this.documents.set(documentId, document);
// Load persisted content if available
if (this.options.enablePersistence) {
try {
const persisted = await this.loadDocument(documentId);
if (persisted) {
Object.assign(document, persisted);
}
} catch (error) {
console.warn(`Failed to load persisted document ${documentId}:`, error);
}
}
console.log(`Document ${documentId} initialized`);
return document;
}
async persistDocument(document) {
// Implementation would depend on chosen storage backend
// This is a placeholder for persistence logic
console.log(`Persisting document ${document.id} version ${document.version}`);
}
async loadDocument(documentId) {
// Implementation would depend on chosen storage backend
// This is a placeholder for loading logic
console.log(`Loading document ${documentId}`);
return null;
}
sendToConnection(connectionId, message) {
const connection = this.connections.get(connectionId);
if (connection && connection.ws.readyState === 1) { // WebSocket.OPEN
try {
connection.ws.send(JSON.stringify(message));
} catch (error) {
console.error(`Failed to send message to ${connectionId}:`, error);
this.handleDisconnection(connectionId);
}
}
}
sendError(connectionId, error) {
this.sendToConnection(connectionId, {
type: 'error',
error,
timestamp: Date.now()
});
}
broadcastToDocument(documentId, message, excludeConnection = null) {
const document = this.documents.get(documentId);
if (!document) return;
document.activeUsers.forEach(user => {
if (user.connectionId !== excludeConnection) {
this.sendToConnection(user.connectionId, message);
}
});
}
rangesOverlap(range1, range2) {
// Simple range overlap check
const start1 = range1.start;
const end1 = range1.end;
const start2 = range2.start;
const end2 = range2.end;
return start1 < end2 && start2 < end1;
}
generateId() {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
generateUserColor() {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57',
'#FF9FF3', '#54A0FF', '#5F27CD', '#00D2D3', '#FF9F43',
'#8395A7', '#6C5CE7', '#A29BFE', '#FD79A8', '#E17055'
];
return colors[Math.floor(Math.random() * colors.length)];
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
const now = Date.now();
const timeout = 60000; // 1 minute
// Check for inactive connections
this.connections.forEach((connection, connectionId) => {
if (now - connection.lastActivity > timeout) {
console.log(`Connection ${connectionId} timed out`);
connection.ws.terminate();
this.handleDisconnection(connectionId);
}
});
// Send heartbeat to active connections
this.connections.forEach((connection, connectionId) => {
if (connection.ws.readyState === 1) {
this.sendToConnection(connectionId, {
type: 'heartbeat',
serverTime: now
});
}
});
}, this.options.heartbeatInterval);
}
getDocumentStats(documentId) {
const document = this.documents.get(documentId);
if (!document) return null;
return {
id: documentId,
version: document.version,
contentLength: document.content.length,
activeUsers: document.activeUsers.size,
activeLocks: document.locks.size,
operationCount: document.operationHistory.length,
lastModified: document.lastModified,
metadata: document.metadata
};
}
getServerStats() {
return {
totalDocuments: this.documents.size,
totalConnections: this.connections.size,
totalUsers: this.users.size,
uptime: Date.now() - this.startTime,
memoryUsage: process.memoryUsage()
};
}
async shutdown() {
console.log('Shutting down collaborative engine...');
// Clear heartbeat timer
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
}
// Close all connections
this.connections.forEach((connection, connectionId) => {
connection.ws.close(1001, 'Server shutting down');
});
// Close WebSocket server
if (this.wss) {
this.wss.close();
}
// Persist all documents
if (this.options.enablePersistence) {
const persistPromises = Array.from(this.documents.values())
.map(doc => this.persistDocument(doc));
await Promise.all(persistPromises);
}
console.log('Collaborative engine shut down complete');
}
}
// Operational Transform implementation
class OperationalTransform {
transform(op1, op2, priority = 'left') {
// Simplified operational transform implementation
// In production, you'd use a more sophisticated library like ShareJS or Yjs
if (op1.type === 'insert' && op2.type === 'insert') {
return this.transformInsertInsert(op1, op2, priority);
} else if (op1.type === 'insert' && op2.type === 'delete') {
return this.transformInsertDelete(op1, op2);
} else if (op1.type === 'delete' && op2.type === 'insert') {
return this.transformDeleteInsert(op1, op2);
} else if (op1.type === 'delete' && op2.type === 'delete') {
return this.transformDeleteDelete(op1, op2);
}
return op1; // Fallback
}
transformInsertInsert(op1, op2, priority) {
if (op1.position <= op2.position || (op1.position === op2.position && priority === 'left')) {
return op1;
} else {
return {
...op1,
position: op1.position + op2.content.length
};
}
}
transformInsertDelete(op1, op2) {
if (op1.position <= op2.position) {
return op1;
} else if (op1.position >= op2.position + op2.length) {
return {
...op1,
position: op1.position - op2.length
};
} else {
// Insert position is within deleted range
return {
...op1,
position: op2.position
};
}
}
transformDeleteInsert(op1, op2) {
if (op1.position >= op2.position) {
return {
...op1,
position: op1.position + op2.content.length
};
} else {
return op1;
}
}
transformDeleteDelete(op1, op2) {
if (op1.position >= op2.position + op2.length) {
return {
...op1,
position: op1.position - op2.length
};
} else if (op1.position + op1.length <= op2.position) {
return op1;
} else {
// Overlapping deletes - more complex logic needed
const start1 = op1.position;
const end1 = op1.position + op1.length;
const start2 = op2.position;
const end2 = op2.position + op2.length;
if (start1 >= start2 && end1 <= end2) {
// op1 is completely within op2, becomes no-op
return { ...op1, length: 0 };
} else if (start2 >= start1 && end2 <= end1) {
// op2 is completely within op1
return {
...op1,
length: op1.length - op2.length
};
} else {
// Partial overlap - simplified handling
const newStart = Math.max(start1, start2);
const newEnd = Math.min(end1, end2);
const overlap = Math.max(0, newEnd - newStart);
return {
...op1,
length: Math.max(0, op1.length - overlap),
position: Math.min(op1.position, op2.position)
};
}
}
}
}
module.exports = CollaborativeMarkdownEngine;
Client-Side Integration
Real-time collaborative editing client implementation:
// collaborative-markdown-client.js - Client-side collaboration
class CollaborativeMarkdownClient extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
serverUrl: options.serverUrl || 'ws://localhost:3001',
reconnectDelay: options.reconnectDelay || 3000,
maxReconnectAttempts: options.maxReconnectAttempts || 10,
operationBufferSize: options.operationBufferSize || 100,
enableCursorSync: options.enableCursorSync !== false,
enableSelectionSync: options.enableSelectionSync !== false,
heartbeatInterval: options.heartbeatInterval || 25000,
...options
};
this.ws = null;
this.connectionId = null;
this.documentId = null;
this.userId = null;
this.documentVersion = 0;
this.isConnected = false;
this.isJoined = false;
this.reconnectAttempts = 0;
this.pendingOperations = [];
this.operationBuffer = [];
this.remoteUsers = new Map();
this.activeLocks = new Map();
this.editor = null;
this.heartbeatTimer = null;
this.setupEventHandlers();
}
setupEventHandlers() {
// Buffer operations when disconnected
this.on('operation-queued', (operation) => {
if (!this.isConnected) {
this.pendingOperations.push(operation);
}
});
}
async connect(retryAttempt = 0) {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.options.serverUrl);
this.ws.onopen = () => {
console.log('Connected to collaborative server');
this.isConnected = true;
this.reconnectAttempts = 0;
this.startHeartbeat();
this.emit('connected');
resolve();
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onclose = (event) => {
console.log('Disconnected from collaborative server');
this.handleDisconnection();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.emit('error', error);
if (retryAttempt < this.options.maxReconnectAttempts) {
setTimeout(() => {
this.connect(retryAttempt + 1);
}, this.options.reconnectDelay * Math.pow(2, retryAttempt));
} else {
reject(new Error('Max reconnection attempts reached'));
}
};
} catch (error) {
reject(error);
}
});
}
async joinDocument(documentId, userId, userInfo = {}) {
if (!this.isConnected) {
throw new Error('Not connected to server');
}
this.documentId = documentId;
this.userId = userId;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Join document timeout'));
}, 10000);
const handleDocumentState = (data) => {
if (data.type === 'document-state') {
clearTimeout(timeout);
this.off('message', handleDocumentState);
this.documentVersion = data.document.version;
this.isJoined = true;
// Update remote users
data.activeUsers.forEach(user => {
if (user.userId !== this.userId) {
this.remoteUsers.set(user.userId, user);
}
});
// Update locks
data.locks.forEach(([lockId, lock]) => {
this.activeLocks.set(lockId, lock);
});
this.emit('document-joined', data.document);
this.emit('users-updated', Array.from(this.remoteUsers.values()));
// Send pending operations
this.flushPendingOperations();
resolve(data.document);
}
};
this.on('message', handleDocumentState);
this.send({
type: 'join-document',
payload: {
documentId,
userId,
userInfo
}
});
});
}
async leaveDocument() {
if (!this.isJoined) return;
this.send({
type: 'leave-document',
payload: {
documentId: this.documentId,
userId: this.userId
}
});
this.isJoined = false;
this.documentId = null;
this.documentVersion = 0;
this.remoteUsers.clear();
this.activeLocks.clear();
this.emit('document-left');
}
sendOperation(operation) {
if (!this.isJoined) {
this.emit('operation-queued', operation);
return;
}
const operationWithId = {
...operation,
id: this.generateOperationId(),
timestamp: Date.now()
};
this.send({
type: 'operation',
payload: {
documentId: this.documentId,
operation: operationWithId,
version: this.documentVersion
}
});
// Add to buffer for tracking
this.operationBuffer.push(operationWithId);
// Keep buffer size manageable
if (this.operationBuffer.length > this.options.operationBufferSize) {
this.operationBuffer = this.operationBuffer.slice(-50);
}
}
sendCursorUpdate(cursor) {
if (!this.isJoined || !this.options.enableCursorSync) return;
this.send({
type: 'cursor-update',
payload: {
documentId: this.documentId,
cursor
}
});
}
sendSelectionUpdate(selection) {
if (!this.isJoined || !this.options.enableSelectionSync) return;
this.send({
type: 'selection-update',
payload: {
documentId: this.documentId,
selection
}
});
}
requestLock(range, lockType = 'edit') {
if (!this.isJoined) return;
this.send({
type: 'lock-request',
payload: {
documentId: this.documentId,
range,
lockType
}
});
}
releaseLock(lockId) {
if (!this.isJoined) return;
this.send({
type: 'unlock-request',
payload: {
documentId: this.documentId,
lockId
}
});
}
handleMessage(data) {
try {
const message = JSON.parse(data);
this.emit('message', message);
switch (message.type) {
case 'connection-ack':
this.connectionId = message.connectionId;
break;
case 'operation':
this.handleRemoteOperation(message);
break;
case 'operation-ack':
this.handleOperationAck(message);
break;
case 'user-joined':
this.handleUserJoined(message);
break;
case 'user-left':
this.handleUserLeft(message);
break;
case 'cursor-update':
this.handleCursorUpdate(message);
break;
case 'selection-update':
this.handleSelectionUpdate(message);
break;
case 'lock-created':
case 'lock-acquired':
this.handleLockCreated(message);
break;
case 'lock-released':
this.handleLockReleased(message);
break;
case 'heartbeat':
this.handleHeartbeat(message);
break;
case 'heartbeat-ack':
this.handleHeartbeatAck(message);
break;
case 'error':
console.error('Server error:', message.error);
this.emit('server-error', message.error);
break;
default:
console.warn('Unknown message type:', message.type);
}
} catch (error) {
console.error('Error handling message:', error);
}
}
handleRemoteOperation(message) {
const { operation, version, author, timestamp } = message;
this.documentVersion = version;
this.emit('remote-operation', {
operation,
version,
author,
timestamp
});
}
handleOperationAck(message) {
const { operationId, version, success } = message;
if (success) {
this.documentVersion = version;
// Remove acknowledged operation from buffer
this.operationBuffer = this.operationBuffer.filter(
op => op.id !== operationId
);
}
this.emit('operation-ack', {
operationId,
version,
success
});
}
handleUserJoined(message) {
const { user } = message;
this.remoteUsers.set(user.userId, user);
this.emit('user-joined', user);
this.emit('users-updated', Array.from(this.remoteUsers.values()));
}
handleUserLeft(message) {
const { userId, locksReleased } = message;
this.remoteUsers.delete(userId);
// Remove locks released by the user
locksReleased?.forEach(lockId => {
this.activeLocks.delete(lockId);
});
this.emit('user-left', { userId, locksReleased });
this.emit('users-updated', Array.from(this.remoteUsers.values()));
this.emit('locks-updated', Array.from(this.activeLocks.values()));
}
handleCursorUpdate(message) {
const { userId, cursor } = message;
const user = this.remoteUsers.get(userId);
if (user) {
user.cursor = cursor;
this.emit('cursor-updated', { userId, cursor });
}
}
handleSelectionUpdate(message) {
const { userId, selection } = message;
const user = this.remoteUsers.get(userId);
if (user) {
user.selection = selection;
this.emit('selection-updated', { userId, selection });
}
}
handleLockCreated(message) {
const { lock } = message;
this.activeLocks.set(lock.lockId, lock);
this.emit('lock-created', lock);
this.emit('locks-updated', Array.from(this.activeLocks.values()));
}
handleLockReleased(message) {
const { lockId } = message;
this.activeLocks.delete(lockId);
this.emit('lock-released', lockId);
this.emit('locks-updated', Array.from(this.activeLocks.values()));
}
handleHeartbeat(message) {
this.send({
type: 'heartbeat',
payload: {
clientTime: Date.now()
}
});
}
handleHeartbeatAck(message) {
const { serverTime } = message;
const clientTime = Date.now();
const latency = clientTime - (message.clientTime || clientTime);
this.emit('latency-update', latency);
}
handleDisconnection() {
this.isConnected = false;
this.isJoined = false;
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
}
this.emit('disconnected');
// Attempt reconnection
if (this.reconnectAttempts < this.options.maxReconnectAttempts) {
setTimeout(() => {
this.reconnectAttempts++;
this.emit('reconnecting', this.reconnectAttempts);
this.connect();
}, this.options.reconnectDelay);
}
}
flushPendingOperations() {
if (this.pendingOperations.length === 0) return;
console.log(`Sending ${this.pendingOperations.length} pending operations`);
this.pendingOperations.forEach(operation => {
this.sendOperation(operation);
});
this.pendingOperations = [];
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.isConnected) {
this.send({
type: 'heartbeat',
payload: {
clientTime: Date.now()
}
});
}
}, this.options.heartbeatInterval);
}
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
generateOperationId() {
return `${this.userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
getRemoteUsers() {
return Array.from(this.remoteUsers.values());
}
getActiveLocks() {
return Array.from(this.activeLocks.values());
}
getConnectionInfo() {
return {
connectionId: this.connectionId,
documentId: this.documentId,
userId: this.userId,
documentVersion: this.documentVersion,
isConnected: this.isConnected,
isJoined: this.isJoined,
remoteUserCount: this.remoteUsers.size,
activeLockCount: this.activeLocks.size
};
}
async disconnect() {
if (this.isJoined) {
await this.leaveDocument();
}
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
}
if (this.ws) {
this.ws.close();
}
this.emit('disconnected');
}
}
module.exports = CollaborativeMarkdownClient;
Advanced Conflict Resolution Strategies
Sophisticated Operational Transform
Implementing advanced conflict resolution for complex editing scenarios:
// advanced-operational-transform.js - Sophisticated conflict resolution
class AdvancedOperationalTransform {
constructor(options = {}) {
this.options = {
enableContentAwareTransform: options.enableContentAwareTransform !== false,
enableSemanticMerge: options.enableSemanticMerge !== false,
enableIntentionPreservation: options.enableIntentionPreservation !== false,
conflictResolutionStrategy: options.conflictResolutionStrategy || 'automatic',
...options
};
this.transformationRules = new Map();
this.contentAnalyzer = new MarkdownContentAnalyzer();
this.intentionTracker = new IntentionTracker();
this.setupTransformationRules();
}
setupTransformationRules() {
// Register transformation rules for different content types
this.addTransformRule('heading', {
priority: 'high',
mergeStrategy: 'structural-aware',
conflictHandler: this.handleHeadingConflict.bind(this)
});
this.addTransformRule('list', {
priority: 'medium',
mergeStrategy: 'item-aware',
conflictHandler: this.handleListConflict.bind(this)
});
this.addTransformRule('code-block', {
priority: 'high',
mergeStrategy: 'preserve-syntax',
conflictHandler: this.handleCodeBlockConflict.bind(this)
});
this.addTransformRule('link', {
priority: 'medium',
mergeStrategy: 'url-aware',
conflictHandler: this.handleLinkConflict.bind(this)
});
this.addTransformRule('table', {
priority: 'high',
mergeStrategy: 'cell-aware',
conflictHandler: this.handleTableConflict.bind(this)
});
}
addTransformRule(contentType, rule) {
this.transformationRules.set(contentType, rule);
}
async transform(op1, op2, context = {}) {
const analysis = await this.analyzeOperations(op1, op2, context);
if (this.options.enableContentAwareTransform) {
return this.contentAwareTransform(op1, op2, analysis);
} else {
return this.basicTransform(op1, op2);
}
}
async analyzeOperations(op1, op2, context) {
const analysis = {
op1Content: await this.contentAnalyzer.analyze(
context.content, op1.position, op1.length || op1.content?.length || 0
),
op2Content: await this.contentAnalyzer.analyze(
context.content, op2.position, op2.length || op2.content?.length || 0
),
conflictType: this.detectConflictType(op1, op2),
semanticContext: await this.extractSemanticContext(context.content, op1, op2),
userIntentions: this.intentionTracker.analyzeIntentions(op1, op2, context)
};
return analysis;
}
detectConflictType(op1, op2) {
// Determine the type of conflict between operations
if (this.operationsOverlap(op1, op2)) {
if (op1.type === 'delete' && op2.type === 'delete') {
return 'concurrent-deletion';
} else if (op1.type === 'insert' && op2.type === 'delete') {
return 'insert-delete-conflict';
} else if (op1.type === 'delete' && op2.type === 'insert') {
return 'delete-insert-conflict';
} else if (op1.type === 'insert' && op2.type === 'insert') {
return 'concurrent-insertion';
} else {
return 'complex-overlap';
}
}
return 'no-conflict';
}
async contentAwareTransform(op1, op2, analysis) {
const { op1Content, op2Content, conflictType, semanticContext } = analysis;
// Apply content-specific transformation rules
if (op1Content.type && this.transformationRules.has(op1Content.type)) {
const rule = this.transformationRules.get(op1Content.type);
return await rule.conflictHandler(op1, op2, analysis);
}
if (op2Content.type && this.transformationRules.has(op2Content.type)) {
const rule = this.transformationRules.get(op2Content.type);
return await rule.conflictHandler(op2, op1, analysis);
}
// Apply semantic-aware transformations
if (this.options.enableSemanticMerge) {
return this.semanticTransform(op1, op2, analysis);
}
// Fallback to basic transformation
return this.basicTransform(op1, op2);
}
async handleHeadingConflict(op1, op2, analysis) {
const { conflictType, semanticContext } = analysis;
if (conflictType === 'concurrent-insertion' &&
op1.content && op2.content &&
op1.content.startsWith('#') && op2.content.startsWith('#')) {
// Both operations are inserting headings
const level1 = this.extractHeadingLevel(op1.content);
const level2 = this.extractHeadingLevel(op2.content);
const text1 = this.extractHeadingText(op1.content);
const text2 = this.extractHeadingText(op2.content);
if (level1 === level2 && text1 !== text2) {
// Same level, different text - merge with conflict markers
const mergedHeading = `${'#'.repeat(level1)} ${text1} / ${text2}\n`;
return {
...op1,
content: mergedHeading,
metadata: {
conflictResolved: true,
strategy: 'heading-merge',
originalOperations: [op1, op2]
}
};
}
}
return this.basicTransform(op1, op2);
}
async handleListConflict(op1, op2, analysis) {
const { conflictType, semanticContext } = analysis;
if (conflictType === 'concurrent-insertion') {
const item1 = this.extractListItem(op1.content);
const item2 = this.extractListItem(op2.content);
if (item1 && item2) {
// Merge list items intelligently
const mergedItems = this.mergeListItems([item1, item2]);
return {
...op1,
content: mergedItems,
metadata: {
conflictResolved: true,
strategy: 'list-item-merge'
}
};
}
}
return this.basicTransform(op1, op2);
}
async handleCodeBlockConflict(op1, op2, analysis) {
const { conflictType } = analysis;
// Code blocks require careful handling to preserve syntax
if (conflictType === 'concurrent-insertion' || conflictType === 'complex-overlap') {
// Attempt to merge if both operations are adding to the same code block
if (this.areInSameCodeBlock(op1, op2, analysis.semanticContext)) {
return this.mergeCodeBlockOperations(op1, op2, analysis);
}
// For conflicting code changes, prefer the most recent or flag for manual resolution
return {
...op1,
metadata: {
conflictRequiresManualResolution: true,
conflictType: 'code-block-conflict',
alternativeOperation: op2
}
};
}
return this.basicTransform(op1, op2);
}
async handleTableConflict(op1, op2, analysis) {
const { conflictType, semanticContext } = analysis;
// Table conflicts need cell-level resolution
if (this.areInSameTable(op1, op2, semanticContext)) {
const cellInfo1 = this.extractTableCellInfo(op1, semanticContext);
const cellInfo2 = this.extractTableCellInfo(op2, semanticContext);
if (cellInfo1.row !== cellInfo2.row || cellInfo1.column !== cellInfo2.column) {
// Different cells, operations can coexist
return this.basicTransform(op1, op2);
} else {
// Same cell, need to merge or choose
return this.resolveCellConflict(op1, op2, cellInfo1);
}
}
return this.basicTransform(op1, op2);
}
async semanticTransform(op1, op2, analysis) {
const { semanticContext, userIntentions } = analysis;
// Use semantic understanding to make better transformation decisions
if (userIntentions.op1Intent === 'formatting' && userIntentions.op2Intent === 'content') {
// Formatting changes typically don't conflict with content changes
return this.mergeFormattingAndContent(op1, op2);
}
if (userIntentions.op1Intent === 'structure' && userIntentions.op2Intent === 'structure') {
// Structural changes need careful coordination
return this.mergeStructuralChanges(op1, op2, semanticContext);
}
return this.basicTransform(op1, op2);
}
basicTransform(op1, op2) {
// Standard operational transform algorithm
if (op1.type === 'insert' && op2.type === 'insert') {
return this.transformInsertInsert(op1, op2);
} else if (op1.type === 'insert' && op2.type === 'delete') {
return this.transformInsertDelete(op1, op2);
} else if (op1.type === 'delete' && op2.type === 'insert') {
return this.transformDeleteInsert(op1, op2);
} else if (op1.type === 'delete' && op2.type === 'delete') {
return this.transformDeleteDelete(op1, op2);
}
return op1;
}
transformInsertInsert(op1, op2) {
if (op1.position <= op2.position) {
return op1;
} else {
return {
...op1,
position: op1.position + (op2.content?.length || 0)
};
}
}
transformInsertDelete(op1, op2) {
if (op1.position <= op2.position) {
return op1;
} else if (op1.position >= op2.position + op2.length) {
return {
...op1,
position: op1.position - op2.length
};
} else {
return {
...op1,
position: op2.position
};
}
}
transformDeleteInsert(op1, op2) {
if (op1.position >= op2.position) {
return {
...op1,
position: op1.position + (op2.content?.length || 0)
};
}
return op1;
}
transformDeleteDelete(op1, op2) {
if (op1.position >= op2.position + op2.length) {
return {
...op1,
position: op1.position - op2.length
};
} else if (op1.position + op1.length <= op2.position) {
return op1;
} else {
// Handle overlapping deletes
const start1 = op1.position;
const end1 = op1.position + op1.length;
const start2 = op2.position;
const end2 = op2.position + op2.length;
const overlapStart = Math.max(start1, start2);
const overlapEnd = Math.min(end1, end2);
const overlapLength = Math.max(0, overlapEnd - overlapStart);
return {
...op1,
position: Math.min(start1, start2),
length: Math.max(0, op1.length - overlapLength)
};
}
}
operationsOverlap(op1, op2) {
const start1 = op1.position;
const end1 = op1.position + (op1.length || op1.content?.length || 0);
const start2 = op2.position;
const end2 = op2.position + (op2.length || op2.content?.length || 0);
return start1 < end2 && start2 < end1;
}
extractHeadingLevel(content) {
const match = content.match(/^(#+)/);
return match ? match[1].length : 1;
}
extractHeadingText(content) {
return content.replace(/^#+\s*/, '').trim();
}
extractListItem(content) {
const match = content.match(/^(\s*)([-*+]|\d+\.)\s*(.+)/);
return match ? {
indent: match[1],
marker: match[2],
text: match[3]
} : null;
}
mergeListItems(items) {
// Merge list items intelligently
return items.map(item => `${item.indent}${item.marker} ${item.text}`).join('\n');
}
areInSameCodeBlock(op1, op2, semanticContext) {
// Check if operations are within the same code block
return semanticContext.codeBlocks?.some(block =>
this.isPositionInRange(op1.position, block.range) &&
this.isPositionInRange(op2.position, block.range)
);
}
areInSameTable(op1, op2, semanticContext) {
// Check if operations are within the same table
return semanticContext.tables?.some(table =>
this.isPositionInRange(op1.position, table.range) &&
this.isPositionInRange(op2.position, table.range)
);
}
isPositionInRange(position, range) {
return position >= range.start && position <= range.end;
}
async extractSemanticContext(content, op1, op2) {
// Extract semantic information about the content around the operations
return this.contentAnalyzer.extractSemanticContext(content, [op1.position, op2.position]);
}
mergeCodeBlockOperations(op1, op2, analysis) {
// Attempt to merge operations within a code block
// This is simplified - in practice would need more sophisticated logic
if (op1.type === 'insert' && op2.type === 'insert') {
// Try to merge insertions in a way that preserves code syntax
return {
...op1,
metadata: {
mergedOperation: true,
requiresSyntaxCheck: true
}
};
}
return this.basicTransform(op1, op2);
}
extractTableCellInfo(operation, semanticContext) {
// Extract which table cell an operation affects
// This is a placeholder - would need proper table parsing
return {
row: 0,
column: 0,
table: null
};
}
resolveCellConflict(op1, op2, cellInfo) {
// Resolve conflicts within the same table cell
return {
...op1,
metadata: {
cellConflictResolved: true,
conflictStrategy: 'last-writer-wins' // or other strategies
}
};
}
mergeFormattingAndContent(formattingOp, contentOp) {
// Merge formatting operation with content operation
return this.basicTransform(formattingOp, contentOp);
}
mergeStructuralChanges(op1, op2, semanticContext) {
// Handle conflicts between structural changes
return this.basicTransform(op1, op2);
}
}
// Content analyzer for semantic understanding
class MarkdownContentAnalyzer {
async analyze(content, position, length) {
const context = this.extractContext(content, position, length);
return {
type: this.detectContentType(context),
structure: this.analyzeStructure(context),
formatting: this.analyzeFormatting(context),
semantics: this.analyzeSemantics(context)
};
}
extractContext(content, position, length) {
const start = Math.max(0, position - 100);
const end = Math.min(content.length, position + length + 100);
return {
before: content.slice(start, position),
target: content.slice(position, position + length),
after: content.slice(position + length, end),
full: content.slice(start, end)
};
}
detectContentType(context) {
const { before, target, after, full } = context;
// Detect if we're in a heading
if (/^#+\s/.test(target) || /\n#+\s/.test(before)) {
return 'heading';
}
// Detect if we're in a list
if (/^(\s*)([-*+]|\d+\.)\s/.test(target) || /\n(\s*)([-*+]|\d+\.)\s/.test(before)) {
return 'list';
}
// Detect if we're in a code block
if (/```/.test(before) && /```/.test(after)) {
return 'code-block';
}
// Detect if we're in inline code
if (/`[^`]*$/.test(before) && /^[^`]*`/.test(after)) {
return 'inline-code';
}
// Detect if we're in a link
if (/\[([^\]]*$)/.test(before) && /^([^\]]*)\]\([^)]*\)/.test(after)) {
return 'link';
}
// Detect if we're in a table
if (/\|/.test(full)) {
return 'table';
}
return 'text';
}
analyzeStructure(context) {
// Analyze structural elements
return {
indentLevel: this.calculateIndentLevel(context.before),
blockType: this.identifyBlockType(context.full),
nesting: this.analyzeNesting(context.full)
};
}
analyzeFormatting(context) {
// Analyze formatting elements
return {
bold: /\*\*/.test(context.full),
italic: /\*/.test(context.full),
emphasis: /_/.test(context.full),
strikethrough: /~~/.test(context.full)
};
}
analyzeSemantics(context) {
// Basic semantic analysis
return {
purpose: this.inferPurpose(context.full),
importance: this.assessImportance(context.full),
relationships: this.identifyRelationships(context.full)
};
}
async extractSemanticContext(content, positions) {
// Extract semantic context around multiple positions
const context = {
headings: this.findHeadings(content),
lists: this.findLists(content),
codeBlocks: this.findCodeBlocks(content),
tables: this.findTables(content),
links: this.findLinks(content)
};
return context;
}
findHeadings(content) {
const headings = [];
const lines = content.split('\n');
lines.forEach((line, index) => {
const match = line.match(/^(#+)\s+(.+)/);
if (match) {
headings.push({
level: match[1].length,
text: match[2],
line: index,
range: this.calculateLineRange(content, index)
});
}
});
return headings;
}
findLists(content) {
// Find list structures
const lists = [];
const lines = content.split('\n');
let currentList = null;
lines.forEach((line, index) => {
const match = line.match(/^(\s*)([-*+]|\d+\.)\s+(.+)/);
if (match) {
const indent = match[1].length;
if (!currentList || currentList.indent !== indent) {
if (currentList) {
lists.push(currentList);
}
currentList = {
type: /\d+\./.test(match[2]) ? 'ordered' : 'unordered',
indent,
items: [],
startLine: index,
range: { start: this.calculateLineRange(content, index).start, end: 0 }
};
}
currentList.items.push({
text: match[3],
line: index
});
} else if (currentList && line.trim() === '') {
// Continue current list
} else if (currentList) {
// End current list
currentList.range.end = this.calculateLineRange(content, index - 1).end;
lists.push(currentList);
currentList = null;
}
});
if (currentList) {
currentList.range.end = content.length;
lists.push(currentList);
}
return lists;
}
findCodeBlocks(content) {
const codeBlocks = [];
const lines = content.split('\n');
let inCodeBlock = false;
let currentBlock = null;
lines.forEach((line, index) => {
if (line.startsWith('```')) {
if (!inCodeBlock) {
// Start of code block
inCodeBlock = true;
currentBlock = {
language: line.substring(3).trim(),
startLine: index,
range: { start: this.calculateLineRange(content, index).start, end: 0 },
content: []
};
} else {
// End of code block
inCodeBlock = false;
currentBlock.range.end = this.calculateLineRange(content, index).end;
codeBlocks.push(currentBlock);
currentBlock = null;
}
} else if (inCodeBlock) {
currentBlock.content.push(line);
}
});
return codeBlocks;
}
findTables(content) {
// Simple table detection
const tables = [];
const lines = content.split('\n');
let inTable = false;
let currentTable = null;
lines.forEach((line, index) => {
if (line.includes('|') && line.trim() !== '') {
if (!inTable) {
inTable = true;
currentTable = {
startLine: index,
range: { start: this.calculateLineRange(content, index).start, end: 0 },
rows: []
};
}
currentTable.rows.push(line.trim());
} else if (inTable && line.trim() === '') {
// Continue table (empty line)
} else if (inTable) {
// End table
inTable = false;
currentTable.range.end = this.calculateLineRange(content, index - 1).end;
tables.push(currentTable);
currentTable = null;
}
});
if (currentTable) {
currentTable.range.end = content.length;
tables.push(currentTable);
}
return tables;
}
findLinks(content) {
const links = [];
const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g;
let match;
while ((match = linkPattern.exec(content)) !== null) {
links.push({
text: match[1],
url: match[2],
position: match.index,
length: match[0].length
});
}
return links;
}
calculateLineRange(content, lineIndex) {
const lines = content.split('\n');
let start = 0;
for (let i = 0; i < lineIndex; i++) {
start += lines[i].length + 1; // +1 for newline
}
return {
start,
end: start + (lines[lineIndex] ? lines[lineIndex].length : 0)
};
}
calculateIndentLevel(text) {
const match = text.match(/\n(\s*)$/);
return match ? match[1].length : 0;
}
identifyBlockType(text) {
// Simple block type identification
if (text.includes('```')) return 'code-block';
if (text.includes('|')) return 'table';
if (/^\s*[-*+]\s/.test(text)) return 'list';
if (/^\s*\d+\.\s/.test(text)) return 'ordered-list';
if (/^#+\s/.test(text)) return 'heading';
return 'paragraph';
}
analyzeNesting(text) {
// Analyze nesting level and structure
return {
level: this.calculateIndentLevel(text),
type: 'flat' // simplified
};
}
inferPurpose(text) {
// Simple purpose inference
if (text.includes('TODO') || text.includes('FIXME')) return 'task';
if (text.includes('```')) return 'code-example';
if (text.includes('http')) return 'reference';
return 'content';
}
assessImportance(text) {
// Simple importance assessment
if (/^#+\s/.test(text)) return 'high';
if (/\*\*/.test(text)) return 'medium';
return 'normal';
}
identifyRelationships(text) {
// Identify relationships with other content
return {
references: this.findReferences(text),
dependencies: this.findDependencies(text)
};
}
findReferences(text) {
// Find references to other sections, files, etc.
const references = [];
// Look for internal links
const internalLinkPattern = /\[([^\]]+)\]\(#([^)]+)\)/g;
let match;
while ((match = internalLinkPattern.exec(text)) !== null) {
references.push({
type: 'internal',
target: match[2],
text: match[1]
});
}
return references;
}
findDependencies(text) {
// Find content that this depends on
return []; // Simplified
}
}
// Intention tracker for understanding user goals
class IntentionTracker {
analyzeIntentions(op1, op2, context) {
return {
op1Intent: this.classifyIntent(op1, context),
op2Intent: this.classifyIntent(op2, context),
conflictLikelihood: this.assessConflictLikelihood(op1, op2),
userGoals: this.inferUserGoals(op1, op2, context)
};
}
classifyIntent(operation, context) {
// Classify the intention behind an operation
if (operation.type === 'insert' && operation.content) {
if (/^#+\s/.test(operation.content)) return 'structure';
if (/\*\*/.test(operation.content)) return 'formatting';
if (/```/.test(operation.content)) return 'code-example';
return 'content';
} else if (operation.type === 'delete') {
return 'editing';
}
return 'unknown';
}
assessConflictLikelihood(op1, op2) {
// Assess how likely the operations are to conflict
if (Math.abs(op1.position - op2.position) < 10) return 'high';
if (Math.abs(op1.position - op2.position) < 100) return 'medium';
return 'low';
}
inferUserGoals(op1, op2, context) {
// Infer what users are trying to accomplish
return {
op1Goal: this.inferGoal(op1, context),
op2Goal: this.inferGoal(op2, context)
};
}
inferGoal(operation, context) {
// Simple goal inference
if (operation.content && operation.content.includes('TODO')) return 'task-management';
if (operation.content && /^#+\s/.test(operation.content)) return 'organization';
if (operation.type === 'delete') return 'cleanup';
return 'content-creation';
}
}
module.exports = { AdvancedOperationalTransform, MarkdownContentAnalyzer, IntentionTracker };
Integration with Modern Development Workflows
Collaborative Markdown editing integrates seamlessly with comprehensive documentation ecosystems. When combined with automated workflow systems and content generation, real-time collaboration enables teams to work simultaneously on automated content pipelines while maintaining synchronized editing experiences across complex documentation projects.
For sophisticated team management, collaborative editing works effectively with version control and Git integration systems by providing real-time editing capabilities that complement traditional version control workflows, enabling teams to collaborate in real-time while maintaining comprehensive change history and branch management.
When building scalable documentation platforms, collaborative editing complements dynamic content generation systems by allowing teams to collaboratively edit templates, data sources, and generated content simultaneously, creating dynamic workflows where content generation and human collaboration work together seamlessly.
Advanced Collaboration Features
User Presence and Awareness
Implementing sophisticated user presence systems for enhanced collaboration:
// collaborative-presence-system.js - Advanced user presence and awareness
class CollaborativePresenceSystem {
constructor(collaborativeEngine) {
this.engine = collaborativeEngine;
this.presenceData = new Map(); // userId -> PresenceInfo
this.awarenessFeatures = new Map();
this.activityTrackers = new Map();
this.setupPresenceFeatures();
this.setupAwarenessTracking();
// Listen to engine events
this.engine.on('user-joined', (data) => this.handleUserJoined(data));
this.engine.on('user-left', (data) => this.handleUserLeft(data));
this.engine.on('document-changed', (data) => this.handleDocumentChanged(data));
}
setupPresenceFeatures() {
// Real-time cursor tracking
this.awarenessFeatures.set('cursors', {
enabled: true,
updateInterval: 100,
showCursorLabels: true,
showTypingIndicator: true
});
// Selection highlighting
this.awarenessFeatures.set('selections', {
enabled: true,
highlightColor: true,
showSelectionUser: true,
fadeTimeout: 3000
});
// Activity indicators
this.awarenessFeatures.set('activity', {
enabled: true,
showActiveUsers: true,
showLastActivity: true,
idleTimeout: 300000 // 5 minutes
});
// Focus tracking
this.awarenessFeatures.set('focus', {
enabled: true,
showFocusedSection: true,
highlightActiveSection: true
});
// Typing awareness
this.awarenessFeatures.set('typing', {
enabled: true,
showTypingUsers: true,
typingTimeout: 2000,
showTypingLocation: true
});
}
setupAwarenessTracking() {
// Set up periodic presence updates
setInterval(() => {
this.updatePresenceData();
this.cleanupIdleUsers();
}, 5000);
// Set up typing indicator cleanup
setInterval(() => {
this.cleanupTypingIndicators();
}, 1000);
}
handleUserJoined(data) {
const { documentId, userId, userInfo } = data;
this.presenceData.set(userId, {
userId,
userInfo,
documentId,
joinedAt: Date.now(),
lastActivity: Date.now(),
cursor: { line: 0, column: 0 },
selection: null,
isTyping: false,
typingAt: null,
focusedSection: null,
isActive: true,
isIdle: false
});
this.broadcastPresenceUpdate(documentId);
}
handleUserLeft(data) {
const { userId } = data;
this.presenceData.delete(userId);
if (data.documentId) {
this.broadcastPresenceUpdate(data.documentId);
}
}
handleDocumentChanged(data) {
const { documentId, author } = data;
// Update author's activity
const presence = this.presenceData.get(author);
if (presence) {
presence.lastActivity = Date.now();
presence.isActive = true;
presence.isIdle = false;
}
this.broadcastPresenceUpdate(documentId);
}
updateCursor(userId, documentId, cursor) {
const presence = this.presenceData.get(userId);
if (presence && presence.documentId === documentId) {
presence.cursor = cursor;
presence.lastActivity = Date.now();
// Broadcast cursor update
this.engine.broadcastToDocument(documentId, {
type: 'presence-update',
subtype: 'cursor',
userId,
cursor,
timestamp: Date.now()
}, this.getConnectionId(userId));
}
}
updateSelection(userId, documentId, selection) {
const presence = this.presenceData.get(userId);
if (presence && presence.documentId === documentId) {
presence.selection = selection;
presence.lastActivity = Date.now();
// Broadcast selection update
this.engine.broadcastToDocument(documentId, {
type: 'presence-update',
subtype: 'selection',
userId,
selection,
userInfo: presence.userInfo,
timestamp: Date.now()
}, this.getConnectionId(userId));
}
}
updateTypingStatus(userId, documentId, isTyping, position = null) {
const presence = this.presenceData.get(userId);
if (presence && presence.documentId === documentId) {
const wasTyping = presence.isTyping;
presence.isTyping = isTyping;
presence.typingAt = isTyping ? (position || presence.cursor) : null;
presence.lastActivity = Date.now();
// Only broadcast if typing status changed
if (wasTyping !== isTyping) {
this.engine.broadcastToDocument(documentId, {
type: 'presence-update',
subtype: 'typing',
userId,
isTyping,
typingAt: presence.typingAt,
userInfo: presence.userInfo,
timestamp: Date.now()
}, this.getConnectionId(userId));
}
}
}
updateFocusedSection(userId, documentId, section) {
const presence = this.presenceData.get(userId);
if (presence && presence.documentId === documentId) {
presence.focusedSection = section;
presence.lastActivity = Date.now();
this.engine.broadcastToDocument(documentId, {
type: 'presence-update',
subtype: 'focus',
userId,
focusedSection: section,
userInfo: presence.userInfo,
timestamp: Date.now()
}, this.getConnectionId(userId));
}
}
updatePresenceData() {
const now = Date.now();
this.presenceData.forEach((presence, userId) => {
const timeSinceActivity = now - presence.lastActivity;
// Update idle status
const wasIdle = presence.isIdle;
presence.isIdle = timeSinceActivity > this.awarenessFeatures.get('activity').idleTimeout;
// Update active status
presence.isActive = timeSinceActivity < 60000; // Active within 1 minute
// Broadcast if idle status changed
if (wasIdle !== presence.isIdle) {
this.engine.broadcastToDocument(presence.documentId, {
type: 'presence-update',
subtype: 'idle',
userId,
isIdle: presence.isIdle,
userInfo: presence.userInfo,
timestamp: now
}, this.getConnectionId(userId));
}
});
}
cleanupIdleUsers() {
const now = Date.now();
const maxIdleTime = 3600000; // 1 hour
const idleUsers = [];
this.presenceData.forEach((presence, userId) => {
if (now - presence.lastActivity > maxIdleTime) {
idleUsers.push(userId);
}
});
// Remove idle users
idleUsers.forEach(userId => {
const presence = this.presenceData.get(userId);
this.presenceData.delete(userId);
if (presence) {
this.engine.broadcastToDocument(presence.documentId, {
type: 'user-idle-removed',
userId,
reason: 'idle-timeout'
});
}
});
}
cleanupTypingIndicators() {
const now = Date.now();
const typingTimeout = this.awarenessFeatures.get('typing').typingTimeout;
this.presenceData.forEach((presence, userId) => {
if (presence.isTyping && presence.lastActivity < now - typingTimeout) {
this.updateTypingStatus(userId, presence.documentId, false);
}
});
}
broadcastPresenceUpdate(documentId) {
const documentUsers = Array.from(this.presenceData.values())
.filter(p => p.documentId === documentId);
const presenceSummary = {
totalUsers: documentUsers.length,
activeUsers: documentUsers.filter(p => p.isActive).length,
typingUsers: documentUsers.filter(p => p.isTyping),
users: documentUsers.map(p => ({
userId: p.userId,
userInfo: p.userInfo,
cursor: p.cursor,
selection: p.selection,
isTyping: p.isTyping,
typingAt: p.typingAt,
focusedSection: p.focusedSection,
isActive: p.isActive,
isIdle: p.isIdle,
lastActivity: p.lastActivity
}))
};
this.engine.broadcastToDocument(documentId, {
type: 'presence-summary',
presence: presenceSummary,
timestamp: Date.now()
});
}
getConnectionId(userId) {
// Helper to get connection ID from user ID
for (const [connectionId, connection] of this.engine.connections) {
if (connection.userId === userId) {
return connectionId;
}
}
return null;
}
generateUserAwarenessData(documentId) {
const documentUsers = Array.from(this.presenceData.values())
.filter(p => p.documentId === documentId);
return {
activeUsers: documentUsers.filter(p => p.isActive),
typingUsers: documentUsers.filter(p => p.isTyping),
idleUsers: documentUsers.filter(p => p.isIdle),
cursorPositions: documentUsers.map(p => ({
userId: p.userId,
userInfo: p.userInfo,
cursor: p.cursor,
selection: p.selection
})),
focusAreas: documentUsers
.filter(p => p.focusedSection)
.map(p => ({
userId: p.userId,
userInfo: p.userInfo,
section: p.focusedSection
})),
collaborationMetrics: this.generateCollaborationMetrics(documentUsers)
};
}
generateCollaborationMetrics(users) {
const now = Date.now();
return {
totalCollaborators: users.length,
currentlyActive: users.filter(u => u.isActive).length,
currentlyTyping: users.filter(u => u.isTyping).length,
averageSessionDuration: users.length > 0 ?
users.reduce((sum, u) => sum + (now - u.joinedAt), 0) / users.length : 0,
mostActiveUser: users.reduce((most, current) =>
!most || (now - current.lastActivity) < (now - most.lastActivity) ? current : most, null
),
collaborationHotspots: this.identifyCollaborationHotspots(users),
concurrentEditing: this.detectConcurrentEditing(users)
};
}
identifyCollaborationHotspots(users) {
// Identify areas where multiple users are working
const hotspots = new Map();
users.forEach(user => {
if (user.cursor) {
const line = user.cursor.line;
const region = Math.floor(line / 10) * 10; // Group by 10-line regions
if (!hotspots.has(region)) {
hotspots.set(region, []);
}
hotspots.get(region).push(user);
}
});
// Return regions with multiple users
return Array.from(hotspots.entries())
.filter(([region, userList]) => userList.length > 1)
.map(([region, userList]) => ({
region: { startLine: region, endLine: region + 9 },
users: userList.map(u => ({ userId: u.userId, userInfo: u.userInfo }))
}));
}
detectConcurrentEditing(users) {
// Detect when users are editing in close proximity
const activeUsers = users.filter(u => u.isActive && u.cursor);
const concurrent = [];
for (let i = 0; i < activeUsers.length; i++) {
for (let j = i + 1; j < activeUsers.length; j++) {
const user1 = activeUsers[i];
const user2 = activeUsers[j];
const distance = Math.abs(user1.cursor.line - user2.cursor.line);
if (distance <= 5) { // Within 5 lines
concurrent.push({
users: [
{ userId: user1.userId, userInfo: user1.userInfo, cursor: user1.cursor },
{ userId: user2.userId, userInfo: user2.userInfo, cursor: user2.cursor }
],
distance,
conflictRisk: distance <= 2 ? 'high' : 'medium'
});
}
}
}
return concurrent;
}
getPresenceForUser(userId) {
return this.presenceData.get(userId) || null;
}
getDocumentPresence(documentId) {
return Array.from(this.presenceData.values())
.filter(p => p.documentId === documentId);
}
enableFeature(featureName, enabled = true) {
const feature = this.awarenessFeatures.get(featureName);
if (feature) {
feature.enabled = enabled;
}
}
configureFeature(featureName, config) {
const feature = this.awarenessFeatures.get(featureName);
if (feature) {
Object.assign(feature, config);
}
}
}
module.exports = CollaborativePresenceSystem;
Conclusion
Collaborative Markdown editing with real-time synchronization represents a fundamental advancement in documentation workflows that enables teams to work together seamlessly while maintaining document integrity and quality. By implementing sophisticated operational transformation algorithms, conflict resolution mechanisms, and user presence systems, organizations can create collaborative editing environments that support productive parallel workflows without sacrificing the simplicity and portability that makes Markdown such an effective content creation format.
The key to successful collaborative editing lies in balancing real-time responsiveness with conflict prevention, ensuring that automated synchronization serves user productivity rather than creating complexity. Whether you’re building team documentation platforms, collaborative writing tools, or integrated development environments, the techniques covered in this guide provide the foundation for creating robust, scalable collaborative editing systems that enhance team productivity while maintaining content quality.
Remember to implement comprehensive testing strategies for collaborative scenarios, establish clear conflict resolution policies that align with your team’s workflows, and continuously monitor system performance to ensure that real-time collaboration remains smooth and responsive as usage scales. With proper implementation of advanced collaborative editing techniques, your Markdown documentation workflows can achieve unprecedented levels of team coordination and productivity while preserving the accessibility and maintainability that makes Markdown an ideal choice for collaborative content creation.