CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-tiptap--pm

Comprehensive wrapper around ProseMirror packages providing unified entry point for rich text editing functionality in Tiptap framework

Pending
Overview
Eval results
Files

collaboration.mddocs/

Collaboration

The collaboration system enables real-time collaborative editing by synchronizing document changes between multiple editors. It handles conflict resolution, version tracking, and ensures document consistency across all participants.

Capabilities

Collaboration Plugin

Create and configure collaborative editing functionality.

/**
 * Create a collaboration plugin
 */
function collab(config?: CollabConfig): Plugin;

/**
 * Collaboration plugin configuration
 */
interface CollabConfig {
  /**
   * The starting version number (default: 0)
   */
  version?: number;
  
  /**
   * Client ID for this editor instance  
   */
  clientID?: string | number;
}

Document Synchronization

Functions for managing document state across collaborative sessions.

/**
 * Get the current collaboration version
 */
function getVersion(state: EditorState): number;

/**
 * Get steps that can be sent to other clients
 */
function sendableSteps(state: EditorState): {
  version: number;
  steps: Step[];
  clientID: string | number;
} | null;

/**
 * Apply steps received from other clients
 */
function receiveTransaction(
  state: EditorState,
  steps: Step[],
  clientIDs: (string | number)[]
): EditorState;

Usage Examples:

import { collab, getVersion, sendableSteps, receiveTransaction } from "@tiptap/pm/collab";
import { Step } from "@tiptap/pm/transform";

// Basic collaboration setup
const collaborationPlugin = collab({
  version: 0,
  clientID: "user-123"
});

const state = EditorState.create({
  schema: mySchema,
  plugins: [collaborationPlugin]
});

// Collaboration manager
class CollaborationManager {
  private view: EditorView;
  private websocket: WebSocket;
  private sendBuffer: Step[] = [];
  private isConnected = false;
  
  constructor(view: EditorView, websocketUrl: string) {
    this.view = view;
    this.setupWebSocket(websocketUrl);
    this.setupSendHandler();
  }
  
  private setupWebSocket(url: string) {
    this.websocket = new WebSocket(url);
    
    this.websocket.onopen = () => {
      this.isConnected = true;
      this.sendInitialState();
      this.flushSendBuffer();
    };
    
    this.websocket.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleIncomingMessage(message);
    };
    
    this.websocket.onclose = () => {
      this.isConnected = false;
      // Attempt reconnection
      setTimeout(() => this.setupWebSocket(url), 1000);
    };
  }
  
  private setupSendHandler() {
    const originalDispatch = this.view.dispatch;
    
    this.view.dispatch = (tr: Transaction) => {
      const newState = this.view.state.apply(tr);
      this.view.updateState(newState);
      
      // Send changes to other clients
      this.sendLocalChanges();
    };
  }
  
  private sendInitialState() {
    const version = getVersion(this.view.state);
    this.websocket.send(JSON.stringify({
      type: "initialize",
      version,
      clientID: this.getClientID()
    }));
  }
  
  private sendLocalChanges() {
    const sendable = sendableSteps(this.view.state);
    if (sendable) {
      if (this.isConnected) {
        this.websocket.send(JSON.stringify({
          type: "steps",
          ...sendable
        }));
      } else {
        // Buffer steps for later sending
        this.sendBuffer.push(...sendable.steps);
      }
    }
  }
  
  private handleIncomingMessage(message: any) {
    switch (message.type) {
      case "steps":
        this.applyRemoteSteps(message.steps, message.clientIDs, message.version);
        break;
        
      case "version":
        this.handleVersionSync(message.version);
        break;
        
      case "client-joined":
        this.handleClientJoined(message.clientID);
        break;
        
      case "client-left":
        this.handleClientLeft(message.clientID);
        break;
    }
  }
  
  private applyRemoteSteps(steps: any[], clientIDs: string[], version: number) {
    try {
      // Convert serialized steps back to Step instances
      const stepObjects = steps.map(stepData => Step.fromJSON(this.view.state.schema, stepData));
      
      // Apply remote changes
      const newState = receiveTransaction(this.view.state, stepObjects, clientIDs);
      this.view.updateState(newState);
      
    } catch (error) {
      console.error("Failed to apply remote steps:", error);
      this.requestFullSync();
    }
  }
  
  private handleVersionSync(serverVersion: number) {
    const localVersion = getVersion(this.view.state);
    if (localVersion !== serverVersion) {
      // Version mismatch - request full document sync
      this.requestFullSync();
    }
  }
  
  private requestFullSync() {
    this.websocket.send(JSON.stringify({
      type: "request-sync",
      clientID: this.getClientID()
    }));
  }
  
  private flushSendBuffer() {
    if (this.sendBuffer.length > 0 && this.isConnected) {
      this.websocket.send(JSON.stringify({
        type: "buffered-steps",
        steps: this.sendBuffer.map(step => step.toJSON()),
        clientID: this.getClientID()
      }));
      this.sendBuffer = [];
    }
  }
  
  private getClientID(): string {
    return this.view.state.plugins
      .find(p => p.spec.key === collab().spec.key)
      ?.getState(this.view.state)?.clientID || "unknown";
  }
  
  private handleClientJoined(clientID: string) {
    console.log(`Client ${clientID} joined the collaboration`);
    // Update UI to show new collaborator
  }
  
  private handleClientLeft(clientID: string) {
    console.log(`Client ${clientID} left the collaboration`);
    // Update UI to remove collaborator
  }
  
  public disconnect() {
    this.isConnected = false;
    this.websocket.close();
  }
}

