CRDT Operations & Rendering

Converting CRDT operations to editor operations and applying them for rendering.

Overview

When using a CRDT library (like Yjs, Automerge, or custom CRDT), you receive operations in CRDT format. These operations need to be converted to your editor's operation format, then applied to the model and rendered.

Key points:

  • CRDT operations use position IDs, not path/offset
  • You need to map CRDT positions to editor paths
  • Convert CRDT operations to editor operations
  • Apply operations through your editor's transaction system
  • Handle selection updates when remote operations arrive

CRDT Operation Format

CRDT libraries typically provide operations in a format like this:

// Example CRDT operation from Yjs, Automerge, etc.
interface CRDTOperation {
  type: 'insert' | 'delete' | 'update' | 'format';
  position: PositionId; // Globally unique position identifier
  after?: PositionId; // Insert after this position
  positions?: PositionId[]; // Multiple positions for ranges
  content?: string | Node;
  attributes?: Record<string, any>;
  siteId: string;
  clock: number;
}

// Position identifier (varies by CRDT library)
interface PositionId {
  siteId: string;
  clock: number;
  offset?: number; // For fractional positions
}

// Example: Yjs operation
const yjsOperation = {
  type: 'insert',
  after: { siteId: 'user1', clock: 42 },
  positions: [
    { siteId: 'user2', clock: 100, offset: 0.1 },
    { siteId: 'user2', clock: 100, offset: 0.2 }
  ],
  content: 'hi',
  siteId: 'user2',
  clock: 100
};

// Example: Automerge operation
const automergeOperation = {
  type: 'splice',
  path: ['text', 5], // Different format
  value: 'hello',
  actor: 'user1',
  seq: 42
};

Position Mapping

The core challenge is mapping CRDT position IDs to your editor's path/offset system. You need to maintain a bidirectional mapping.

Position Mapping Strategy

class CRDTPositionMapper {
  // Map CRDT position to editor path/offset
  private crdtToEditor = new Map<string, { path: number[], offset: number }>();
  // Map editor path/offset to CRDT position
  private editorToCrdt = new Map<string, PositionId>();
  
  // Convert CRDT position to editor path
  toEditorPath(crdtPosition: PositionId, model: DocumentModel): { path: number[], offset: number } | null {
    const key = this.positionKey(crdtPosition);
    
    // Check cache
    if (this.crdtToEditor.has(key)) {
      return this.crdtToEditor.get(key)!;
    }
    
    // Find position in model by traversing and comparing
    const result = this.findPositionInModel(crdtPosition, model);
    
    if (result) {
      this.crdtToEditor.set(key, result);
      const editorKey = this.editorPathKey(result.path, result.offset);
      this.editorToCrdt.set(editorKey, crdtPosition);
    }
    
    return result;
  }
  
  // Convert editor path to CRDT position
  toCrdtPosition(path: number[], offset: number): PositionId | null {
    const key = this.editorPathKey(path, offset);
    return this.editorToCrdt.get(key) || null;
  }
  
  // Find CRDT position in model by traversing
  private findPositionInModel(crdtPosition: PositionId, model: DocumentModel): { path: number[], offset: number } | null {
    // Traverse model and find node with matching CRDT position
    // This requires storing CRDT position IDs in your model nodes
    return this.traverseModel(model, crdtPosition);
  }
  
  private traverseModel(model: DocumentModel, targetPosition: PositionId, currentPath: number[] = []): { path: number[], offset: number } | null {
    for (let i = 0; i < model.children.length; i++) {
      const child = model.children[i];
      const childPath = [...currentPath, i];
      
      // Check if node has CRDT position stored
      if (child.crdtPosition && this.comparePositions(child.crdtPosition, targetPosition) === 0) {
        return { path: childPath, offset: 0 };
      }
      
      // For text nodes, check character positions
      if (child.type === 'text') {
        for (let j = 0; j < child.text.length; j++) {
          const charPosition = child.charPositions?.[j];
          if (charPosition && this.comparePositions(charPosition, targetPosition) === 0) {
            return { path: childPath, offset: j };
          }
        }
      }
      
      // Recurse into children
      if (child.children) {
        const result = this.traverseModel(child, targetPosition, childPath);
        if (result) return result;
      }
    }
    
    return null;
  }
  
