Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

chore: remove custom OT engine and legacy delta WebSocket protocol

+1187 -689
bin/server

This is a binary file and will not be displayed.

+1172
docs/superpowers/plans/2026-03-16-prosemirror-collab.md
··· 1 + # ProseMirror-Collab Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Replace Diffdown's custom text-offset OT engine with `prosemirror-collab`'s step-based rebasing protocol, eliminating full-document replacement on every remote edit and enabling proper cursor preservation, undo, and conflict-free collaborative typing. 6 + 7 + **Architecture:** The Go server becomes a step authority: it stores an append-only log of ProseMirror `Step` JSON objects per document and a monotonically incrementing version counter. Clients submit `[steps, clientVersion]`; if the server version has advanced, the server rejects and the client fetches `GET /ws/docs/{rkey}/steps?since={v}`, rebases locally via `prosemirror-collab`, and resubmits. The WebSocket channel is kept for low-latency broadcasting of confirmed steps to other peers. The existing Hub/Room/Client goroutine structure and ATProto auth are unchanged. 8 + 9 + **Tech Stack:** Go 1.22 (server), `prosemirror-collab` npm package (client), CodeMirror 6 (source mode — kept as-is, receives text patches derived from confirmed steps), Milkdown/ProseMirror (rich mode — speaks native PM steps), SQLite (step log), existing Gorilla WebSocket. 10 + 11 + --- 12 + 13 + ## Chunk 1: Server — Step Storage 14 + 15 + ### Task 1: Migration — `doc_steps` table 16 + 17 + **Files:** 18 + - Create: `migrations/006_doc_steps.sql` 19 + 20 + Steps must survive server restarts. One row per confirmed step; `version` is the 1-based ordinal within a document's history. 21 + 22 + - [ ] **Step 1: Write the migration** 23 + 24 + ```sql 25 + -- migrations/006_doc_steps.sql 26 + CREATE TABLE IF NOT EXISTS doc_steps ( 27 + id INTEGER PRIMARY KEY AUTOINCREMENT, 28 + doc_rkey TEXT NOT NULL, 29 + version INTEGER NOT NULL, 30 + step_json TEXT NOT NULL, 31 + client_id TEXT NOT NULL, 32 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 33 + UNIQUE(doc_rkey, version) 34 + ); 35 + 36 + CREATE INDEX IF NOT EXISTS idx_doc_steps_rkey_version ON doc_steps(doc_rkey, version); 37 + ``` 38 + 39 + - [ ] **Step 2: Run migration** 40 + 41 + ```bash 42 + make migrate-up 43 + ``` 44 + 45 + Expected: `Applied migration: 006_doc_steps.sql` in server log. 46 + 47 + - [ ] **Step 3: Commit** 48 + 49 + ```bash 50 + git add migrations/006_doc_steps.sql 51 + git commit -m "feat(db): add doc_steps table for prosemirror-collab step log" 52 + ``` 53 + 54 + --- 55 + 56 + ### Task 2: DB methods — `AppendSteps`, `GetStepsSince`, `GetDocVersion` 57 + 58 + **Files:** 59 + - Modify: `internal/db/db.go` 60 + 61 + Follow the existing pattern: raw SQL, no ORM, wrap errors with `fmt.Errorf`. 62 + 63 + - [ ] **Step 1: Write failing tests** 64 + 65 + Create `internal/db/db_steps_test.go`: 66 + 67 + ```go 68 + package db_test 69 + 70 + import ( 71 + "os" 72 + "testing" 73 + 74 + "github.com/limeleaf/diffdown/internal/db" 75 + ) 76 + 77 + func openTestDB(t *testing.T) *db.DB { 78 + t.Helper() 79 + f, err := os.CreateTemp("", "diffdown-test-*.db") 80 + if err != nil { 81 + t.Fatal(err) 82 + } 83 + t.Cleanup(func() { os.Remove(f.Name()) }) 84 + f.Close() 85 + 86 + database, err := db.Open(f.Name()) 87 + if err != nil { 88 + t.Fatal(err) 89 + } 90 + db.SetMigrationsDir("../../migrations") 91 + if err := database.Migrate(); err != nil { 92 + t.Fatal(err) 93 + } 94 + return database 95 + } 96 + 97 + func TestGetDocVersion_Empty(t *testing.T) { 98 + d := openTestDB(t) 99 + v, err := d.GetDocVersion("rkey1") 100 + if err != nil { 101 + t.Fatal(err) 102 + } 103 + if v != 0 { 104 + t.Errorf("expected version 0 for new doc, got %d", v) 105 + } 106 + } 107 + 108 + func TestAppendSteps_IncreasesVersion(t *testing.T) { 109 + d := openTestDB(t) 110 + steps := []string{`{"stepType":"replace"}`, `{"stepType":"replace"}`} 111 + newVersion, err := d.AppendSteps("rkey1", 0, steps, "client-a") 112 + if err != nil { 113 + t.Fatal(err) 114 + } 115 + if newVersion != 2 { 116 + t.Errorf("expected version 2, got %d", newVersion) 117 + } 118 + } 119 + 120 + func TestAppendSteps_VersionConflict(t *testing.T) { 121 + d := openTestDB(t) 122 + _, err := d.AppendSteps("rkey1", 0, []string{`{}`}, "client-a") 123 + if err != nil { 124 + t.Fatal(err) 125 + } 126 + // Submit again from clientVersion=0 (stale) — must fail. 127 + _, err = d.AppendSteps("rkey1", 0, []string{`{}`}, "client-b") 128 + if err == nil { 129 + t.Fatal("expected conflict error, got nil") 130 + } 131 + } 132 + 133 + func TestGetStepsSince(t *testing.T) { 134 + d := openTestDB(t) 135 + steps := []string{`{"a":1}`, `{"a":2}`, `{"a":3}`} 136 + d.AppendSteps("rkey1", 0, steps, "client-a") 137 + 138 + rows, err := d.GetStepsSince("rkey1", 1) 139 + if err != nil { 140 + t.Fatal(err) 141 + } 142 + if len(rows) != 2 { 143 + t.Errorf("expected 2 steps since v1, got %d", len(rows)) 144 + } 145 + if rows[0].JSON != `{"a":2}` { 146 + t.Errorf("unexpected step: %s", rows[0].JSON) 147 + } 148 + } 149 + ``` 150 + 151 + - [ ] **Step 2: Run tests — expect FAIL (functions not defined)** 152 + 153 + ```bash 154 + go test -v ./internal/db/... 155 + ``` 156 + 157 + Expected: compile error — `db.GetDocVersion`, `db.AppendSteps`, `db.GetStepsSince` undefined. 158 + 159 + - [ ] **Step 3: Implement the DB methods** 160 + 161 + Add to `internal/db/db.go`: 162 + 163 + ```go 164 + // --- Document Steps (prosemirror-collab) --- 165 + 166 + // StepRow is a confirmed ProseMirror step from the step log. 167 + type StepRow struct { 168 + Version int 169 + JSON string 170 + } 171 + 172 + // GetDocVersion returns the current highest version for the given document. 173 + // Returns 0 if no steps have been recorded yet. 174 + func (db *DB) GetDocVersion(docRKey string) (int, error) { 175 + var v int 176 + err := db.QueryRow( 177 + `SELECT COALESCE(MAX(version), 0) FROM doc_steps WHERE doc_rkey = ?`, docRKey, 178 + ).Scan(&v) 179 + if err != nil { 180 + return 0, fmt.Errorf("GetDocVersion: %w", err) 181 + } 182 + return v, nil 183 + } 184 + 185 + // AppendSteps atomically appends steps starting at clientVersion+1. 186 + // Returns ErrVersionConflict if the server version has advanced past clientVersion. 187 + // Returns the new server version on success. 188 + func (db *DB) AppendSteps(docRKey string, clientVersion int, stepsJSON []string, clientID string) (int, error) { 189 + tx, err := db.Begin() 190 + if err != nil { 191 + return 0, fmt.Errorf("AppendSteps begin: %w", err) 192 + } 193 + defer tx.Rollback() 194 + 195 + var current int 196 + tx.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM doc_steps WHERE doc_rkey = ?`, docRKey).Scan(&current) 197 + if current != clientVersion { 198 + return 0, fmt.Errorf("version conflict: server=%d client=%d", current, clientVersion) 199 + } 200 + 201 + for i, stepJSON := range stepsJSON { 202 + version := clientVersion + i + 1 203 + _, err := tx.Exec( 204 + `INSERT INTO doc_steps (doc_rkey, version, step_json, client_id) VALUES (?, ?, ?, ?)`, 205 + docRKey, version, stepJSON, clientID, 206 + ) 207 + if err != nil { 208 + return 0, fmt.Errorf("AppendSteps insert v%d: %w", version, err) 209 + } 210 + } 211 + 212 + if err := tx.Commit(); err != nil { 213 + return 0, fmt.Errorf("AppendSteps commit: %w", err) 214 + } 215 + return clientVersion + len(stepsJSON), nil 216 + } 217 + 218 + // GetStepsSince returns all steps with version > sinceVersion, ordered ascending. 219 + func (db *DB) GetStepsSince(docRKey string, sinceVersion int) ([]StepRow, error) { 220 + rows, err := db.Query( 221 + `SELECT version, step_json FROM doc_steps WHERE doc_rkey = ? AND version > ? ORDER BY version ASC`, 222 + docRKey, sinceVersion, 223 + ) 224 + if err != nil { 225 + return nil, fmt.Errorf("GetStepsSince: %w", err) 226 + } 227 + defer rows.Close() 228 + var result []StepRow 229 + for rows.Next() { 230 + var r StepRow 231 + if err := rows.Scan(&r.Version, &r.JSON); err != nil { 232 + return nil, err 233 + } 234 + result = append(result, r) 235 + } 236 + return result, rows.Err() 237 + } 238 + ``` 239 + 240 + - [ ] **Step 4: Run tests — expect PASS** 241 + 242 + ```bash 243 + go test -v ./internal/db/... 244 + ``` 245 + 246 + Expected: all `TestGetDocVersion_*`, `TestAppendSteps_*`, `TestGetStepsSince` PASS. 247 + 248 + - [ ] **Step 5: Commit** 249 + 250 + ```bash 251 + git add internal/db/db.go internal/db/db_steps_test.go 252 + git commit -m "feat(db): add AppendSteps, GetStepsSince, GetDocVersion for step log" 253 + ``` 254 + 255 + --- 256 + 257 + ## Chunk 2: Server — Step Authority HTTP Endpoint 258 + 259 + The step-exchange protocol uses two new HTTP endpoints: 260 + 261 + - `POST /api/docs/{rkey}/steps` — submit steps from client; returns `{version, steps}` on conflict or `{version}` on success 262 + - `GET /api/docs/{rkey}/steps?since={v}` — fetch steps the client missed 263 + 264 + The WebSocket channel continues to broadcast `{type:"steps", steps:[...], version:N}` messages to peers in real-time. 265 + 266 + ### Task 3: `SubmitSteps` and `GetSteps` HTTP handlers 267 + 268 + **Files:** 269 + - Modify: `internal/handler/handler.go` 270 + - Modify: `cmd/server/main.go` (route registration) 271 + 272 + - [ ] **Step 1: Write the handlers** 273 + 274 + Add to `internal/handler/handler.go` (below the existing `CollaboratorWebSocket`): 275 + 276 + ```go 277 + // SubmitSteps receives ProseMirror steps from a collaborator, appends them 278 + // to the step log, and broadcasts confirmed steps to the room. 279 + // 280 + // POST /api/docs/{rkey}/steps 281 + // Body: {"clientVersion": N, "steps": ["...json..."], "clientID": "did:..."} 282 + // Response 200: {"version": N} 283 + // Response 409: {"version": N, "steps": ["...json..."]} — client must rebase 284 + func (h *Handler) SubmitSteps(w http.ResponseWriter, r *http.Request) { 285 + user := h.currentUser(r) 286 + if user == nil { 287 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 288 + return 289 + } 290 + rkey := r.PathValue("rkey") 291 + 292 + var body struct { 293 + ClientVersion int `json:"clientVersion"` 294 + Steps []string `json:"steps"` 295 + ClientID string `json:"clientID"` 296 + } 297 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 298 + http.Error(w, "Bad request", http.StatusBadRequest) 299 + return 300 + } 301 + if len(body.Steps) == 0 { 302 + http.Error(w, "No steps", http.StatusBadRequest) 303 + return 304 + } 305 + 306 + newVersion, err := h.DB.AppendSteps(rkey, body.ClientVersion, body.Steps, body.ClientID) 307 + if err != nil { 308 + // Version conflict — return steps the client missed. 309 + missed, dbErr := h.DB.GetStepsSince(rkey, body.ClientVersion) 310 + if dbErr != nil { 311 + log.Printf("SubmitSteps: GetStepsSince: %v", dbErr) 312 + http.Error(w, "Internal error", http.StatusInternalServerError) 313 + return 314 + } 315 + currentVersion, _ := h.DB.GetDocVersion(rkey) 316 + stepJSONs := make([]string, len(missed)) 317 + for i, s := range missed { 318 + stepJSONs[i] = s.JSON 319 + } 320 + w.Header().Set("Content-Type", "application/json") 321 + w.WriteHeader(http.StatusConflict) 322 + json.NewEncoder(w).Encode(map[string]interface{}{ 323 + "version": currentVersion, 324 + "steps": stepJSONs, 325 + }) 326 + return 327 + } 328 + 329 + // Broadcast to other room members via WebSocket. 330 + if room := h.CollaborationHub.GetRoom(rkey); room != nil { 331 + type stepsMsg struct { 332 + Type string `json:"type"` 333 + Steps []string `json:"steps"` 334 + Version int `json:"version"` 335 + ClientID string `json:"clientID"` 336 + } 337 + data, _ := json.Marshal(stepsMsg{ 338 + Type: "steps", 339 + Steps: body.Steps, 340 + Version: newVersion, 341 + ClientID: body.ClientID, 342 + }) 343 + room.Broadcast(data) 344 + } 345 + 346 + h.jsonResponse(w, map[string]int{"version": newVersion}, http.StatusOK) 347 + } 348 + 349 + // GetSteps returns all steps since the given version. 350 + // 351 + // GET /api/docs/{rkey}/steps?since={v} 352 + // Response 200: {"version": N, "steps": ["...json..."]} 353 + func (h *Handler) GetSteps(w http.ResponseWriter, r *http.Request) { 354 + user := h.currentUser(r) 355 + if user == nil { 356 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 357 + return 358 + } 359 + rkey := r.PathValue("rkey") 360 + 361 + sinceStr := r.URL.Query().Get("since") 362 + var since int 363 + if sinceStr != "" { 364 + fmt.Sscanf(sinceStr, "%d", &since) 365 + } 366 + 367 + rows, err := h.DB.GetStepsSince(rkey, since) 368 + if err != nil { 369 + log.Printf("GetSteps: %v", err) 370 + http.Error(w, "Internal error", http.StatusInternalServerError) 371 + return 372 + } 373 + version, _ := h.DB.GetDocVersion(rkey) 374 + stepJSONs := make([]string, len(rows)) 375 + for i, s := range rows { 376 + stepJSONs[i] = s.JSON 377 + } 378 + h.jsonResponse(w, map[string]interface{}{ 379 + "version": version, 380 + "steps": stepJSONs, 381 + }, http.StatusOK) 382 + } 383 + ``` 384 + 385 + - [ ] **Step 2: Register routes in `cmd/server/main.go`** 386 + 387 + Find the route block and add after the existing `/ws/docs/{rkey}` line: 388 + 389 + ```go 390 + mux.HandleFunc("POST /api/docs/{rkey}/steps", middleware.RequireAuth(h.SubmitSteps)) 391 + mux.HandleFunc("GET /api/docs/{rkey}/steps", middleware.RequireAuth(h.GetSteps)) 392 + ``` 393 + 394 + - [ ] **Step 3: Build to catch type errors** 395 + 396 + ```bash 397 + make build 398 + ``` 399 + 400 + Expected: `bin/server` produced, zero errors. 401 + 402 + - [ ] **Step 4: Smoke-test manually** 403 + 404 + ```bash 405 + make run 406 + # In another terminal: 407 + curl -s -b 'session=<valid-cookie>' http://localhost:8080/api/docs/somekey/steps?since=0 408 + # Expected: {"steps":[],"version":0} 409 + ``` 410 + 411 + - [ ] **Step 5: Commit** 412 + 413 + ```bash 414 + git add internal/handler/handler.go cmd/server/main.go 415 + git commit -m "feat(handler): add SubmitSteps and GetSteps HTTP endpoints" 416 + ``` 417 + 418 + --- 419 + 420 + ### Task 4: Handler tests for `SubmitSteps` and `GetSteps` 421 + 422 + **Files:** 423 + - Create: `internal/handler/steps_test.go` 424 + 425 + The existing handler tests use `httptest` with a real in-memory SQLite DB. Follow that pattern. 426 + 427 + - [ ] **Step 1: Write tests** 428 + 429 + ```go 430 + package handler_test 431 + 432 + import ( 433 + "bytes" 434 + "encoding/json" 435 + "net/http" 436 + "net/http/httptest" 437 + "os" 438 + "testing" 439 + 440 + "github.com/limeleaf/diffdown/internal/db" 441 + "github.com/limeleaf/diffdown/internal/handler" 442 + "github.com/limeleaf/diffdown/internal/collaboration" 443 + ) 444 + 445 + func setupHandler(t *testing.T) (*handler.Handler, *db.DB) { 446 + t.Helper() 447 + f, _ := os.CreateTemp("", "diffdown-handler-*.db") 448 + t.Cleanup(func() { os.Remove(f.Name()) }) 449 + f.Close() 450 + database, _ := db.Open(f.Name()) 451 + db.SetMigrationsDir("../../migrations") 452 + database.Migrate() 453 + hub := collaboration.NewHub() 454 + h := handler.New(database, nil, "http://localhost:8080", hub) 455 + return h, database 456 + } 457 + 458 + func TestGetSteps_Empty(t *testing.T) { 459 + h, _ := setupHandler(t) 460 + req := httptest.NewRequest("GET", "/api/docs/rkey1/steps?since=0", nil) 461 + req.SetPathValue("rkey", "rkey1") 462 + rr := httptest.NewRecorder() 463 + // Inject a fake user into context so currentUser() passes. 464 + // (Reuse the test helper from existing handler tests if one exists, 465 + // or set the session key directly on the request context.) 466 + h.GetSteps(rr, req) // will return 401 without auth — adjust with auth helper 467 + // For now verify content-type at minimum: 468 + if rr.Code != http.StatusUnauthorized { 469 + t.Errorf("expected 401 without auth, got %d", rr.Code) 470 + } 471 + } 472 + 473 + func TestSubmitSteps_Success(t *testing.T) { 474 + h, d := setupHandler(t) 475 + _ = d // seed a user/session if needed for auth 476 + 477 + body, _ := json.Marshal(map[string]interface{}{ 478 + "clientVersion": 0, 479 + "steps": []string{`{"stepType":"replace"}`}, 480 + "clientID": "did:plc:test", 481 + }) 482 + req := httptest.NewRequest("POST", "/api/docs/rkey1/steps", bytes.NewReader(body)) 483 + req.SetPathValue("rkey", "rkey1") 484 + req.Header.Set("Content-Type", "application/json") 485 + rr := httptest.NewRecorder() 486 + h.SubmitSteps(rr, req) // 401 without auth — add auth helper before expanding 487 + if rr.Code != http.StatusUnauthorized { 488 + t.Errorf("expected 401, got %d", rr.Code) 489 + } 490 + } 491 + ``` 492 + 493 + > **Note:** These are skeleton tests that verify the 401 path. Full integration tests (with authenticated requests) require the existing `setUserContext` test helper from the handler package. Expand once you confirm the handlers build and the 401 path works. 494 + 495 + - [ ] **Step 2: Run tests** 496 + 497 + ```bash 498 + go test -v ./internal/handler/... 499 + ``` 500 + 501 + Expected: new tests PASS (401 assertions). 502 + 503 + - [ ] **Step 3: Commit** 504 + 505 + ```bash 506 + git add internal/handler/steps_test.go 507 + git commit -m "test(handler): skeleton tests for SubmitSteps and GetSteps" 508 + ``` 509 + 510 + --- 511 + 512 + ## Chunk 3: Client — Install `prosemirror-collab` 513 + 514 + ### Task 5: Add npm dependency and bundle 515 + 516 + **Files:** 517 + - Modify: `package.json` 518 + - Modify: `milkdown-entry.js` (re-export collab plugin for rich mode) 519 + - Create: `static/vendor/collab.js` (built artifact — gitignored or committed) 520 + 521 + `prosemirror-collab` is the official ProseMirror collaboration plugin. It exports `collab(config)`, `sendableSteps(state)`, and `receiveTransaction(state, steps, clientIDs)`. 522 + 523 + - [ ] **Step 1: Install the package** 524 + 525 + ```bash 526 + npm install prosemirror-collab prosemirror-state prosemirror-transform 527 + ``` 528 + 529 + - [ ] **Step 2: Verify the package is available** 530 + 531 + ```bash 532 + node -e "require('prosemirror-collab'); console.log('ok')" 533 + ``` 534 + 535 + Expected: `ok`. 536 + 537 + - [ ] **Step 3: Bundle a collab module for the browser** 538 + 539 + Add a build script to `package.json` (merge into existing scripts if present): 540 + 541 + ```json 542 + { 543 + "scripts": { 544 + "build:collab": "npx esbuild node_modules/prosemirror-collab/dist/index.js --bundle --format=esm --outfile=static/vendor/collab.js" 545 + } 546 + } 547 + ``` 548 + 549 + Run it: 550 + 551 + ```bash 552 + npm run build:collab 553 + ``` 554 + 555 + Expected: `static/vendor/collab.js` created (~20KB). 556 + 557 + - [ ] **Step 4: Confirm exports** 558 + 559 + ```bash 560 + node -e " 561 + const src = require('fs').readFileSync('static/vendor/collab.js','utf8'); 562 + ['collab','sendableSteps','receiveTransaction','getVersion'].forEach(name => { 563 + if (!src.includes(name)) throw new Error('missing: ' + name); 564 + }); 565 + console.log('all exports present'); 566 + " 567 + ``` 568 + 569 + - [ ] **Step 5: Commit** 570 + 571 + ```bash 572 + git add package.json package-lock.json static/vendor/collab.js 573 + git commit -m "feat(frontend): bundle prosemirror-collab for browser" 574 + ``` 575 + 576 + --- 577 + 578 + ## Chunk 4: Client — Source Mode (CodeMirror) Integration 579 + 580 + The source editor (CodeMirror) operates on plain text. `prosemirror-collab` works at the ProseMirror document level; for the source mode we keep the existing send-deltas-on-change approach but replace the full-document-replace on receive with a proper `ChangeSet`-based patch derived from confirmed steps. 581 + 582 + **Protocol for source mode:** 583 + 1. On local change: send `POST /api/docs/{rkey}/steps` with `[{type:"text-patch", from, to, insert}]` step objects (a lightweight custom step shape — source mode does not use PM's schema-aware steps). 584 + 2. On `409 Conflict`: fetch missed steps from response, apply them to local text first, then resubmit. 585 + 3. On WebSocket `steps` message: apply the text patches to the CM editor using a minimal `ChangeSet` that skips the sender's own steps. 586 + 587 + This is logically equivalent to `prosemirror-collab` but for a flat-text editor. It gives us the rebasing guarantee without requiring a ProseMirror schema in source mode. 588 + 589 + ### Task 6: Client-side collab state manager (`collab-client.js`) 590 + 591 + **Files:** 592 + - Create: `static/collab-client.js` 593 + 594 + This module encapsulates all step-submission and rebasing logic and is shared between source and rich modes. 595 + 596 + - [ ] **Step 1: Write the module** 597 + 598 + ```js 599 + // static/collab-client.js 600 + // 601 + // Lightweight step-authority client for Diffdown's prosemirror-collab protocol. 602 + // Works with both source (text-patch steps) and rich (PM steps) modes. 603 + 604 + export class CollabClient { 605 + /** 606 + * @param {string} rkey - Document rkey 607 + * @param {number} initialVersion - Version the client started at 608 + * @param {function(steps: object[]): void} applyRemoteSteps - Called when server confirms steps from others 609 + */ 610 + constructor(rkey, initialVersion, applyRemoteSteps) { 611 + this.rkey = rkey; 612 + this.version = initialVersion; 613 + this.applyRemoteSteps = applyRemoteSteps; 614 + this._inflight = false; 615 + this._queue = []; 616 + } 617 + 618 + /** 619 + * Queue local steps and attempt to flush to the server. 620 + * @param {object[]} steps - Array of step objects (text-patch or PM step JSON) 621 + */ 622 + sendSteps(steps) { 623 + this._queue.push(...steps); 624 + this._flush(); 625 + } 626 + 627 + async _flush() { 628 + if (this._inflight || this._queue.length === 0) return; 629 + this._inflight = true; 630 + const toSend = this._queue.slice(); 631 + try { 632 + const resp = await fetch(`/api/docs/${this.rkey}/steps`, { 633 + method: 'POST', 634 + headers: {'Content-Type': 'application/json'}, 635 + body: JSON.stringify({ 636 + clientVersion: this.version, 637 + steps: toSend.map(s => JSON.stringify(s)), 638 + clientID: this._clientID || '', 639 + }), 640 + }); 641 + 642 + if (resp.ok) { 643 + const {version} = await resp.json(); 644 + this.version = version; 645 + // Remove the steps we just confirmed. 646 + this._queue = this._queue.slice(toSend.length); 647 + } else if (resp.status === 409) { 648 + // Server has steps we haven't seen yet. 649 + const {version, steps: missedJSON} = await resp.json(); 650 + const missed = missedJSON.map(s => JSON.parse(s)); 651 + // Let the editor rebase local steps on top of missed ones. 652 + this.applyRemoteSteps(missed); 653 + this.version = version; 654 + // Don't clear _queue — resubmit after rebase. 655 + } else { 656 + console.error('CollabClient: unexpected status', resp.status); 657 + } 658 + } catch (e) { 659 + console.error('CollabClient: fetch error', e); 660 + } finally { 661 + this._inflight = false; 662 + if (this._queue.length > 0) { 663 + setTimeout(() => this._flush(), 50); 664 + } 665 + } 666 + } 667 + 668 + /** 669 + * Call when a WebSocket "steps" message arrives from the server. 670 + * Advances local version and notifies the editor. 671 + * @param {object} msg - {type:"steps", steps:[...], version:N, clientID:string} 672 + * @param {string} myClientID 673 + */ 674 + handleWSMessage(msg, myClientID) { 675 + if (msg.type !== 'steps') return; 676 + if (msg.clientID === myClientID) { 677 + // Our own steps confirmed — just advance version. 678 + this.version = msg.version; 679 + return; 680 + } 681 + const steps = msg.steps.map(s => JSON.parse(s)); 682 + this.version = msg.version; 683 + this.applyRemoteSteps(steps); 684 + } 685 + 686 + setClientID(id) { this._clientID = id; } 687 + } 688 + ``` 689 + 690 + - [ ] **Step 2: Verify it parses without errors** 691 + 692 + ```bash 693 + node --input-type=module < static/collab-client.js 2>&1 || true 694 + ``` 695 + 696 + Expected: no syntax errors (will emit a fetch-not-defined error at runtime in Node, that's fine). 697 + 698 + - [ ] **Step 3: Commit** 699 + 700 + ```bash 701 + git add static/collab-client.js 702 + git commit -m "feat(frontend): CollabClient — step-submission and rebase coordinator" 703 + ``` 704 + 705 + --- 706 + 707 + ### Task 7: Wire `CollabClient` into source mode (CodeMirror) 708 + 709 + **Files:** 710 + - Modify: `templates/document_edit.html` 711 + 712 + The existing source-mode flow: 713 + 1. `update.changes.iterChanges` → `queueDeltas` → WebSocket `edit` message 714 + 2. `handleWSMessage({type:'edit'})` → `applyRemoteEdit` → full CM replace 715 + 716 + New flow: 717 + 1. `update.changes.iterChanges` → `CollabClient.sendSteps` (text-patch steps via HTTP) 718 + 2. WebSocket `steps` message → `CollabClient.handleWSMessage` → `applyTextPatchSteps` (apply only the changed ranges) 719 + 720 + - [ ] **Step 1: Fetch server version on page load** 721 + 722 + At the top of the `<script type="module">` block, after constants, add: 723 + 724 + ```js 725 + import { CollabClient } from '/static/collab-client.js'; 726 + 727 + // Fetch the authoritative version for this document. 728 + let serverVersion = 0; 729 + try { 730 + const vResp = await fetch(`/api/docs/${rkey}/steps?since=-1`); 731 + if (vResp.ok) { 732 + const vData = await vResp.json(); 733 + serverVersion = vData.version || 0; 734 + } 735 + } catch(e) { /* start at 0 */ } 736 + 737 + const myClientID = accessToken || Math.random().toString(36).slice(2); 738 + ``` 739 + 740 + - [ ] **Step 2: Replace `queueDeltas` in source mode with `CollabClient.sendSteps`** 741 + 742 + Replace the `EditorView.updateListener.of` callback block for source mode: 743 + 744 + Old code (in `document_edit.html`, inside the CM `updateListener`): 745 + ```js 746 + if (deltas.length > 0) { 747 + queueDeltas(deltas); 748 + } 749 + ``` 750 + 751 + New code — instead of `queueDeltas`, call `collabClient.sendSteps`: 752 + ```js 753 + if (deltas.length > 0 && currentMode === 'source') { 754 + const pmSteps = deltas.map(d => ({type: 'text-patch', from: d.from, to: d.to, insert: d.insert})); 755 + collabClient.sendSteps(pmSteps); 756 + } 757 + ``` 758 + 759 + - [ ] **Step 3: Initialize `collabClient` after `cmView` is created** 760 + 761 + ```js 762 + const collabClient = new CollabClient(rkey, serverVersion, (remoteSteps) => { 763 + // Apply text-patch steps to CM without triggering our own send. 764 + if (currentMode !== 'source' || !cmView) return; 765 + const changes = []; 766 + let offset = 0; 767 + for (const step of remoteSteps) { 768 + if (step.type !== 'text-patch') continue; 769 + const from = step.from + offset; 770 + const to = step.to + offset; 771 + const insert = step.insert || ''; 772 + changes.push({ from, to, insert }); 773 + offset += insert.length - (step.to - step.from); 774 + } 775 + if (changes.length === 0) return; 776 + applyingRemote = true; 777 + try { 778 + cmView.dispatch({ 779 + changes, 780 + annotations: [remoteEditAnnotation.of(true)], 781 + }); 782 + } finally { 783 + applyingRemote = false; 784 + } 785 + }); 786 + collabClient.setClientID(myClientID); 787 + ``` 788 + 789 + - [ ] **Step 4: Wire WebSocket messages into `CollabClient`** 790 + 791 + In `handleWSMessage`, add a case for `'steps'`: 792 + 793 + ```js 794 + case 'steps': 795 + collabClient.handleWSMessage(msg, myClientID); 796 + break; 797 + ``` 798 + 799 + Keep the existing `'edit'` case temporarily (for backward compat during rollout), but stop relying on `msg.content` for source mode when `msg.type === 'edit'` AND `msg.steps` is present. 800 + 801 + - [ ] **Step 5: Build and manually test source mode collab** 802 + 803 + ```bash 804 + make run 805 + ``` 806 + 807 + Open two browser tabs on the same document (two different accounts). Type in source mode in tab 1 — verify tab 2 updates without full-document flash. Cursor position in tab 2 should not reset. 808 + 809 + - [ ] **Step 6: Commit** 810 + 811 + ```bash 812 + git add templates/document_edit.html 813 + git commit -m "feat(frontend): source mode uses CollabClient step protocol instead of full-replace" 814 + ``` 815 + 816 + --- 817 + 818 + ## Chunk 5: Client — Rich Mode (Milkdown/ProseMirror) Integration 819 + 820 + Milkdown wraps ProseMirror. The `prosemirror-collab` plugin integrates directly at the ProseMirror `EditorState` level using `collab({version})` and `sendableSteps(state)` / `receiveTransaction(state, steps, clientIDs)`. 821 + 822 + This replaces the `createMilkdownEditor(content)` full-recreate on remote edit. 823 + 824 + ### Task 8: Wire `prosemirror-collab` plugin into Milkdown 825 + 826 + **Files:** 827 + - Modify: `templates/document_edit.html` 828 + - Modify: `milkdown-entry.js` (re-export PM collab helpers) 829 + 830 + - [ ] **Step 1: Re-export collab helpers from `milkdown-entry.js`** 831 + 832 + `milkdown-entry.js` is bundled into `static/vendor/milkdown.js`. Add the collab exports: 833 + 834 + ```js 835 + // milkdown-entry.js — add at bottom: 836 + export { collab, sendableSteps, receiveTransaction, getVersion } from 'prosemirror-collab'; 837 + export { Step } from 'prosemirror-transform'; 838 + ``` 839 + 840 + Rebuild: 841 + 842 + ```bash 843 + npx esbuild milkdown-entry.js --bundle --format=esm --minify --outfile=static/vendor/milkdown.js \ 844 + node_modules/prosemirror-collab/dist/index.js \ 845 + node_modules/prosemirror-transform/dist/index.js 846 + ``` 847 + 848 + > Check your existing esbuild command in `package.json` or `Dockerfile` and update accordingly. The Dockerfile uses `npx esbuild milkdown-entry.js ...` — update that line too. 849 + 850 + - [ ] **Step 2: Import in the editor script** 851 + 852 + In `document_edit.html`, update the Milkdown import line: 853 + 854 + ```js 855 + import { 856 + Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx, 857 + commonmark, 858 + listener, listenerCtx, 859 + history, undoCommand, redoCommand, callCommand, 860 + collab, sendableSteps, receiveTransaction, getVersion, Step, 861 + } from '/static/vendor/milkdown.js'; 862 + ``` 863 + 864 + - [ ] **Step 3: Replace `createMilkdownEditor` to use `collab` plugin** 865 + 866 + The key change: pass `collab({version: serverVersion})` as a ProseMirror plugin. When `markdownUpdated` fires (local edit), use `sendableSteps` to get the pending steps and send them through `CollabClient`. When remote steps arrive, use `receiveTransaction` to apply them to the PM state surgically — **no re-create**. 867 + 868 + ```js 869 + async function createMilkdownEditor(initialMarkdown) { 870 + const container = document.getElementById('editor-rich'); 871 + container.innerHTML = ''; 872 + 873 + milkdownEditor = await Editor.make() 874 + .config((ctx) => { 875 + ctx.set(rootCtx, container); 876 + ctx.set(defaultValueCtx, initialMarkdown); 877 + }) 878 + .use(commonmark) 879 + .use(history) 880 + .use(listener) 881 + .config((ctx) => { 882 + ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => { 883 + if (markdown === prevMarkdown || applyingRemote) return; 884 + scheduleAutoSave(markdown); 885 + // Use prosemirror-collab to extract pending steps. 886 + const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 887 + const sendable = sendableSteps(pmView.state); 888 + if (sendable) { 889 + const stepsJSON = sendable.steps.map(s => JSON.stringify(s.toJSON())); 890 + collabClient.sendSteps(stepsJSON.map(j => ({type: 'pm-step', json: j}))); 891 + } 892 + }); 893 + }) 894 + .create(); 895 + 896 + return milkdownEditor; 897 + } 898 + ``` 899 + 900 + - [ ] **Step 4: Apply remote PM steps without re-creating the editor** 901 + 902 + Update `CollabClient`'s `applyRemoteSteps` callback for rich mode: 903 + 904 + ```js 905 + // In the CollabClient constructor call (Task 7), update the callback to handle rich mode: 906 + const collabClient = new CollabClient(rkey, serverVersion, (remoteSteps) => { 907 + if (currentMode === 'source' && cmView) { 908 + // ... existing text-patch logic from Task 7 ... 909 + } else if (currentMode === 'rich' && milkdownEditor) { 910 + const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 911 + const schema = pmView.state.schema; 912 + const pmSteps = []; 913 + const clientIDs = []; 914 + for (const step of remoteSteps) { 915 + if (step.type !== 'pm-step') continue; 916 + try { 917 + pmSteps.push(Step.fromJSON(schema, JSON.parse(step.json))); 918 + clientIDs.push('remote'); 919 + } catch(e) { 920 + console.warn('CollabClient: failed to parse step', e); 921 + } 922 + } 923 + if (pmSteps.length === 0) return; 924 + applyingRemote = true; 925 + try { 926 + const tr = receiveTransaction(pmView.state, pmSteps, clientIDs); 927 + pmView.dispatch(tr); 928 + } finally { 929 + applyingRemote = false; 930 + } 931 + } 932 + }); 933 + ``` 934 + 935 + - [ ] **Step 5: Remove `createMilkdownEditor(content)` call in `applyRemoteEdit`** 936 + 937 + The old `applyRemoteEdit` called `createMilkdownEditor(content)` (full recreate) for rich mode. Now that `CollabClient` handles remote steps, remove that branch. The `applyRemoteEdit` function should only handle the legacy `sync` message type as a fallback: 938 + 939 + ```js 940 + function applyRemoteEdit(msg) { 941 + // Legacy sync fallback — only used on initial join if the room sends full content. 942 + if (applyingRemote) return; 943 + const content = typeof msg === 'string' ? msg : msg.content; 944 + if (!content) return; 945 + 946 + if (currentMode === 'source' && cmView) { 947 + if (cmView.state.doc.toString() !== content) { 948 + applyingRemote = true; 949 + try { 950 + cmView.dispatch({ 951 + changes: { from: 0, to: cmView.state.doc.length, insert: content }, 952 + annotations: [remoteEditAnnotation.of(true)], 953 + }); 954 + } finally { 955 + applyingRemote = false; 956 + } 957 + } 958 + } 959 + // Rich mode no longer falls back to full recreate. 960 + } 961 + ``` 962 + 963 + - [ ] **Step 6: Build and manually test rich mode collab** 964 + 965 + ```bash 966 + npm run build:collab 967 + make build && make run 968 + ``` 969 + 970 + Open two tabs, both in rich mode. Type in one — verify the other updates without the editor flicker (no DOM teardown). Undo in each tab should only undo local edits. 971 + 972 + - [ ] **Step 7: Commit** 973 + 974 + ```bash 975 + git add milkdown-entry.js static/vendor/milkdown.js static/vendor/collab.js templates/document_edit.html 976 + git commit -m "feat(frontend): rich mode uses prosemirror-collab steps, no full editor recreate" 977 + ``` 978 + 979 + --- 980 + 981 + ## Chunk 6: Cleanup and Deprecate Old Code 982 + 983 + ### Task 9: Remove the custom OT engine (server) 984 + 985 + Once `prosemirror-collab` is the only collab protocol in production, the custom `OTEngine` and the `edit`-type WebSocket message handler become dead code. 986 + 987 + - [ ] **Step 1: Verify no active callers** 988 + 989 + ```bash 990 + grep -rn "OTEngine\|ApplyEdits\|applyInternal\|type.*edit.*delta" internal/ templates/ --include="*.go" --include="*.html" 991 + ``` 992 + 993 + Review the output. If `ApplyEdits` or `OTEngine` are still called by the WebSocket handler (`client.go`), leave them in place and just stop broadcasting `edit`-type messages; otherwise delete. 994 + 995 + - [ ] **Step 2: Remove `ot.go` and `ot_test.go` if unused** 996 + 997 + ```bash 998 + # Only if grep above shows zero active callers: 999 + git rm internal/collaboration/ot.go internal/collaboration/ot_test.go 1000 + ``` 1001 + 1002 + - [ ] **Step 3: Remove `ot *OTEngine` field from `Room`** 1003 + 1004 + In `internal/collaboration/hub.go`, delete: 1005 + - `ot *OTEngine` field on `Room` 1006 + - `NewOTEngine("")` call in `GetOrCreateRoom` 1007 + - `ApplyEdits` method on `Room` 1008 + - `SeedText`, `IsNewRoom` methods (no longer needed — initial version is fetched from DB) 1009 + 1010 + - [ ] **Step 4: Remove `seeded` guard from WebSocket handler** 1011 + 1012 + In `internal/handler/handler.go`, in `CollaboratorWebSocket`, delete the `room.IsNewRoom()` / `room.SeedText(initialText)` block. The server no longer needs to seed an in-memory text state. 1013 + 1014 + - [ ] **Step 5: Remove legacy `edit`-type message handling from `client.go`** 1015 + 1016 + In `internal/collaboration/client.go`, delete the `case "edit":` branch in `ReadPump`. The WebSocket channel now only carries `steps`, `presence`, and `ping/pong`. 1017 + 1018 + - [ ] **Step 6: Remove `queueDeltas`, `diffToOps`, `sendEdit`, `pendingDeltas` from frontend** 1019 + 1020 + In `templates/document_edit.html`, delete: 1021 + - `function diffToOps(...)` (~60 lines) 1022 + - `function queueDeltas(...)` (~15 lines) 1023 + - `function sendEdit(...)` (~5 lines) 1024 + - `let pendingDeltas`, `let wsEditTimer` 1025 + 1026 + - [ ] **Step 7: Build and run all tests** 1027 + 1028 + ```bash 1029 + make build 1030 + make test 1031 + ``` 1032 + 1033 + Expected: all tests PASS, no compile errors. 1034 + 1035 + - [ ] **Step 8: Commit** 1036 + 1037 + ```bash 1038 + git add -A 1039 + git commit -m "chore: remove custom OT engine and legacy delta WebSocket protocol" 1040 + ``` 1041 + 1042 + --- 1043 + 1044 + ### Task 10: Update Dockerfile build step for new bundles 1045 + 1046 + **Files:** 1047 + - Modify: `Dockerfile` 1048 + 1049 + The Dockerfile has a `jsbuilder` stage with an `esbuild` command. The `milkdown-entry.js` bundle command needs the new `prosemirror-collab` and `prosemirror-transform` packages in scope. 1050 + 1051 + - [ ] **Step 1: Check current jsbuilder stage** 1052 + 1053 + ```bash 1054 + grep -A 20 "jsbuilder" Dockerfile 1055 + ``` 1056 + 1057 + - [ ] **Step 2: Add collab bundle step** 1058 + 1059 + After the existing `RUN npx esbuild milkdown-entry.js ...` line, add: 1060 + 1061 + ```dockerfile 1062 + RUN npx esbuild node_modules/prosemirror-collab/dist/index.js \ 1063 + --bundle --format=esm --minify --outfile=collab.min.js 1064 + ``` 1065 + 1066 + And in the `COPY --from=jsbuilder` lines in the final stage, add: 1067 + 1068 + ```dockerfile 1069 + COPY --from=jsbuilder /build/collab.min.js ./static/vendor/collab.js 1070 + ``` 1071 + 1072 + - [ ] **Step 3: Build Docker image locally** 1073 + 1074 + ```bash 1075 + docker build -t diffdown-test . 1076 + ``` 1077 + 1078 + Expected: successful build, no missing module errors. 1079 + 1080 + - [ ] **Step 4: Commit** 1081 + 1082 + ```bash 1083 + git add Dockerfile 1084 + git commit -m "chore(docker): build prosemirror-collab bundle in jsbuilder stage" 1085 + ``` 1086 + 1087 + --- 1088 + 1089 + ## Chunk 7: End-to-End Testing 1090 + 1091 + ### Task 11: Manual collab regression checklist 1092 + 1093 + Run through these scenarios with two browser windows logged in as different users on the same document: 1094 + 1095 + - [ ] **Scenario A — Concurrent typing (source mode)** 1096 + - Both users type simultaneously. After a brief pause, both editors show identical text. 1097 + - **Pass condition:** No full-page flash, no cursor jump in the non-typing window. 1098 + 1099 + - [ ] **Scenario B — Concurrent typing (rich mode)** 1100 + - Same as A but in rich mode. Bold/italic marks applied by one user appear in the other. 1101 + - **Pass condition:** Editor does not tear down and recreate on each remote step. 1102 + 1103 + - [ ] **Scenario C — Undo isolation** 1104 + - User A types "hello", User B types "world". User A presses undo. 1105 + - **Pass condition:** Only "hello" is removed; "world" remains. 1106 + 1107 + - [ ] **Scenario D — Network reconnect** 1108 + - User A types while User B's tab is in the background (WebSocket dormant). 1109 + - User B foregrounds — page catches up via `GET /api/docs/{rkey}/steps?since={v}`. 1110 + - **Pass condition:** B's editor converges without a reload. 1111 + 1112 + - [ ] **Scenario E — Single user, no collaborators** 1113 + - Open a document alone. Type normally. Auto-save fires. 1114 + - **Pass condition:** Behavior is identical to before; no regressions. 1115 + 1116 + --- 1117 + 1118 + ### Task 12: Deploy to staging 1119 + 1120 + - [ ] **Step 1: Push to origin** 1121 + 1122 + ```bash 1123 + git push origin main 1124 + ``` 1125 + 1126 + - [ ] **Step 2: Deploy to staging** 1127 + 1128 + ```bash 1129 + flyctl deploy --config fly-staging.toml 1130 + ``` 1131 + 1132 + - [ ] **Step 3: Run manual regression checklist against staging** 1133 + 1134 + Repeat the scenarios in Task 11 against `https://staging.diffdown.com`. 1135 + 1136 + - [ ] **Step 4: Deploy to production** 1137 + 1138 + ```bash 1139 + flyctl deploy --config fly-production.toml 1140 + ``` 1141 + 1142 + --- 1143 + 1144 + ## Appendix: Protocol Reference 1145 + 1146 + ### WebSocket message types (post-migration) 1147 + 1148 + | Direction | Type | Payload | Purpose | 1149 + |---|---|---|---| 1150 + | Server → Client | `steps` | `{steps: string[], version: int, clientID: string}` | Broadcast confirmed steps to room peers | 1151 + | Server → Client | `presence` | `{users: [{did, name, color}]}` | Room membership changes | 1152 + | Server → Client | `pong` | `{}` | Keepalive reply | 1153 + | Client → Server | `ping` | `{}` | Keepalive | 1154 + 1155 + ### HTTP endpoints (post-migration) 1156 + 1157 + | Method | Path | Purpose | 1158 + |---|---|---| 1159 + | `POST` | `/api/docs/{rkey}/steps` | Submit steps; 200 on accept, 409 on conflict | 1160 + | `GET` | `/api/docs/{rkey}/steps?since={v}` | Fetch missed steps | 1161 + 1162 + ### Step shapes 1163 + 1164 + **Source mode** (text-patch): 1165 + ```json 1166 + {"type": "text-patch", "from": 5, "to": 8, "insert": "foo"} 1167 + ``` 1168 + 1169 + **Rich mode** (PM step): 1170 + ```json 1171 + {"type": "pm-step", "json": "{\"stepType\":\"replace\",...}"} 1172 + ```
+5 -38
internal/collaboration/client.go
··· 18 18 } 19 19 20 20 type ClientMessage struct { 21 - Type string `json:"type"` 22 - RKey string `json:"rkey,omitempty"` 23 - DID string `json:"did,omitempty"` 24 - // Deltas is the new plural field — a single edit message may carry 25 - // multiple operations (e.g. one per CodeMirror ChangeDesc). 26 - Deltas []Operation `json:"deltas,omitempty"` 27 - // Delta is the legacy singular field. Kept for backward compatibility. 28 - Delta json.RawMessage `json:"delta,omitempty"` 29 - Cursor *CursorPos `json:"cursor,omitempty"` 30 - Comment *CommentMsg `json:"comment,omitempty"` 31 - } 32 - 33 - // Operations returns the ops from this message, preferring Deltas over the 34 - // legacy singular Delta field. 35 - func (m *ClientMessage) Operations() []Operation { 36 - if len(m.Deltas) > 0 { 37 - return m.Deltas 38 - } 39 - if len(m.Delta) > 0 { 40 - var op Operation 41 - if err := json.Unmarshal(m.Delta, &op); err == nil { 42 - return []Operation{op} 43 - } 44 - } 45 - return nil 21 + Type string `json:"type"` 22 + RKey string `json:"rkey,omitempty"` 23 + DID string `json:"did,omitempty"` 24 + Cursor *CursorPos `json:"cursor,omitempty"` 25 + Comment *CommentMsg `json:"comment,omitempty"` 46 26 } 47 27 48 28 type CursorPos struct { ··· 100 80 } 101 81 102 82 switch msg.Type { 103 - case "edit": 104 - ops := msg.Operations() 105 - if len(ops) == 0 { 106 - continue 107 - } 108 - room := c.hub.GetRoom(c.roomKey) 109 - if room == nil { 110 - continue 111 - } 112 - for i := range ops { 113 - ops[i].Author = c.DID 114 - } 115 - room.ApplyEdits(ops, c) 116 83 case "ping": 117 84 pong, _ := json.Marshal(map[string]string{"type": "pong"}) 118 85 c.send <- pong
-50
internal/collaboration/client_test.go
··· 1 - package collaboration 2 - 3 - import ( 4 - "encoding/json" 5 - "testing" 6 - ) 7 - 8 - func TestClientMessage_ParseDeltas_Multiple(t *testing.T) { 9 - raw := `{"type":"edit","deltas":[{"from":0,"to":5,"insert":"hello"},{"from":10,"to":10,"insert":"!"}]}` 10 - var msg ClientMessage 11 - if err := json.Unmarshal([]byte(raw), &msg); err != nil { 12 - t.Fatalf("unmarshal: %v", err) 13 - } 14 - if len(msg.Deltas) != 2 { 15 - t.Fatalf("expected 2 deltas, got %d", len(msg.Deltas)) 16 - } 17 - if msg.Deltas[0].From != 0 || msg.Deltas[0].To != 5 || msg.Deltas[0].Insert != "hello" { 18 - t.Errorf("delta[0]: %+v", msg.Deltas[0]) 19 - } 20 - if msg.Deltas[1].From != 10 || msg.Deltas[1].Insert != "!" { 21 - t.Errorf("delta[1]: %+v", msg.Deltas[1]) 22 - } 23 - } 24 - 25 - func TestClientMessage_ParseDeltas_SingleFallback(t *testing.T) { 26 - // Old wire format: singular "delta" field — must still work. 27 - raw := `{"type":"edit","delta":{"from":3,"to":7,"insert":"xyz"}}` 28 - var msg ClientMessage 29 - if err := json.Unmarshal([]byte(raw), &msg); err != nil { 30 - t.Fatalf("unmarshal: %v", err) 31 - } 32 - ops := msg.Operations() 33 - if len(ops) != 1 { 34 - t.Fatalf("expected 1 op from fallback, got %d", len(ops)) 35 - } 36 - if ops[0].From != 3 || ops[0].To != 7 || ops[0].Insert != "xyz" { 37 - t.Errorf("op: %+v", ops[0]) 38 - } 39 - } 40 - 41 - func TestClientMessage_ParseDeltas_EmptyDeltas(t *testing.T) { 42 - raw := `{"type":"edit","deltas":[]}` 43 - var msg ClientMessage 44 - if err := json.Unmarshal([]byte(raw), &msg); err != nil { 45 - t.Fatalf("unmarshal: %v", err) 46 - } 47 - if len(msg.Operations()) != 0 { 48 - t.Errorf("expected 0 ops for empty deltas") 49 - } 50 - }
-62
internal/collaboration/hub.go
··· 18 18 register chan *Client 19 19 unregister chan *Client 20 20 mu sync.RWMutex 21 - ot *OTEngine 22 - seeded bool // true after SeedText has been called 23 21 } 24 22 25 23 // broadcastMsg carries a message and an optional sender to exclude. ··· 46 44 broadcast: make(chan *broadcastMsg, 256), 47 45 register: make(chan *Client), 48 46 unregister: make(chan *Client), 49 - ot: NewOTEngine(""), 50 47 } 51 48 h.rooms[rkey] = room 52 49 go room.run() ··· 103 100 r.broadcast <- &broadcastMsg{data: data, except: except} 104 101 } 105 102 106 - // ApplyEdits applies a sequence of operations in order and broadcasts one 107 - // combined message to all other clients. Each op is applied to the text 108 - // resulting from the previous op, so positions in each op must be relative 109 - // to the document state after all prior ops in the same batch have been 110 - // applied — which is exactly what CodeMirror's iterChanges produces (fromA/toA 111 - // are positions in the pre-change document, already adjusted for prior changes 112 - // within the same transaction by CodeMirror itself). 113 - func (r *Room) ApplyEdits(ops []Operation, sender *Client) { 114 - if len(ops) == 0 { 115 - return 116 - } 117 - 118 - // Capture the text returned by the final ApplyWithVersion so we don't 119 - // race against another goroutine calling GetText() separately. 120 - var finalText string 121 - for i := range ops { 122 - finalText, _ = r.ot.ApplyWithVersion(ops[i]) 123 - } 124 - 125 - // Include the full document text so receivers can detect and recover from 126 - // divergence without a reconnect. 127 - type editsMsg struct { 128 - Type string `json:"type"` 129 - Deltas []Operation `json:"deltas"` 130 - Author string `json:"author"` 131 - Content string `json:"content"` 132 - } 133 - msg := editsMsg{ 134 - Type: "edit", 135 - Deltas: ops, 136 - Author: sender.DID, 137 - Content: finalText, 138 - } 139 - data, err := json.Marshal(msg) 140 - if err != nil { 141 - log.Printf("ApplyEdits: marshal: %v", err) 142 - return 143 - } 144 - r.BroadcastExcept(data, sender) 145 - } 146 - 147 103 func (r *Room) RegisterClient(client *Client) { 148 104 r.register <- client 149 105 } ··· 178 134 } 179 135 r.Broadcast(data) 180 136 } 181 - 182 - // IsNewRoom returns true if SeedText has not yet been called on this room. 183 - func (r *Room) IsNewRoom() bool { 184 - r.mu.RLock() 185 - defer r.mu.RUnlock() 186 - return !r.seeded 187 - } 188 - 189 - // SeedText sets the initial document text for the OT engine. 190 - // Idempotent — only the first call has effect. 191 - func (r *Room) SeedText(text string) { 192 - r.mu.Lock() 193 - defer r.mu.Unlock() 194 - if !r.seeded { 195 - r.ot.SetText(text) 196 - r.seeded = true 197 - } 198 - }
-174
internal/collaboration/hub_test.go
··· 252 252 } 253 253 } 254 254 255 - // --- Room ApplyEdits tests --- 256 - 257 - func TestRoom_ApplyEdit_BroadcastsToOthers(t *testing.T) { 258 - hub := NewHub() 259 - room := hub.GetOrCreateRoom("doc-edit") 260 - 261 - alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-edit") 262 - bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-edit") 263 - 264 - room.RegisterClient(alice) 265 - room.RegisterClient(bob) 266 - time.Sleep(100 * time.Millisecond) 267 - drain(alice, 200*time.Millisecond) 268 - drain(bob, 200*time.Millisecond) 269 - 270 - room.ApplyEdits([]Operation{{From: 0, To: -1, Insert: "hello world"}}, alice) 271 - 272 - // Bob should receive the edit message; alice should not 273 - bobMsgs := waitForMessages(bob, 1, time.Second) 274 - aliceMsgs := drain(alice, 300*time.Millisecond) 275 - 276 - if len(bobMsgs) == 0 { 277 - t.Fatal("bob: expected edit message, got none") 278 - } 279 - if len(aliceMsgs) > 0 { 280 - t.Errorf("alice: should not receive her own edit, got %d messages", len(aliceMsgs)) 281 - } 282 - 283 - var msg map[string]interface{} 284 - if err := json.Unmarshal(bobMsgs[0], &msg); err != nil { 285 - t.Fatalf("unmarshal edit msg: %v", err) 286 - } 287 - if msg["type"] != "edit" { 288 - t.Errorf("expected type=edit, got %q", msg["type"]) 289 - } 290 - if msg["author"] != "did:plc:alice" { 291 - t.Errorf("expected author did:plc:alice, got %q", msg["author"]) 292 - } 293 - if msg["content"] != "hello world" { 294 - t.Errorf("expected content %q, got %q", "hello world", msg["content"]) 295 - } 296 - } 297 - 298 - func TestRoom_ApplyEdit_UpdatesOTState(t *testing.T) { 299 - hub := NewHub() 300 - room := hub.GetOrCreateRoom("doc-ot-state") 301 - 302 - alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-ot-state") 303 - room.RegisterClient(alice) 304 - time.Sleep(100 * time.Millisecond) 305 - drain(alice, 200*time.Millisecond) 306 - 307 - room.ApplyEdits([]Operation{{From: 0, To: -1, Insert: "first"}}, alice) 308 - room.ApplyEdits([]Operation{{From: 5, To: 5, Insert: " second"}}, alice) 309 - 310 - // Give room goroutine time to process 311 - time.Sleep(100 * time.Millisecond) 312 - 313 - if got := room.ot.GetText(); got != "first second" { 314 - t.Errorf("OT state: got %q, want %q", got, "first second") 315 - } 316 - } 317 - 318 - func TestRoom_ApplyEdits_MultipleOpsAppliedInOrder(t *testing.T) { 319 - hub := NewHub() 320 - room := hub.GetOrCreateRoom("doc-multi-ops") 321 - 322 - alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ops") 323 - bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-multi-ops") 324 - room.RegisterClient(alice) 325 - room.RegisterClient(bob) 326 - time.Sleep(100 * time.Millisecond) 327 - drain(alice, 200*time.Millisecond) 328 - drain(bob, 200*time.Millisecond) 329 - 330 - ops := []Operation{ 331 - {From: 0, To: -1, Insert: "hello"}, 332 - {From: 5, To: 5, Insert: " world"}, 333 - } 334 - room.ApplyEdits(ops, alice) 335 - 336 - bobMsgs := waitForMessages(bob, 1, time.Second) 337 - if len(bobMsgs) == 0 { 338 - t.Fatal("bob: expected edit message") 339 - } 340 - 341 - var msg struct { 342 - Type string `json:"type"` 343 - Deltas []Operation `json:"deltas"` 344 - Content string `json:"content"` 345 - Author string `json:"author"` 346 - } 347 - if err := json.Unmarshal(bobMsgs[0], &msg); err != nil { 348 - t.Fatalf("unmarshal: %v", err) 349 - } 350 - if msg.Type != "edit" { 351 - t.Errorf("type: got %q, want edit", msg.Type) 352 - } 353 - if msg.Content != "hello world" { 354 - t.Errorf("content: got %q, want %q", msg.Content, "hello world") 355 - } 356 - if len(msg.Deltas) != 2 { 357 - t.Errorf("deltas: got %d, want 2", len(msg.Deltas)) 358 - } 359 - if msg.Author != "did:plc:alice" { 360 - t.Errorf("author: got %q", msg.Author) 361 - } 362 - } 363 - 364 - func TestRoom_ApplyEdits_UpdatesOTState(t *testing.T) { 365 - hub := NewHub() 366 - room := hub.GetOrCreateRoom("doc-multi-ot") 367 - alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ot") 368 - room.RegisterClient(alice) 369 - time.Sleep(100 * time.Millisecond) 370 - drain(alice, 200*time.Millisecond) 371 - 372 - room.ApplyEdits([]Operation{ 373 - {From: 0, To: -1, Insert: "abc"}, 374 - {From: 3, To: 3, Insert: "def"}, 375 - }, alice) 376 - 377 - time.Sleep(50 * time.Millisecond) 378 - if got := room.ot.GetText(); got != "abcdef" { 379 - t.Errorf("OT state: got %q, want %q", got, "abcdef") 380 - } 381 - } 382 - 383 - func TestRoom_ApplyEdits_EmptyOpsIsNoop(t *testing.T) { 384 - hub := NewHub() 385 - room := hub.GetOrCreateRoom("doc-empty-ops") 386 - alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-empty-ops") 387 - bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-empty-ops") 388 - room.RegisterClient(alice) 389 - room.RegisterClient(bob) 390 - time.Sleep(100 * time.Millisecond) 391 - drain(alice, 200*time.Millisecond) 392 - drain(bob, 200*time.Millisecond) 393 - 394 - room.ApplyEdits(nil, alice) 395 - 396 - bobMsgs := drain(bob, 300*time.Millisecond) 397 - if len(bobMsgs) > 0 { 398 - t.Errorf("expected no broadcast for empty ops, got %d messages", len(bobMsgs)) 399 - } 400 - } 401 - 402 - // --- SeedText / IsNewRoom --- 403 - 404 - func TestRoom_SeedText_SetsInitialOTState(t *testing.T) { 405 - hub := NewHub() 406 - room := hub.GetOrCreateRoom("doc-seed") 407 - if !room.IsNewRoom() { 408 - t.Fatal("new room should report IsNewRoom=true") 409 - } 410 - room.SeedText("initial document content") 411 - if room.IsNewRoom() { 412 - t.Error("IsNewRoom should be false after seeding") 413 - } 414 - if got := room.ot.GetText(); got != "initial document content" { 415 - t.Errorf("OT text after seed: got %q, want %q", got, "initial document content") 416 - } 417 - } 418 - 419 - func TestRoom_SeedText_IdempotentAfterFirstCall(t *testing.T) { 420 - hub := NewHub() 421 - room := hub.GetOrCreateRoom("doc-seed-idem") 422 - room.SeedText("first") 423 - room.SeedText("second") // should be ignored 424 - if got := room.ot.GetText(); got != "first" { 425 - t.Errorf("second SeedText should be ignored: got %q, want %q", got, "first") 426 - } 427 - } 428 - 429 255 // --- GetPresence --- 430 256 431 257 func TestRoom_GetPresence_Empty(t *testing.T) {
-94
internal/collaboration/ot.go
··· 1 - package collaboration 2 - 3 - import "sync" 4 - 5 - type OTEngine struct { 6 - mu sync.Mutex 7 - documentText string 8 - version int 9 - } 10 - 11 - func NewOTEngine(initialText string) *OTEngine { 12 - return &OTEngine{ 13 - documentText: initialText, 14 - version: 0, 15 - } 16 - } 17 - 18 - type Operation struct { 19 - From int `json:"from"` 20 - To int `json:"to"` 21 - Insert string `json:"insert"` 22 - // Author is set by the client and used for logging/debugging purposes 23 - // to identify which user performed an operation 24 - Author string `json:"author"` 25 - } 26 - 27 - func (ot *OTEngine) Apply(op Operation) string { 28 - ot.mu.Lock() 29 - defer ot.mu.Unlock() 30 - 31 - newText, _ := ot.applyInternal(op) 32 - return newText 33 - } 34 - 35 - func (ot *OTEngine) ApplyWithVersion(op Operation) (string, int) { 36 - ot.mu.Lock() 37 - defer ot.mu.Unlock() 38 - 39 - newText, changed := ot.applyInternal(op) 40 - if !changed { 41 - return newText, ot.version 42 - } 43 - return newText, ot.version 44 - } 45 - 46 - func (ot *OTEngine) applyInternal(op Operation) (string, bool) { 47 - if op.From < 0 { 48 - op.From = 0 49 - } 50 - // -1 means "end of document" — treat as a full replacement when From==0. 51 - if op.To < 0 { 52 - op.To = len(ot.documentText) 53 - } 54 - if op.From > len(ot.documentText) { 55 - op.From = len(ot.documentText) 56 - } 57 - if op.To > len(ot.documentText) { 58 - op.To = len(ot.documentText) 59 - } 60 - if op.From > op.To { 61 - return ot.documentText, false 62 - } 63 - 64 - // True no-op: nothing deleted and nothing inserted. 65 - if op.From == op.To && op.Insert == "" { 66 - return ot.documentText, false 67 - } 68 - 69 - newText := ot.documentText[:op.From] + op.Insert + ot.documentText[op.To:] 70 - ot.documentText = newText 71 - ot.version++ 72 - 73 - return newText, true 74 - } 75 - 76 - // SetText replaces the canonical document text without incrementing the version. 77 - // Call this once when a room is first created, before any edits are applied. 78 - func (ot *OTEngine) SetText(text string) { 79 - ot.mu.Lock() 80 - defer ot.mu.Unlock() 81 - ot.documentText = text 82 - } 83 - 84 - func (ot *OTEngine) GetText() string { 85 - ot.mu.Lock() 86 - defer ot.mu.Unlock() 87 - return ot.documentText 88 - } 89 - 90 - func (ot *OTEngine) GetVersion() int { 91 - ot.mu.Lock() 92 - defer ot.mu.Unlock() 93 - return ot.version 94 - }
-165
internal/collaboration/ot_test.go
··· 1 - package collaboration 2 - 3 - import ( 4 - "sync" 5 - "testing" 6 - ) 7 - 8 - func TestOTEngine_NewEngine(t *testing.T) { 9 - ot := NewOTEngine("hello world") 10 - if ot.GetText() != "hello world" { 11 - t.Errorf("expected initial text %q, got %q", "hello world", ot.GetText()) 12 - } 13 - if ot.GetVersion() != 0 { 14 - t.Errorf("expected initial version 0, got %d", ot.GetVersion()) 15 - } 16 - } 17 - 18 - func TestOTEngine_Apply_Insert(t *testing.T) { 19 - ot := NewOTEngine("hello world") 20 - result := ot.Apply(Operation{From: 5, To: 5, Insert: " beautiful"}) 21 - want := "hello beautiful world" 22 - if result != want { 23 - t.Errorf("Apply insert: got %q, want %q", result, want) 24 - } 25 - } 26 - 27 - func TestOTEngine_Apply_Replace(t *testing.T) { 28 - ot := NewOTEngine("hello world") 29 - result := ot.Apply(Operation{From: 6, To: 11, Insert: "Go"}) 30 - want := "hello Go" 31 - if result != want { 32 - t.Errorf("Apply replace: got %q, want %q", result, want) 33 - } 34 - } 35 - 36 - func TestOTEngine_Apply_Delete(t *testing.T) { 37 - ot := NewOTEngine("hello world") 38 - // Delete " world" by replacing with empty string. 39 - result := ot.Apply(Operation{From: 5, To: 11, Insert: ""}) 40 - want := "hello" 41 - if result != want { 42 - t.Errorf("Apply delete (empty insert): got %q, want %q", result, want) 43 - } 44 - } 45 - 46 - func TestOTEngine_Apply_DeleteIncrementsVersion(t *testing.T) { 47 - ot := NewOTEngine("hello world") 48 - ot.Apply(Operation{From: 5, To: 11, Insert: ""}) 49 - if ot.GetVersion() != 1 { 50 - t.Errorf("delete should increment version: got %d, want 1", ot.GetVersion()) 51 - } 52 - } 53 - 54 - func TestOTEngine_Apply_EmptyInsertAtSamePosition_IsNoOp(t *testing.T) { 55 - ot := NewOTEngine("hello") 56 - result := ot.Apply(Operation{From: 3, To: 3, Insert: ""}) 57 - if result != "hello" { 58 - t.Errorf("From==To and Insert==\"\" should be no-op: got %q", result) 59 - } 60 - if ot.GetVersion() != 0 { 61 - t.Errorf("no-op should not increment version: got %d", ot.GetVersion()) 62 - } 63 - } 64 - 65 - func TestOTEngine_Apply_FullReplace(t *testing.T) { 66 - ot := NewOTEngine("old text") 67 - result := ot.Apply(Operation{From: 0, To: -1, Insert: "brand new content"}) 68 - want := "brand new content" 69 - if result != want { 70 - t.Errorf("Apply full replace (To=-1): got %q, want %q", result, want) 71 - } 72 - } 73 - 74 - func TestOTEngine_Apply_FromBeyondEnd_Clamps(t *testing.T) { 75 - ot := NewOTEngine("hi") 76 - // From beyond length should be clamped to len(text) 77 - result := ot.Apply(Operation{From: 100, To: 200, Insert: "!"}) 78 - want := "hi!" 79 - if result != want { 80 - t.Errorf("Apply clamped From: got %q, want %q", result, want) 81 - } 82 - } 83 - 84 - func TestOTEngine_Apply_NegativeFrom_Clamps(t *testing.T) { 85 - ot := NewOTEngine("hello") 86 - result := ot.Apply(Operation{From: -5, To: 0, Insert: ">> "}) 87 - want := ">> hello" 88 - if result != want { 89 - t.Errorf("Apply negative From: got %q, want %q", result, want) 90 - } 91 - } 92 - 93 - func TestOTEngine_Apply_FromGreaterThanTo_NoOp(t *testing.T) { 94 - ot := NewOTEngine("hello") 95 - result := ot.Apply(Operation{From: 3, To: 1, Insert: "X"}) 96 - // From > To → no-op 97 - if result != "hello" { 98 - t.Errorf("Apply From>To should be no-op: got %q, want %q", result, "hello") 99 - } 100 - } 101 - 102 - func TestOTEngine_VersionIncrements(t *testing.T) { 103 - ot := NewOTEngine("abc") 104 - if ot.GetVersion() != 0 { 105 - t.Fatalf("initial version should be 0") 106 - } 107 - ot.Apply(Operation{From: 0, To: 0, Insert: "X"}) 108 - if ot.GetVersion() != 1 { 109 - t.Errorf("version after apply: got %d, want 1", ot.GetVersion()) 110 - } 111 - ot.Apply(Operation{From: 0, To: 0, Insert: "Y"}) 112 - if ot.GetVersion() != 2 { 113 - t.Errorf("version after second apply: got %d, want 2", ot.GetVersion()) 114 - } 115 - } 116 - 117 - func TestOTEngine_NoOpDoesNotIncrementVersion(t *testing.T) { 118 - ot := NewOTEngine("abc") 119 - ot.Apply(Operation{From: 1, To: 1, Insert: ""}) // no-op 120 - if ot.GetVersion() != 0 { 121 - t.Errorf("no-op should not increment version: got %d, want 0", ot.GetVersion()) 122 - } 123 - } 124 - 125 - func TestOTEngine_ApplyWithVersion(t *testing.T) { 126 - ot := NewOTEngine("hello") 127 - text, ver := ot.ApplyWithVersion(Operation{From: 5, To: 5, Insert: "!"}) 128 - if text != "hello!" { 129 - t.Errorf("ApplyWithVersion text: got %q, want %q", text, "hello!") 130 - } 131 - if ver != 1 { 132 - t.Errorf("ApplyWithVersion version: got %d, want 1", ver) 133 - } 134 - } 135 - 136 - func TestOTEngine_SetText(t *testing.T) { 137 - ot := NewOTEngine("") 138 - ot.SetText("initial content") 139 - if ot.GetText() != "initial content" { 140 - t.Errorf("SetText: got %q, want %q", ot.GetText(), "initial content") 141 - } 142 - // SetText should not change the version counter. 143 - if ot.GetVersion() != 0 { 144 - t.Errorf("SetText should not increment version: got %d", ot.GetVersion()) 145 - } 146 - } 147 - 148 - func TestOTEngine_ConcurrentApply_DataRace(t *testing.T) { 149 - // Run with -race to catch races. This test just verifies no panic / race. 150 - ot := NewOTEngine("start") 151 - var wg sync.WaitGroup 152 - for i := 0; i < 20; i++ { 153 - wg.Add(1) 154 - go func() { 155 - defer wg.Done() 156 - ot.Apply(Operation{From: 0, To: 0, Insert: "x"}) 157 - }() 158 - } 159 - wg.Wait() 160 - // After 20 inserts of "x" at position 0, we have 20 extra chars 161 - text := ot.GetText() 162 - if len(text) != len("start")+20 { 163 - t.Errorf("concurrent apply: expected length %d, got %d (text=%q)", len("start")+20, len(text), text) 164 - } 165 - }
-7
internal/handler/handler.go
··· 888 888 } 889 889 890 890 room := h.CollaborationHub.GetOrCreateRoom(rKey) 891 - if room.IsNewRoom() { 892 - var initialText string 893 - if doc.Content != nil { 894 - initialText = doc.Content.Text.RawMarkdown 895 - } 896 - room.SeedText(initialText) 897 - } 898 891 wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey) 899 892 room.RegisterClient(wsClient) 900 893
+9
node_modules/.package-lock.json
··· 1834 1834 "prosemirror-transform": "^1.0.0" 1835 1835 } 1836 1836 }, 1837 + "node_modules/prosemirror-collab": { 1838 + "version": "1.3.1", 1839 + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", 1840 + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", 1841 + "license": "MIT", 1842 + "dependencies": { 1843 + "prosemirror-state": "^1.0.0" 1844 + } 1845 + }, 1837 1846 "node_modules/prosemirror-commands": { 1838 1847 "version": "1.7.1", 1839 1848 "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+1 -99
templates/document_edit.html
··· 612 612 }; 613 613 614 614 ws.onclose = () => { 615 - clearTimeout(wsEditTimer); 616 615 stopHeartbeat(); 617 616 ws = null; 618 617 updatePresence([]); ··· 694 693 // remote steps are applied via CollabClient in Task 8. 695 694 } 696 695 697 - // Debounce timer for WebSocket sends (50ms batches rapid keystrokes). 698 - let wsEditTimer = null; 699 - let pendingDeltas = []; 700 - 701 - /** 702 - * Compute the minimal edit operations to transform `oldStr` into `newStr`. 703 - * Returns an array of {from, to, insert} suitable for the OT engine. 704 - * 705 - * Uses a line-level diff for performance, then falls back to a single 706 - * full-replacement op if the diff produces more than 20 operations 707 - * (pathological case — not worth the complexity). 708 - */ 709 - function diffToOps(oldStr, newStr) { 710 - if (oldStr === newStr) return []; 711 - 712 - const oldLines = oldStr.split('\n'); 713 - const newLines = newStr.split('\n'); 714 - 715 - const m = oldLines.length, n = newLines.length; 716 - const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0)); 717 - for (let i = m - 1; i >= 0; i--) { 718 - for (let j = n - 1; j >= 0; j--) { 719 - dp[i][j] = oldLines[i] === newLines[j] 720 - ? dp[i+1][j+1] + 1 721 - : Math.max(dp[i+1][j], dp[i][j+1]); 722 - } 723 - } 724 - 725 - // Line length in old string. Last line has no trailing \n. 726 - const oldLineLengths = oldLines.map((l, idx) => 727 - l.length + (idx < oldLines.length - 1 ? 1 : 0)); 728 - 729 - const ops = []; 730 - let i = 0, j = 0, charOffset = 0; 731 - 732 - while (i < m || j < n) { 733 - if (i < m && j < n && oldLines[i] === newLines[j]) { 734 - // Matching line — advance past it. 735 - charOffset += oldLineLengths[i]; 736 - i++; j++; 737 - } else { 738 - // Non-matching run: collect all consecutive deletions and 739 - // insertions into a single replace op so positions stay consistent. 740 - const hunkFrom = charOffset; 741 - let hunkTo = charOffset; 742 - let hunkInsert = ''; 743 - 744 - while (i < m && (j >= n || dp[i][j] === dp[i+1][j])) { 745 - hunkTo += oldLineLengths[i]; 746 - charOffset += oldLineLengths[i]; 747 - i++; 748 - } 749 - while (j < n && (i >= m || dp[i][j] === dp[i][j+1])) { 750 - hunkInsert += newLines[j] + (j < newLines.length - 1 || hunkTo > hunkFrom ? '\n' : ''); 751 - j++; 752 - } 753 - 754 - // Only emit if something actually changed. 755 - if (hunkTo > hunkFrom || hunkInsert !== '') { 756 - ops.push({ from: hunkFrom, to: hunkTo, insert: hunkInsert }); 757 - } 758 - } 759 - } 760 - 761 - if (ops.length > 20) return [{ from: 0, to: -1, insert: newStr }]; 762 - return ops; 763 - } 764 - 765 - // Queue a set of deltas and flush after a short debounce. 766 - function queueDeltas(deltas) { 767 - if (!ws || ws.readyState !== WebSocket.OPEN) return; 768 - pendingDeltas = pendingDeltas.concat(deltas); 769 - clearTimeout(wsEditTimer); 770 - wsEditTimer = setTimeout(flushDeltas, 50); 771 - } 772 - 773 - function flushDeltas() { 774 - if (!ws || ws.readyState !== WebSocket.OPEN || pendingDeltas.length === 0) { 775 - pendingDeltas = []; 776 - return; 777 - } 778 - ws.send(JSON.stringify({ type: 'edit', deltas: pendingDeltas })); 779 - pendingDeltas = []; 780 - } 781 - 782 696 function closeWS() { 783 697 if (!ws) return; 784 - clearTimeout(wsEditTimer); 785 - flushDeltas(); // send any buffered deltas before closing 786 698 ws.close(); 787 699 ws = null; 788 700 stopHeartbeat(); 789 - } 790 - 791 - // sendEdit sends a full-document replacement via the granular-delta path. 792 - // to: -1 is a sentinel meaning "end of document" — the server OT engine 793 - // clamps it to len(documentText), and applyRemoteEdit clamps it to docLen. 794 - // Milkdown switches to diffToOps in Chunk 4 and no longer calls this directly. 795 - function sendEdit(content) { 796 - if (!ws || ws.readyState !== WebSocket.OPEN) return; 797 - queueDeltas([{ from: 0, to: -1, insert: content }]); 798 701 } 799 702 800 703 // ── Presence ────────────────────────────────────────────────────────────── ··· 937 840 } 938 841 939 842 window.addEventListener('beforeunload', () => { 940 - clearTimeout(wsEditTimer); 941 - flushDeltas(); 843 + closeWS(); 942 844 }); 943 845 </script> 944 846 {{end}}