// Usage
const collaborationManager = new CollaborationManager(
  view, 
  "wss://your-collab-server.com/ws"
);

Advanced Collaboration Features

Presence Awareness

Track and display collaborator presence and selections.

interface CollaboratorInfo {
  clientID: string;
  name: string;
  color: string;
  selection?: Selection;
  cursor?: number;
}

class PresenceManager {
  private collaborators = new Map<string, CollaboratorInfo>();
  private decorations = DecorationSet.empty;
  
  constructor(private view: EditorView) {
    this.setupPresencePlugin();
  }
  
  private setupPresencePlugin() {
    const presencePlugin = new Plugin({
      state: {
        init: () => DecorationSet.empty,
        apply: (tr, decorations) => {
          // Update decorations based on collaborator presence
          return this.updatePresenceDecorations(decorations, tr);
        }
      },
      
      props: {
        decorations: (state) => presencePlugin.getState(state)
      }
    });
    
    const newState = this.view.state.reconfigure({
      plugins: this.view.state.plugins.concat(presencePlugin)
    });
    this.view.updateState(newState);
  }
  
  updateCollaborator(collaborator: CollaboratorInfo) {
    this.collaborators.set(collaborator.clientID, collaborator);
    this.updateView();
  }
  
  removeCollaborator(clientID: string) {
    this.collaborators.delete(clientID);
    this.updateView();
  }
  
  private updatePresenceDecorations(decorations: DecorationSet, tr: Transaction): DecorationSet {
    let newDecorations = decorations.map(tr.mapping, tr.doc);
    
    // Clear old presence decorations
    newDecorations = newDecorations.remove(
      newDecorations.find(0, tr.doc.content.size, 
        spec => spec.presence
      )
    );
    
    // Add new presence decorations
    for (const collaborator of this.collaborators.values()) {
      if (collaborator.selection) {
        const decoration = this.createSelectionDecoration(collaborator);
        if (decoration) {
          newDecorations = newDecorations.add(tr.doc, [decoration]);
        }
      }
      
      if (collaborator.cursor !== undefined) {
        const cursorDecoration = this.createCursorDecoration(collaborator);
        if (cursorDecoration) {
          newDecorations = newDecorations.add(tr.doc, [cursorDecoration]);
        }
      }
    }
    
    return newDecorations;
  }
  
