Create powerful collaborative editing experiences where multiple users can work together on documents, code, designs, and more in real-time using Multisynq’s deterministic synchronization.

Core Architecture

Model-View Collaboration Pattern

Multisynq collaborative editing uses the Model-View pattern where:

  • Model: Manages document state, handles edits, and ensures consistency
  • View: Handles user input, displays content, and shows user presence
  • Events: Coordinate all changes between users

User Awareness System

Track active users and their cursors automatically:

class CollaborativeModel extends Multisynq.Model {
    init() {
        this.document = { content: "", cursors: new Map() };
        this.users = new Map();
        
        // Built-in user lifecycle events
        this.subscribe(this.sessionId, "view-join", this.handleUserJoin);
        this.subscribe(this.sessionId, "view-exit", this.handleUserLeave);
        
        // Custom collaboration events
        this.subscribe(this.sessionId, "updateCursor", this.handleCursorUpdate);
        this.subscribe(this.sessionId, "textEdit", this.handleTextEdit);
    }
    
    handleUserJoin(viewId) {
        this.users.set(viewId, {
            id: viewId,
            name: this.generateUserName(),
            color: this.getRandomColor(),
            joinedAt: this.now()
        });
        
        this.publish(this.sessionId, "usersChanged", Array.from(this.users.values()));
    }
    
    handleUserLeave(viewId) {
        this.users.delete(viewId);
        this.document.cursors.delete(viewId);
        this.publish(this.sessionId, "usersChanged", Array.from(this.users.values()));
    }
    
    getRandomColor() {
        const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD'];
        return colors[Math.floor(this.random() * colors.length)];
    }
    
    generateUserName() {
        const adjectives = ['Quick', 'Bright', 'Creative', 'Smart', 'Cool'];
        const nouns = ['Writer', 'Editor', 'Author', 'Coder', 'Designer'];
        const adj = adjectives[Math.floor(this.random() * adjectives.length)];
        const noun = nouns[Math.floor(this.random() * nouns.length)];
        return `${adj} ${noun}`;
    }
}

Cursor and Selection Tracking

Real-time Cursor Sharing

class CursorModel extends Multisynq.Model {
    init() {
        this.cursors = new Map();
        this.subscribe(this.sessionId, "cursorMove", this.handleCursorMove);
        this.subscribe(this.sessionId, "selectionChange", this.handleSelectionChange);
    }
    
    handleCursorMove({ viewId, position }) {
        const cursor = this.cursors.get(viewId) || {};
        cursor.position = position;
        cursor.lastUpdate = this.now();
        this.cursors.set(viewId, cursor);
        
        this.publish(this.sessionId, "cursorsUpdated", this.getCursorsArray());
    }
    
    handleSelectionChange({ viewId, selection }) {
        const cursor = this.cursors.get(viewId) || {};
        cursor.selection = selection;
        cursor.lastUpdate = this.now();
        this.cursors.set(viewId, cursor);
        
        this.publish(this.sessionId, "cursorsUpdated", this.getCursorsArray());
    }
    
    getCursorsArray() {
        return Array.from(this.cursors.entries()).map(([viewId, cursor]) => ({
            viewId,
            ...cursor
        }));
    }
}

Cursor View Implementation

class CursorView extends Multisynq.View {
    constructor(model) {
        super(model);
        this.editor = document.getElementById('editor');
        this.cursorOverlay = document.getElementById('cursor-overlay');
        
        this.subscribe(this.sessionId, "cursorsUpdated", this.renderCursors);
        this.setupCursorTracking();
    }
    
    setupCursorTracking() {
        this.editor.addEventListener('mouseup', () => this.sendCursorPosition());
        this.editor.addEventListener('keyup', () => this.sendCursorPosition());
        this.editor.addEventListener('selectionchange', () => this.sendSelection());
    }
    
    sendCursorPosition() {
        const selection = window.getSelection();
        if (selection.rangeCount > 0) {
            const range = selection.getRangeAt(0);
            const position = this.getPositionFromRange(range);
            
            this.publish(this.sessionId, "cursorMove", {
                viewId: this.viewId,
                position: position
            });
        }
    }
    
    sendSelection() {
        const selection = window.getSelection();
        if (selection.rangeCount > 0) {
            const range = selection.getRangeAt(0);
            const selectionData = {
                start: this.getPositionFromRange({ 
                    startContainer: range.startContainer, 
                    startOffset: range.startOffset 
                }),
                end: this.getPositionFromRange({ 
                    endContainer: range.endContainer, 
                    endOffset: range.endOffset 
                })
            };
            
            this.publish(this.sessionId, "selectionChange", {
                viewId: this.viewId,
                selection: selectionData
            });
        }
    }
    