  private comparePositions(a: PositionId, b: PositionId): number {
    if (a.siteId < b.siteId) return -1;
    if (a.siteId > b.siteId) return 1;
    if (a.clock < b.clock) return -1;
    if (a.clock > b.clock) return 1;
    const aOffset = a.offset || 0;
    const bOffset = b.offset || 0;
    if (aOffset < bOffset) return -1;
    if (aOffset > bOffset) return 1;
    return 0;
  }
  
  private positionKey(pos: PositionId): string {
    return `${pos.siteId}:${pos.clock}:${pos.offset || 0}`;
  }
  
  private editorPathKey(path: number[], offset: number): string {
    return `${path.join(',')}:${offset}`;
  }
  
  // Invalidate cache when model changes
  invalidate() {
    this.crdtToEditor.clear();
    this.editorToCrdt.clear();
  }
}

Storing CRDT Positions in Model

// Extend your model to store CRDT positions
interface TextNode {
  type: 'text';
  text: string;
  charPositions?: PositionId[]; // CRDT position for each character
  formats?: Record<string, any>;
}

interface BlockNode {
  type: string;
  crdtPosition?: PositionId; // CRDT position for the node
  children: Node[];
  attributes?: Record<string, any>;
}

// When applying local operations, store CRDT positions
class EditorWithCRDT {
  applyLocalOperation(operation: EditorOperation) {
    const tx = this.beginTransaction();
    
    // Apply operation
    tx.add(operation);
    
    // Generate and store CRDT positions
    if (operation.type === 'insertText') {
      const crdtPositions = this.generateCRDTPositions(operation.path, operation.text.length);
      this.storeCRDTPositions(operation.path, crdtPositions);
    }
    
    tx.commit();
    
    // Send to CRDT library
    this.sendToCRDT(operation, crdtPositions);
  }
  
  private generateCRDTPositions(path: number[], length: number): PositionId[] {
    const node = this.getNodeAtPath(path);
    const after = node?.crdtPosition || null;
    
    const positions: PositionId[] = [];
    for (let i = 0; i < length; i++) {
      positions.push({
        siteId: this.siteId,
        clock: this.clock++,
        offset: i * 0.001
      });
    }
    
    return positions;
  }
}

Operation Conversion

Convert CRDT operations to your editor's operation format:

Insert Operations

class CRDTOperationConverter {
  constructor(
    private positionMapper: CRDTPositionMapper,
    private editor: Editor
  ) {}
  
  convertInsert(crdtOp: CRDTOperation): EditorOperation | null {
    // Map CRDT position to editor path
    const editorPath = this.positionMapper.toEditorPath(crdtOp.after || crdtOp.position, this.editor.getModel());
    
    if (!editorPath) {
      console.warn('Could not map CRDT position to editor path', crdtOp);
      return null;
    }
    
    if (typeof crdtOp.content === 'string') {
      // Insert text
      return {
        type: 'insertText',
        path: editorPath.path,
        offset: editorPath.offset,
        text: crdtOp.content
      };
    } else {
      // Insert node
      return {
        type: 'insertNode',
        path: editorPath.path,
        node: this.convertCRDTNodeToEditorNode(crdtOp.content)
      };
    }
  }
  
  convertCRDTNodeToEditorNode(crdtNode: any): Node {
    // Convert CRDT node format to your editor's node format
    return {
      type: crdtNode.type,
      children: crdtNode.children?.map((child: any) => this.convertCRDTNodeToEditorNode(child)) || [],
      attributes: crdtNode.attributes || {}
    };
  }
}

Delete Operations

