Skip to main content
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

Real-time Chat

Build chat systems with message history and user presence

Shared Whiteboard

Create collaborative drawing and design tools

Model API Reference

Deep dive into Model event handling and state management

View API Reference

Learn advanced View techniques and UI integration