Skip to content

Latest commit

 

History

History
544 lines (453 loc) · 16.8 KB

File metadata and controls

544 lines (453 loc) · 16.8 KB
title Collaborative Editing
description Build real-time collaborative editing experiences with Multisynq's Model-View architecture

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);
    }
}

Integration with Popular Editors

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

Build chat systems with message history and user presence Create collaborative drawing and design tools Deep dive into Model event handling and state management Learn advanced View techniques and UI integration