0
# Collaboration
1
2
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.
3
4
## Capabilities
5
6
### Collaboration Plugin
7
8
Create and configure collaborative editing functionality.
9
10
```typescript { .api }
11
/**
12
* Create a collaboration plugin
13
*/
14
function collab(config?: CollabConfig): Plugin;
15
16
/**
17
* Collaboration plugin configuration
18
*/
19
interface CollabConfig {
20
/**
21
* The starting version number (default: 0)
22
*/
23
version?: number;
24
25
/**
26
* Client ID for this editor instance
27
*/
28
clientID?: string | number;
29
}
30
```
31
32
### Document Synchronization
33
34
Functions for managing document state across collaborative sessions.
35
36
```typescript { .api }
37
/**
38
* Get the current collaboration version
39
*/
40
function getVersion(state: EditorState): number;
41
42
/**
43
* Get steps that can be sent to other clients
44
*/
45
function sendableSteps(state: EditorState): {
46
version: number;
47
steps: Step[];
48
clientID: string | number;
49
} | null;
50
51
/**
52
* Apply steps received from other clients
53
*/
54
function receiveTransaction(
55
state: EditorState,
56
steps: Step[],
57
clientIDs: (string | number)[]
58
): EditorState;
59
```
60
61
**Usage Examples:**
62
63
```typescript
64
import { collab, getVersion, sendableSteps, receiveTransaction } from "@tiptap/pm/collab";
65
import { Step } from "@tiptap/pm/transform";
66
67
// Basic collaboration setup
68
const collaborationPlugin = collab({
69
version: 0,
70
clientID: "user-123"
71
});
72
73
const state = EditorState.create({
74
schema: mySchema,
75
plugins: [collaborationPlugin]
76
});
77
78
// Collaboration manager
79
class CollaborationManager {
80
private view: EditorView;
81
private websocket: WebSocket;
82
private sendBuffer: Step[] = [];
83
private isConnected = false;
84
85
constructor(view: EditorView, websocketUrl: string) {
86
this.view = view;
87
this.setupWebSocket(websocketUrl);
88
this.setupSendHandler();
89
}
90
91
private setupWebSocket(url: string) {
92
this.websocket = new WebSocket(url);
93
94
this.websocket.onopen = () => {
95
this.isConnected = true;
96
this.sendInitialState();
97
this.flushSendBuffer();
98
};
99
100
this.websocket.onmessage = (event) => {
101
const message = JSON.parse(event.data);
102
this.handleIncomingMessage(message);
103
};
104
105
this.websocket.onclose = () => {
106
this.isConnected = false;
107
// Attempt reconnection
108
setTimeout(() => this.setupWebSocket(url), 1000);
109
};
110
}
111
112
private setupSendHandler() {
113
const originalDispatch = this.view.dispatch;
114
115
this.view.dispatch = (tr: Transaction) => {
116
const newState = this.view.state.apply(tr);
117
this.view.updateState(newState);
118
119
// Send changes to other clients
120
this.sendLocalChanges();
121
};
122
}
123
124
private sendInitialState() {
125
const version = getVersion(this.view.state);
126
this.websocket.send(JSON.stringify({
127
type: "initialize",
128
version,
129
clientID: this.getClientID()
130
}));
131
}
132
133
private sendLocalChanges() {
134
const sendable = sendableSteps(this.view.state);
135
if (sendable) {
136
if (this.isConnected) {
137
this.websocket.send(JSON.stringify({
138
type: "steps",
139
...sendable
140
}));
141
} else {
142
// Buffer steps for later sending
143
this.sendBuffer.push(...sendable.steps);
144
}
145
}
146
}
147
148
private handleIncomingMessage(message: any) {
149
switch (message.type) {
150
case "steps":
151
this.applyRemoteSteps(message.steps, message.clientIDs, message.version);
152
break;
153
154
case "version":
155
this.handleVersionSync(message.version);
156
break;
157
158
case "client-joined":
159
this.handleClientJoined(message.clientID);
160
break;
161
162
case "client-left":
163
this.handleClientLeft(message.clientID);
164
break;
165
}
166
}
167
168
private applyRemoteSteps(steps: any[], clientIDs: string[], version: number) {
169
try {
170
// Convert serialized steps back to Step instances
171
const stepObjects = steps.map(stepData => Step.fromJSON(this.view.state.schema, stepData));
172
173
// Apply remote changes
174
const newState = receiveTransaction(this.view.state, stepObjects, clientIDs);
175
this.view.updateState(newState);
176
177
} catch (error) {
178
console.error("Failed to apply remote steps:", error);
179
this.requestFullSync();
180
}
181
}
182
183
private handleVersionSync(serverVersion: number) {
184
const localVersion = getVersion(this.view.state);
185
if (localVersion !== serverVersion) {
186
// Version mismatch - request full document sync
187
this.requestFullSync();
188
}
189
}
190
191
private requestFullSync() {
192
this.websocket.send(JSON.stringify({
193
type: "request-sync",
194
clientID: this.getClientID()
195
}));
196
}
197
198
private flushSendBuffer() {
199
if (this.sendBuffer.length > 0 && this.isConnected) {
200
this.websocket.send(JSON.stringify({
201
type: "buffered-steps",
202
steps: this.sendBuffer.map(step => step.toJSON()),
203
clientID: this.getClientID()
204
}));
205
this.sendBuffer = [];
206
}
207
}
208
209
private getClientID(): string {
210
return this.view.state.plugins
211
.find(p => p.spec.key === collab().spec.key)
212
?.getState(this.view.state)?.clientID || "unknown";
213
}
214
215
private handleClientJoined(clientID: string) {
216
console.log(`Client ${clientID} joined the collaboration`);
217
// Update UI to show new collaborator
218
}
219
220
private handleClientLeft(clientID: string) {
221
console.log(`Client ${clientID} left the collaboration`);
222
// Update UI to remove collaborator
223
}
224
225
public disconnect() {
226
this.isConnected = false;
227
this.websocket.close();
228
}
229
}
230
231
// Usage
232
const collaborationManager = new CollaborationManager(
233
view,
234
"wss://your-collab-server.com/ws"
235
);
236
```
237
238
## Advanced Collaboration Features
239
240
### Presence Awareness
241
242
Track and display collaborator presence and selections.
243
244
```typescript
245
interface CollaboratorInfo {
246
clientID: string;
247
name: string;
248
color: string;
249
selection?: Selection;
250
cursor?: number;
251
}
252
253
class PresenceManager {
254
private collaborators = new Map<string, CollaboratorInfo>();
255
private decorations = DecorationSet.empty;
256
257
constructor(private view: EditorView) {
258
this.setupPresencePlugin();
259
}
260
261
private setupPresencePlugin() {
262
const presencePlugin = new Plugin({
263
state: {
264
init: () => DecorationSet.empty,
265
apply: (tr, decorations) => {
266
// Update decorations based on collaborator presence
267
return this.updatePresenceDecorations(decorations, tr);
268
}
269
},
270
271
props: {
272
decorations: (state) => presencePlugin.getState(state)
273
}
274
});
275
276
const newState = this.view.state.reconfigure({
277
plugins: this.view.state.plugins.concat(presencePlugin)
278
});
279
this.view.updateState(newState);
280
}
281
282
updateCollaborator(collaborator: CollaboratorInfo) {
283
this.collaborators.set(collaborator.clientID, collaborator);
284
this.updateView();
285
}
286
287
removeCollaborator(clientID: string) {
288
this.collaborators.delete(clientID);
289
this.updateView();
290
}
291
292
private updatePresenceDecorations(decorations: DecorationSet, tr: Transaction): DecorationSet {
293
let newDecorations = decorations.map(tr.mapping, tr.doc);
294
295
// Clear old presence decorations
296
newDecorations = newDecorations.remove(
297
newDecorations.find(0, tr.doc.content.size,
298
spec => spec.presence
299
)
300
);
301
302
// Add new presence decorations
303
for (const collaborator of this.collaborators.values()) {
304
if (collaborator.selection) {
305
const decoration = this.createSelectionDecoration(collaborator);
306
if (decoration) {
307
newDecorations = newDecorations.add(tr.doc, [decoration]);
308
}
309
}
310
311
if (collaborator.cursor !== undefined) {
312
const cursorDecoration = this.createCursorDecoration(collaborator);
313
if (cursorDecoration) {
314
newDecorations = newDecorations.add(tr.doc, [cursorDecoration]);
315
}
316
}
317
}
318
319
return newDecorations;
320
}
321
322
private createSelectionDecoration(collaborator: CollaboratorInfo): Decoration | null {
323
if (!collaborator.selection) return null;
324
325
const { from, to } = collaborator.selection;
326
return Decoration.inline(from, to, {
327
class: `collaborator-selection collaborator-${collaborator.clientID}`,
328
style: `background-color: ${collaborator.color}33;` // 33 for transparency
329
}, { presence: true });
330
}
331
332
private createCursorDecoration(collaborator: CollaboratorInfo): Decoration | null {
333
if (collaborator.cursor === undefined) return null;
334
335
const cursorElement = document.createElement("span");
336
cursorElement.className = `collaborator-cursor collaborator-${collaborator.clientID}`;
337
cursorElement.style.borderColor = collaborator.color;
338
cursorElement.setAttribute("data-name", collaborator.name);
339
340
return Decoration.widget(collaborator.cursor, cursorElement, {
341
presence: true,
342
side: 1
343
});
344
}
345
346
private updateView() {
347
// Force view update to reflect presence changes
348
this.view.dispatch(this.view.state.tr);
349
}
350
}
351
```
352
353
### Conflict Resolution
354
355
Handle and resolve editing conflicts automatically.
356
357
```typescript
358
class ConflictResolver {
359
static resolveConflicts(
360
localSteps: Step[],
361
remoteSteps: Step[],
362
doc: Node
363
): {
364
transformedLocal: Step[];
365
transformedRemote: Step[];
366
resolved: boolean;
367
} {
368
let currentDoc = doc;
369
const transformedLocal: Step[] = [];
370
const transformedRemote: Step[] = [];
371
372
// Use operational transformation to resolve conflicts
373
const mapping = new Mapping();
374
375
try {
376
// Apply remote steps first, transforming local steps
377
for (const remoteStep of remoteSteps) {
378
const result = remoteStep.apply(currentDoc);
379
if (result.failed) {
380
throw new Error(`Remote step failed: ${result.failed}`);
381
}
382
383
currentDoc = result.doc;
384
transformedRemote.push(remoteStep);
385
386
// Transform remaining local steps
387
for (let i = 0; i < localSteps.length; i++) {
388
localSteps[i] = localSteps[i].map(mapping);
389
}
390
391
mapping.appendMapping(remoteStep.getMap());
392
}
393
394
// Apply transformed local steps
395
for (const localStep of localSteps) {
396
const result = localStep.apply(currentDoc);
397
if (result.failed) {
398
throw new Error(`Local step failed: ${result.failed}`);
399
}
400
401
currentDoc = result.doc;
402
transformedLocal.push(localStep);
403
}
404
405
return {
406
transformedLocal,
407
transformedRemote,
408
resolved: true
409
};
410
411
} catch (error) {
412
console.error("Conflict resolution failed:", error);
413
return {
414
transformedLocal: [],
415
transformedRemote,
416
resolved: false
417
};
418
}
419
}
420
421
static handleFailedResolution(
422
view: EditorView,
423
localSteps: Step[],
424
remoteSteps: Step[]
425
) {
426
// Show conflict resolution UI
427
const conflictDialog = this.createConflictDialog(localSteps, remoteSteps);
428
document.body.appendChild(conflictDialog);
429
}
430
431
private static createConflictDialog(localSteps: Step[], remoteSteps: Step[]): HTMLElement {
432
const dialog = document.createElement("div");
433
dialog.className = "conflict-resolution-dialog";
434
435
dialog.innerHTML = `
436
<h3>Editing Conflict Detected</h3>
437
<p>Your changes conflict with recent changes from another user.</p>
438
<div class="conflict-options">
439
<button id="accept-remote">Accept Their Changes</button>
440
<button id="keep-local">Keep My Changes</button>
441
<button id="merge-manual">Resolve Manually</button>
442
</div>
443
`;
444
445
// Add event handlers for resolution options
446
dialog.querySelector("#accept-remote")?.addEventListener("click", () => {
447
// Accept remote changes, discard local
448
dialog.remove();
449
});
450
451
dialog.querySelector("#keep-local")?.addEventListener("click", () => {
452
// Keep local changes, may cause issues
453
dialog.remove();
454
});
455
456
dialog.querySelector("#merge-manual")?.addEventListener("click", () => {
457
// Open manual merge interface
458
dialog.remove();
459
this.openManualMergeInterface(localSteps, remoteSteps);
460
});
461
462
return dialog;
463
}
464
465
private static openManualMergeInterface(localSteps: Step[], remoteSteps: Step[]) {
466
// Implementation for manual conflict resolution UI
467
console.log("Opening manual merge interface...");
468
}
469
}
470
```
471
472
### Collaboration Server Integration
473
474
Example server-side integration for managing collaborative sessions.
475
476
```typescript
477
// Server-side collaboration handler (Node.js/WebSocket example)
478
class CollaborationServer {
479
private documents = new Map<string, DocumentSession>();
480
481
handleConnection(websocket: WebSocket, documentId: string, clientId: string) {
482
let session = this.documents.get(documentId);
483
if (!session) {
484
session = new DocumentSession(documentId);
485
this.documents.set(documentId, session);
486
}
487
488
session.addClient(websocket, clientId);
489
490
websocket.on("message", (data) => {
491
const message = JSON.parse(data.toString());
492
this.handleMessage(session!, websocket, message);
493
});
494
495
websocket.on("close", () => {
496
session?.removeClient(clientId);
497
if (session?.isEmpty()) {
498
this.documents.delete(documentId);
499
}
500
});
501
}
502
503
private handleMessage(session: DocumentSession, websocket: WebSocket, message: any) {
504
switch (message.type) {
505
case "steps":
506
session.applySteps(websocket, message.steps, message.version, message.clientID);
507
break;
508
509
case "initialize":
510
session.sendInitialState(websocket, message.clientID);
511
break;
512
513
case "request-sync":
514
session.sendFullDocument(websocket);
515
break;
516
}
517
}
518
}
519
520
class DocumentSession {
521
private clients = new Map<string, WebSocket>();
522
private version = 0;
523
private steps: any[] = [];
524
525
constructor(private documentId: string) {}
526
527
addClient(websocket: WebSocket, clientId: string) {
528
this.clients.set(clientId, websocket);
529
530
// Notify other clients
531
this.broadcast({
532
type: "client-joined",
533
clientID: clientId
534
}, clientId);
535
}
536
537
removeClient(clientId: string) {
538
this.clients.delete(clientId);
539
540
// Notify remaining clients
541
this.broadcast({
542
type: "client-left",
543
clientID: clientId
544
}, clientId);
545
}
546
547
applySteps(sender: WebSocket, steps: any[], expectedVersion: number, clientId: string) {
548
if (expectedVersion !== this.version) {
549
// Version mismatch - send current version
550
sender.send(JSON.stringify({
551
type: "version",
552
version: this.version
553
}));
554
return;
555
}
556
557
// Apply steps and increment version
558
this.steps.push(...steps);
559
this.version += steps.length;
560
561
// Broadcast to other clients
562
this.broadcast({
563
type: "steps",
564
steps,
565
version: expectedVersion,
566
clientIDs: [clientId]
567
}, clientId);
568
}
569
570
private broadcast(message: any, excludeClient?: string) {
571
for (const [clientId, websocket] of this.clients) {
572
if (clientId !== excludeClient) {
573
websocket.send(JSON.stringify(message));
574
}
575
}
576
}
577
578
isEmpty(): boolean {
579
return this.clients.size === 0;
580
}
581
582
sendInitialState(websocket: WebSocket, clientId: string) {
583
websocket.send(JSON.stringify({
584
type: "initialize-response",
585
version: this.version,
586
steps: this.steps
587
}));
588
}
589
590
sendFullDocument(websocket: WebSocket) {
591
websocket.send(JSON.stringify({
592
type: "full-sync",
593
version: this.version,
594
steps: this.steps
595
}));
596
}
597
}
598
```
599
600
## Types
601
602
```typescript { .api }
603
/**
604
* Collaboration configuration options
605
*/
606
interface CollabConfig {
607
version?: number;
608
clientID?: string | number;
609
}
610
611
/**
612
* Sendable steps data structure
613
*/
614
interface SendableSteps {
615
version: number;
616
steps: Step[];
617
clientID: string | number;
618
}
619
620
/**
621
* Collaboration event types
622
*/
623
type CollabEventType = "steps" | "version" | "client-joined" | "client-left" | "initialize";
624
625
/**
626
* Collaboration message structure
627
*/
628
interface CollabMessage {
629
type: CollabEventType;
630
[key: string]: any;
631
}
632
```