or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

collaboration.mdcommands-and-editing.mdcursors-and-enhancements.mdhistory.mdindex.mdinput-and-keymaps.mdmarkdown.mdmenus-and-ui.mdmodel-and-schema.mdschema-definitions.mdstate-management.mdtables.mdtransformations.mdview-and-rendering.md

collaboration.mddocs/

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

```