  private createSelectionDecoration(collaborator: CollaboratorInfo): Decoration | null {
    if (!collaborator.selection) return null;
    
    const { from, to } = collaborator.selection;
    return Decoration.inline(from, to, {
      class: `collaborator-selection collaborator-${collaborator.clientID}`,
      style: `background-color: ${collaborator.color}33;` // 33 for transparency
    }, { presence: true });
  }
  
  private createCursorDecoration(collaborator: CollaboratorInfo): Decoration | null {
    if (collaborator.cursor === undefined) return null;
    
    const cursorElement = document.createElement("span");
    cursorElement.className = `collaborator-cursor collaborator-${collaborator.clientID}`;
    cursorElement.style.borderColor = collaborator.color;
    cursorElement.setAttribute("data-name", collaborator.name);
    
    return Decoration.widget(collaborator.cursor, cursorElement, { 
      presence: true,
      side: 1 
    });
  }
  
  private updateView() {
    // Force view update to reflect presence changes
    this.view.dispatch(this.view.state.tr);
  }
}

Conflict Resolution

Handle and resolve editing conflicts automatically.

class ConflictResolver {
  static resolveConflicts(
    localSteps: Step[],
    remoteSteps: Step[],
    doc: Node
  ): { 
    transformedLocal: Step[];
    transformedRemote: Step[];
    resolved: boolean;
  } {
    let currentDoc = doc;
    const transformedLocal: Step[] = [];
    const transformedRemote: Step[] = [];
    
    // Use operational transformation to resolve conflicts
    const mapping = new Mapping();
    
    try {
      // Apply remote steps first, transforming local steps
      for (const remoteStep of remoteSteps) {
        const result = remoteStep.apply(currentDoc);
        if (result.failed) {
          throw new Error(`Remote step failed: ${result.failed}`);
        }
        
        currentDoc = result.doc;
        transformedRemote.push(remoteStep);
        
        // Transform remaining local steps
        for (let i = 0; i < localSteps.length; i++) {
          localSteps[i] = localSteps[i].map(mapping);
        }
        
        mapping.appendMapping(remoteStep.getMap());
      }
      
      // Apply transformed local steps
      for (const localStep of localSteps) {
        const result = localStep.apply(currentDoc);
        if (result.failed) {
          throw new Error(`Local step failed: ${result.failed}`);
        }
        
        currentDoc = result.doc;
        transformedLocal.push(localStep);
      }
      
      return {
        transformedLocal,
        transformedRemote,
        resolved: true
      };
      
    } catch (error) {
      console.error("Conflict resolution failed:", error);
      return {
        transformedLocal: [],
        transformedRemote,
        resolved: false
      };
    }
  }
  
  static handleFailedResolution(
    view: EditorView,
    localSteps: Step[],
    remoteSteps: Step[]
  ) {
    // Show conflict resolution UI
    const conflictDialog = this.createConflictDialog(localSteps, remoteSteps);
    document.body.appendChild(conflictDialog);
  }
  
  private static createConflictDialog(localSteps: Step[], remoteSteps: Step[]): HTMLElement {
    const dialog = document.createElement("div");
    dialog.className = "conflict-resolution-dialog";
    
    dialog.innerHTML = `
      <h3>Editing Conflict Detected</h3>
      <p>Your changes conflict with recent changes from another user.</p>
      <div class="conflict-options">
        <button id="accept-remote">Accept Their Changes</button>
        <button id="keep-local">Keep My Changes</button>
        <button id="merge-manual">Resolve Manually</button>
      </div>
    `;
    
    // Add event handlers for resolution options
    dialog.querySelector("#accept-remote")?.addEventListener("click", () => {
      // Accept remote changes, discard local
      dialog.remove();
    });
    
    dialog.querySelector("#keep-local")?.addEventListener("click", () => {
      // Keep local changes, may cause issues
      dialog.remove();
    });
    
    dialog.querySelector("#merge-manual")?.addEventListener("click", () => {
      // Open manual merge interface
      dialog.remove();
      this.openManualMergeInterface(localSteps, remoteSteps);
    });
    
    return dialog;
  }
  