convertDelete(crdtOp: CRDTOperation): EditorOperation | null {
  if (crdtOp.positions && crdtOp.positions.length > 0) {
    // Delete range
    const startPath = this.positionMapper.toEditorPath(crdtOp.positions[0], this.editor.getModel());
    const endPath = this.positionMapper.toEditorPath(
      crdtOp.positions[crdtOp.positions.length - 1],
      this.editor.getModel()
    );
    
    if (!startPath || !endPath) return null;
    
    // Calculate length
    const length = this.calculateLength(startPath, endPath);
    
    return {
      type: 'deleteContent',
      path: startPath.path,
      offset: startPath.offset,
      length: length
    };
  } else if (crdtOp.position) {
    // Delete single position
    const editorPath = this.positionMapper.toEditorPath(crdtOp.position, this.editor.getModel());
    if (!editorPath) return null;
    
    return {
      type: 'deleteContent',
      path: editorPath.path,
      offset: editorPath.offset,
      length: 1
    };
  }
  
  return null;
}

private calculateLength(start: { path: number[], offset: number }, end: { path: number[], offset: number }): number {
  // Calculate length between two positions
  if (this.pathsEqual(start.path, end.path)) {
    return end.offset - start.offset;
  }
  
  // Different paths - need to traverse
  let length = 0;
  const startNode = this.editor.getNodeAtPath(start.path);
  const endNode = this.editor.getNodeAtPath(end.path);
  
  // Count characters from start to end of start node
  if (startNode?.type === 'text') {
    length += startNode.text.length - start.offset;
  }
  
  // Count all nodes between
  // ... traversal logic ...
  
  // Count characters from start of end node to end
  if (endNode?.type === 'text') {
    length += end.offset;
  }
  
  return length;
}

Format Operations

convertFormat(crdtOp: CRDTOperation): EditorOperation | null {
  if (!crdtOp.positions || crdtOp.positions.length < 2) return null;
  
  const startPath = this.positionMapper.toEditorPath(crdtOp.positions[0], this.editor.getModel());
  const endPath = this.positionMapper.toEditorPath(
    crdtOp.positions[crdtOp.positions.length - 1],
    this.editor.getModel()
  );
  
  if (!startPath || !endPath) return null;
  
  const length = this.calculateLength(startPath, endPath);
  
  return {
    type: 'applyFormat',
    path: startPath.path,
    offset: startPath.offset,
    length: length,
    format: Object.keys(crdtOp.attributes || {})[0],
    value: Object.values(crdtOp.attributes || {})[0]
  };
}

Node Operations

convertUpdateAttributes(crdtOp: CRDTOperation): EditorOperation | null {
  const editorPath = this.positionMapper.toEditorPath(crdtOp.position, this.editor.getModel());
  if (!editorPath) return null;
  
  return {
    type: 'updateAttributes',
    path: editorPath.path,
    attributes: crdtOp.attributes || {}
  };
}

convertSetNodeType(crdtOp: CRDTOperation): EditorOperation | null {
  const editorPath = this.positionMapper.toEditorPath(crdtOp.position, this.editor.getModel());
  if (!editorPath) return null;
  
  return {
    type: 'setNodeType',
    path: editorPath.path,
    nodeType: crdtOp.content?.type || 'paragraph'
  };
}

// Main conversion method
convert(crdtOp: CRDTOperation): EditorOperation | EditorOperation[] | null {
  switch (crdtOp.type) {
    case 'insert':
      return this.convertInsert(crdtOp);
    case 'delete':
      return this.convertDelete(crdtOp);
    case 'format':
      return this.convertFormat(crdtOp);
    case 'update':
      return this.convertUpdateAttributes(crdtOp);
    case 'setType':
      return this.convertSetNodeType(crdtOp);
    default:
      console.warn('Unknown CRDT operation type', crdtOp.type);
      return null;
  }
}

Applying Operations

Once converted, apply operations through your editor's transaction system:

class CRDTEditorAdapter {
  constructor(
    private editor: Editor,
    private converter: CRDTOperationConverter,
    private positionMapper: CRDTPositionMapper
  ) {}
  