    renderCursors(cursors) {
        // Clear existing cursors
        this.cursorOverlay.innerHTML = '';
        
        cursors.forEach(cursor => {
            if (cursor.viewId !== this.viewId) {
                this.renderRemoteCursor(cursor);
            }
        });
    }
    
    renderRemoteCursor(cursor) {
        const cursorElement = document.createElement('div');
        cursorElement.className = 'remote-cursor';
        cursorElement.style.backgroundColor = cursor.color || '#999';
        
        // Position the cursor element
        const position = this.getElementPosition(cursor.position);
        cursorElement.style.left = position.x + 'px';
        cursorElement.style.top = position.y + 'px';
        
        this.cursorOverlay.appendChild(cursorElement);
    }
}

Text Editing with Operational Transformation

Document Model with Edit History

class DocumentModel extends Multisynq.Model {
    init() {
        this.content = "";
        this.operations = [];
        this.version = 0;
        
        this.subscribe(this.sessionId, "insertText", this.handleInsert);
        this.subscribe(this.sessionId, "deleteText", this.handleDelete);
        this.subscribe(this.sessionId, "replaceText", this.handleReplace);
    }
    
    handleInsert({ viewId, position, text, version }) {
        // Simple operational transformation
        const adjustedPosition = this.transformPosition(position, version);
        
        const operation = {
            id: this.generateOperationId(),
            type: 'insert',
            position: adjustedPosition,
            text: text,
            viewId: viewId,
            version: this.version,
            timestamp: this.now()
        };
        
        this.applyOperation(operation);
    }
    
    handleDelete({ viewId, start, end, version }) {
        const adjustedStart = this.transformPosition(start, version);
        const adjustedEnd = this.transformPosition(end, version);
        
        const operation = {
            id: this.generateOperationId(),
            type: 'delete',
            start: adjustedStart,
            end: adjustedEnd,
            deletedText: this.content.substring(adjustedStart, adjustedEnd),
            viewId: viewId,
            version: this.version,
            timestamp: this.now()
        };
        
        this.applyOperation(operation);
    }
    
    applyOperation(operation) {
        switch (operation.type) {
            case 'insert':
                this.content = 
                    this.content.slice(0, operation.position) + 
                    operation.text + 
                    this.content.slice(operation.position);
                break;
                
            case 'delete':
                this.content = 
                    this.content.slice(0, operation.start) + 
                    this.content.slice(operation.end);
                break;
        }
        
        this.operations.push(operation);
        this.version++;
        
        this.publish(this.sessionId, "documentUpdated", {
            content: this.content,
            operation: operation,
            version: this.version
        });
    }
    
    transformPosition(position, fromVersion) {
        // Simple position transformation based on intervening operations
        let adjustedPosition = position;
        
        for (let i = fromVersion; i < this.operations.length; i++) {
            const op = this.operations[i];
            if (op.type === 'insert' && op.position <= adjustedPosition) {
                adjustedPosition += op.text.length;
            } else if (op.type === 'delete' && op.start <= adjustedPosition) {
                const deletedLength = op.end - op.start;
                adjustedPosition = Math.max(op.start, adjustedPosition - deletedLength);
            }
        }
        
        return adjustedPosition;
    }
    
    generateOperationId() {
        return `op_${this.now()}_${Math.floor(this.random() * 1000000)}`;
    }
}

Complete Collaborative Editor Example

Basic Text Editor

class SimpleEditorModel extends Multisynq.Model {
    init() {
        this.content = "Welcome to collaborative editing!\n\nStart typing to see real-time sync.";
        this.users = new Map();
        
        this.subscribe(this.sessionId, "view-join", this.handleUserJoin);
        this.subscribe(this.sessionId, "view-exit", this.handleUserLeave);
        this.subscribe(this.sessionId, "contentChange", this.handleContentChange);
    }
    
    handleUserJoin(viewId) {
        this.users.set(viewId, {
            id: viewId,
            name: `User ${this.users.size + 1}`,
            color: this.getRandomColor()
        });
        
        // Send current state to new user
        this.publish(viewId, "initialContent", {
            content: this.content,
            users: Array.from(this.users.values())
        });
        
        // Notify others of new user
        this.publish(this.sessionId, "userJoined", this.users.get(viewId));
    }
    
    handleUserLeave(viewId) {
        const user = this.users.get(viewId);
        this.users.delete(viewId);
        
        if (user) {
            this.publish(this.sessionId, "userLeft", user);
        }
    }
    