  private static openManualMergeInterface(localSteps: Step[], remoteSteps: Step[]) {
    // Implementation for manual conflict resolution UI
    console.log("Opening manual merge interface...");
  }
}

Collaboration Server Integration

Example server-side integration for managing collaborative sessions.

// Server-side collaboration handler (Node.js/WebSocket example)
class CollaborationServer {
  private documents = new Map<string, DocumentSession>();
  
  handleConnection(websocket: WebSocket, documentId: string, clientId: string) {
    let session = this.documents.get(documentId);
    if (!session) {
      session = new DocumentSession(documentId);
      this.documents.set(documentId, session);
    }
    
    session.addClient(websocket, clientId);
    
    websocket.on("message", (data) => {
      const message = JSON.parse(data.toString());
      this.handleMessage(session!, websocket, message);
    });
    
    websocket.on("close", () => {
      session?.removeClient(clientId);
      if (session?.isEmpty()) {
        this.documents.delete(documentId);
      }
    });
  }
  
  private handleMessage(session: DocumentSession, websocket: WebSocket, message: any) {
    switch (message.type) {
      case "steps":
        session.applySteps(websocket, message.steps, message.version, message.clientID);
        break;
        
      case "initialize":
        session.sendInitialState(websocket, message.clientID);
        break;
        
      case "request-sync":
        session.sendFullDocument(websocket);
        break;
    }
  }
}

class DocumentSession {
  private clients = new Map<string, WebSocket>();
  private version = 0;
  private steps: any[] = [];
  
  constructor(private documentId: string) {}
  
  addClient(websocket: WebSocket, clientId: string) {
    this.clients.set(clientId, websocket);
    
    // Notify other clients
    this.broadcast({
      type: "client-joined",
      clientID: clientId
    }, clientId);
  }
  
  removeClient(clientId: string) {
    this.clients.delete(clientId);
    
    // Notify remaining clients
    this.broadcast({
      type: "client-left", 
      clientID: clientId
    }, clientId);
  }
  
  applySteps(sender: WebSocket, steps: any[], expectedVersion: number, clientId: string) {
    if (expectedVersion !== this.version) {
      // Version mismatch - send current version
      sender.send(JSON.stringify({
        type: "version",
        version: this.version
      }));
      return;
    }
    
    // Apply steps and increment version
    this.steps.push(...steps);
    this.version += steps.length;
    
    // Broadcast to other clients
    this.broadcast({
      type: "steps",
      steps,
      version: expectedVersion,
      clientIDs: [clientId]
    }, clientId);
  }
  
  private broadcast(message: any, excludeClient?: string) {
    for (const [clientId, websocket] of this.clients) {
      if (clientId !== excludeClient) {
        websocket.send(JSON.stringify(message));
      }
    }
  }
  
  isEmpty(): boolean {
    return this.clients.size === 0;
  }
  
  sendInitialState(websocket: WebSocket, clientId: string) {
    websocket.send(JSON.stringify({
      type: "initialize-response",
      version: this.version,
      steps: this.steps
    }));
  }
  
  sendFullDocument(websocket: WebSocket) {
    websocket.send(JSON.stringify({
      type: "full-sync",
      version: this.version,
      steps: this.steps
    }));
  }
}

Types

/**
 * Collaboration configuration options
 */
interface CollabConfig {
  version?: number;
  clientID?: string | number;
}

/**
 * Sendable steps data structure
 */
interface SendableSteps {
  version: number;
  steps: Step[];
  clientID: string | number;
}

/**
 * Collaboration event types
 */
type CollabEventType = "steps" | "version" | "client-joined" | "client-left" | "initialize";

/**
 * Collaboration message structure
 */
interface CollabMessage {
  type: CollabEventType;
  [key: string]: any;
}

Install with Tessl CLI

npx tessl i tessl/npm-tiptap--pm

docs

collaboration.md

commands-and-editing.md

cursors-and-enhancements.md

history.md

index.md

input-and-keymaps.md

markdown.md

menus-and-ui.md

model-and-schema.md

schema-definitions.md

state-management.md

tables.md

transformations.md

view-and-rendering.md

tile.json