  // Handle incoming CRDT operation
  handleCRDTOperation(crdtOp: CRDTOperation) {
    // Skip if this is our own operation
    if (crdtOp.siteId === this.editor.getSiteId()) {
      return;
    }
    
    // Convert to editor operation
    const editorOps = this.converter.convert(crdtOp);
    
    if (!editorOps) {
      console.warn('Failed to convert CRDT operation', crdtOp);
      return;
    }
    
    // Normalize to array
    const operations = Array.isArray(editorOps) ? editorOps : [editorOps];
    
    // Apply through transaction
    const tx = this.editor.beginTransaction();
    
    for (const op of operations) {
      tx.add(op);
      
      // Store CRDT positions in model for future mapping
      this.storeCRDTPositions(op, crdtOp);
    }
    
    // Commit transaction (this will trigger rendering)
    tx.commit();
    
    // Invalidate position cache after model changes
    this.positionMapper.invalidate();
  }
  
  private storeCRDTPositions(editorOp: EditorOperation, crdtOp: CRDTOperation) {
    if (editorOp.type === 'insertText' && crdtOp.positions) {
      const node = this.editor.getNodeAtPath(editorOp.path);
      if (node?.type === 'text') {
        // Store CRDT positions for each character
        if (!node.charPositions) {
          node.charPositions = [];
        }
        // Insert positions at offset
        node.charPositions.splice(editorOp.offset, 0, ...crdtOp.positions);
      }
    } else if (editorOp.type === 'insertNode' && crdtOp.position) {
      const node = this.editor.getNodeAtPath(editorOp.path);
      if (node) {
        node.crdtPosition = crdtOp.position;
      }
    }
  }
  
  // Handle batch of CRDT operations
  handleCRDTOperations(crdtOps: CRDTOperation[]) {
    // Group by transaction (same clock)
    const transactions = this.groupByTransaction(crdtOps);
    
    for (const transactionOps of transactions) {
      const tx = this.editor.beginTransaction();
      
      for (const crdtOp of transactionOps) {
        const editorOps = this.converter.convert(crdtOp);
        if (editorOps) {
          const ops = Array.isArray(editorOps) ? editorOps : [editorOps];
          for (const op of ops) {
            tx.add(op);
            this.storeCRDTPositions(op, crdtOp);
          }
        }
      }
      
      tx.commit();
    }
    
    this.positionMapper.invalidate();
  }
  
  private groupByTransaction(ops: CRDTOperation[]): CRDTOperation[][] {
    const groups = new Map<number, CRDTOperation[]>();
    
    for (const op of ops) {
      const clock = op.clock;
      if (!groups.has(clock)) {
        groups.set(clock, []);
      }
      groups.get(clock)!.push(op);
    }
    
    return Array.from(groups.values());
  }
}

Rendering Updates

Your editor's rendering system should automatically update when operations are applied through transactions:

// Your editor should already handle rendering
class Editor {
  beginTransaction(): Transaction {
    return new Transaction(this);
  }
  
  // Transaction commits trigger rendering
  commitTransaction(tx: Transaction) {
    // Apply operations to model
    for (const op of tx.operations) {
      this.applyOperationToModel(op);
    }
    
    // Trigger render update
    this.renderer.update(this.model);
    
    // Update selection if needed
    this.updateSelection(tx.afterSelection);
  }
}

// The renderer updates DOM based on model changes
class Renderer {
  update(model: DocumentModel) {
    // Diff model and DOM
    const changes = this.diffModelAndDOM(model, this.currentDOM);
    
    // Apply changes incrementally
    for (const change of changes) {
      this.applyChange(change);
    }
    
    this.currentDOM = this.serializeModel(model);
  }
  
  private applyChange(change: ModelChange) {
    switch (change.type) {
      case 'insert':
        this.insertNode(change.path, change.node);
        break;
      case 'delete':
        this.deleteNode(change.path);
        break;
      case 'update':
        this.updateNode(change.path, change.attributes);
        break;
    }
  }
}

Selection Handling

When remote operations arrive, you need to adjust the selection:

class CRDTSelectionManager {
  constructor(private editor: Editor, private positionMapper: CRDTPositionMapper) {}
  