    handleContentChange({ viewId, content }) {
        this.content = content;
        
        // Broadcast to all other users
        this.publish(this.sessionId, "contentUpdated", {
            content: this.content,
            authorId: viewId
        });
    }
    
    getRandomColor() {
        const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
        return colors[Math.floor(this.random() * colors.length)];
    }
}

class SimpleEditorView extends Multisynq.View {
    constructor(model) {
        super(model);
        this.textarea = document.getElementById('editor');
        this.userList = document.getElementById('users');
        this.suppressEvents = false;
        
        this.subscribe(this.sessionId, "initialContent", this.handleInitialContent);
        this.subscribe(this.sessionId, "contentUpdated", this.handleContentUpdate);
        this.subscribe(this.sessionId, "userJoined", this.handleUserJoined);
        this.subscribe(this.sessionId, "userLeft", this.handleUserLeft);
        
        this.setupEditor();
    }
    
    setupEditor() {
        this.textarea.addEventListener('input', () => {
            if (!this.suppressEvents) {
                this.publish(this.sessionId, "contentChange", {
                    viewId: this.viewId,
                    content: this.textarea.value
                });
            }
        });
    }
    
    handleInitialContent({ content, users }) {
        this.suppressEvents = true;
        this.textarea.value = content;
        this.suppressEvents = false;
        
        this.updateUserList(users);
    }
    
    handleContentUpdate({ content, authorId }) {
        if (authorId !== this.viewId) {
            const cursorPosition = this.textarea.selectionStart;
            
            this.suppressEvents = true;
            this.textarea.value = content;
            this.textarea.setSelectionRange(cursorPosition, cursorPosition);
            this.suppressEvents = false;
        }
    }
    
    handleUserJoined(user) {
        this.showUserNotification(`${user.name} joined`, user.color);
    }
    
    handleUserLeft(user) {
        this.showUserNotification(`${user.name} left`, '#999');
    }
    
    updateUserList(users) {
        this.userList.innerHTML = '';
        users.forEach(user => {
            const userElement = document.createElement('div');
            userElement.className = 'user-indicator';
            userElement.style.backgroundColor = user.color;
            userElement.textContent = user.name;
            this.userList.appendChild(userElement);
        });
    }
    
    showUserNotification(message, color) {
        const notification = document.createElement('div');
        notification.className = 'notification';
        notification.style.borderLeftColor = color;
        notification.textContent = message;
        
        document.body.appendChild(notification);
        
        setTimeout(() => notification.remove(), 3000);
    }
}

CodeMirror Integration

class CodeMirrorCollabView extends Multisynq.View {
    constructor(model) {
        super(model);
        
        this.editor = CodeMirror.fromTextArea(document.getElementById('code-editor'), {
            lineNumbers: true,
            mode: 'javascript',
            theme: 'default'
        });
        
        this.subscribe(this.sessionId, "codeChange", this.handleRemoteChange);
        this.setupCollaboration();
    }
    
    setupCollaboration() {
        this.editor.on('change', (instance, change) => {
            if (change.origin !== 'remote') {
                this.publish(this.sessionId, "codeEdit", {
                    viewId: this.viewId,
                    change: change,
                    content: this.editor.getValue()
                });
            }
        });
    }
    
    handleRemoteChange({ change, authorId }) {
        if (authorId !== this.viewId) {
            // Apply remote change without triggering local change event
            this.editor.replaceRange(
                change.text.join('\n'),
                change.from,
                change.to,
                'remote'
            );
        }
    }
}

Best Practices

Performance Optimization

  • Debounce rapid changes to reduce event frequency
  • Batch operations when possible
  • Limit history size to prevent memory growth
  • Use efficient diff algorithms for large documents

User Experience

  • Visual feedback for all user actions
  • Smooth cursor animations for better presence awareness
  • Conflict resolution indicators when edits overlap
  • Undo/redo support that respects collaborative changes

Error Handling

class RobustEditorModel extends Multisynq.Model {
    init() {
        this.content = "";
        this.subscribe(this.sessionId, "textEdit", this.handleTextEdit);
    }
    
    handleTextEdit(data) {
        try {
            // Validate edit data
            if (!this.validateEdit(data)) {
                console.warn('Invalid edit received:', data);
                return;
            }
            
            // Apply edit
            this.applyEdit(data);
            
        } catch (error) {
            console.error('Error applying edit:', error);
            
            // Recover by broadcasting current state
            this.publish(this.sessionId, "contentReset", {
                content: this.content,
                reason: "recovery"
            });
        }
    }
    
    validateEdit(data) {
        return data && 
               typeof data.position === 'number' && 
               data.position >= 0 && 
               data.position <= this.content.length;
    }
}

Next Steps