Comprehensive wrapper around ProseMirror packages providing unified entry point for rich text editing functionality in Tiptap framework
—
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.
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;
}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"
);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);
}
}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...");
}
}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
}));
}
}/**
* 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