  // Adjust selection when remote operation arrives
  adjustSelectionForRemoteOperation(
    currentSelection: Selection,
    crdtOp: CRDTOperation
  ): Selection {
    const editorOp = this.editor.getConverter().convert(crdtOp);
    if (!editorOp) return currentSelection;
    
    // Check if operation affects selection
    if (this.operationAffectsSelection(editorOp, currentSelection)) {
      return this.adjustSelection(editorOp, currentSelection);
    }
    
    return currentSelection;
  }
  
  private operationAffectsSelection(op: EditorOperation, selection: Selection): boolean {
    // Check if operation is before, within, or after selection
    const opPath = op.path;
    const opOffset = 'offset' in op ? op.offset : 0;
    const opLength = 'length' in op ? op.length : 1;
    
    const selectionStart = selection.start;
    const selectionEnd = selection.end;
    
    // Operation is before selection - no change needed
    if (this.isBefore(opPath, opOffset, selectionStart.path, selectionStart.offset)) {
      return false;
    }
    
    // Operation is after selection - no change needed
    if (this.isAfter(
      opPath,
      opOffset + opLength,
      selectionEnd.path,
      selectionEnd.offset
    )) {
      return false;
    }
    
    // Operation affects selection
    return true;
  }
  
  private adjustSelection(op: EditorOperation, selection: Selection): Selection {
    if (op.type === 'insertText') {
      // If insertion is before selection start, shift selection
      if (this.isBefore(op.path, op.offset, selection.start.path, selection.start.offset)) {
        return {
          start: {
            path: selection.start.path,
            offset: selection.start.offset + op.text.length
          },
          end: {
            path: selection.end.path,
            offset: selection.end.offset + op.text.length
          }
        };
      }
      // If insertion is within selection, extend selection
      else if (this.isWithin(op.path, op.offset, selection)) {
        return {
          start: selection.start,
          end: {
            path: selection.end.path,
            offset: selection.end.offset + op.text.length
          }
        };
      }
    } else if (op.type === 'deleteContent') {
      // If deletion is before selection, shift selection back
      if (this.isBefore(op.path, op.offset, selection.start.path, selection.start.offset)) {
        const deletedLength = op.length;
        return {
          start: {
            path: selection.start.path,
            offset: Math.max(0, selection.start.offset - deletedLength)
          },
          end: {
            path: selection.end.path,
            offset: Math.max(0, selection.end.offset - deletedLength)
          }
        };
      }
      // If deletion overlaps with selection, adjust selection
      else if (this.overlaps(op, selection)) {
        // Collapse selection to deletion start
        return {
          start: { path: op.path, offset: op.offset },
          end: { path: op.path, offset: op.offset }
        };
      }
    }
    
    return selection;
  }
  
  private isBefore(path1: number[], offset1: number, path2: number[], offset2: number): boolean {
    // Compare paths and offsets
    const pathCompare = this.comparePaths(path1, path2);
    if (pathCompare < 0) return true;
    if (pathCompare > 0) return false;
    return offset1 < offset2;
  }
  
  private isAfter(path1: number[], offset1: number, path2: number[], offset2: number): boolean {
    return this.isBefore(path2, offset2, path1, offset1);
  }
  
  private isWithin(path: number[], offset: number, selection: Selection): boolean {
    return !this.isBefore(path, offset, selection.start.path, selection.start.offset) &&
           !this.isAfter(path, offset, selection.end.path, selection.end.offset);
  }
  
  private overlaps(op: EditorOperation, selection: Selection): boolean {
    const opEnd = { path: op.path, offset: op.offset + (op.length || 1) };
    return !this.isAfter(op.path, op.offset, selection.end.path, selection.end.offset) &&
           !this.isBefore(opEnd.path, opEnd.offset, selection.start.path, selection.start.offset);
  }
  
  private comparePaths(path1: number[], path2: number[]): number {
    for (let i = 0; i < Math.min(path1.length, path2.length); i++) {
      if (path1[i] < path2[i]) return -1;
      if (path1[i] > path2[i]) return 1;
    }
    return path1.length - path2.length;
  }
}