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

feat(frontend): source mode uses CollabClient step protocol instead of full-replace

+64 -22
+64 -22
templates/document_edit.html
··· 100 100 listener, listenerCtx, 101 101 history, undoCommand, redoCommand, callCommand, 102 102 } from '/static/vendor/milkdown.js'; 103 + import { CollabClient } from '/static/collab-client.js'; 103 104 104 105 const textarea = document.getElementById('editor-textarea'); 105 106 const previewEl = document.getElementById('preview'); ··· 109 110 const accessToken = '{{.Content.AccessToken}}'; 110 111 const isCollaborator = {{if .Content.IsCollaborator}}true{{else}}false{{end}}; 111 112 const ownerDID = '{{.Content.OwnerDID}}'; // empty string when current user is owner 113 + 114 + // Fetch the authoritative step version for this document. 115 + let serverVersion = 0; 116 + try { 117 + const vResp = await fetch(`/api/docs/${rkey}/steps?since=0`); 118 + if (vResp.ok) { 119 + const vData = await vResp.json(); 120 + serverVersion = vData.version || 0; 121 + } 122 + } catch(e) { /* start at 0 */ } 123 + 124 + const myClientID = accessToken || Math.random().toString(36).slice(2); 112 125 113 126 const STORAGE_KEY = 'editor-mode'; 114 127 let currentMode = localStorage.getItem(STORAGE_KEY) || 'rich'; // 'rich' | 'source' ··· 192 205 deltas.push({ from: fromA, to: toA, insert: inserted.toString() }); 193 206 }); 194 207 if (deltas.length > 0) { 195 - queueDeltas(deltas); 208 + const pmSteps = deltas.map(d => ({type: 'text-patch', from: d.from, to: d.to, insert: d.insert})); 209 + collabClient.sendSteps(pmSteps); 196 210 } 197 211 } 198 212 } ··· 200 214 ], 201 215 parent: document.getElementById('editor'), 202 216 }); 217 + 218 + // ── CollabClient (step-authority protocol) ──────────────────────────────── 219 + 220 + // Guard against applying a remote edit while we're already applying one 221 + // (prevents echo loops). Moved here from the WebSocket section so collabClient 222 + // can reference it during initialization. 223 + let applyingRemote = false; 224 + 225 + const collabClient = new CollabClient(rkey, serverVersion, (remoteSteps) => { 226 + // Apply text-patch steps to CM without triggering our own send. 227 + if (currentMode !== 'source' || !cmView) return; 228 + const changes = []; 229 + let offset = 0; 230 + for (const step of remoteSteps) { 231 + if (step.type !== 'text-patch') continue; 232 + const from = step.from + offset; 233 + const to = step.to + offset; 234 + const insert = step.insert || ''; 235 + changes.push({ from, to, insert }); 236 + offset += insert.length - (step.to - step.from); 237 + } 238 + if (changes.length === 0) return; 239 + applyingRemote = true; 240 + try { 241 + cmView.dispatch({ 242 + changes, 243 + annotations: [remoteEditAnnotation.of(true)], 244 + }); 245 + } finally { 246 + applyingRemote = false; 247 + } 248 + }); 249 + collabClient.setClientID(myClientID); 203 250 204 251 async function updatePreview(content) { 205 252 try { ··· 539 586 540 587 ws.onclose = () => { 541 588 clearTimeout(wsEditTimer); 542 - pendingDeltas = []; 543 589 stopHeartbeat(); 544 590 ws = null; 545 591 updatePresence([]); ··· 576 622 clearInterval(wsPingTimer); 577 623 } 578 624 579 - // Guard against applying a remote edit while we're already applying one 580 - // (prevents echo loops). 581 - let applyingRemote = false; 582 - 583 625 function handleWSMessage(msg) { 584 626 switch (msg.type) { 585 627 case 'presence': ··· 588 630 case 'pong': 589 631 wsMissedPings = 0; 590 632 break; 633 + case 'steps': 634 + collabClient.handleWSMessage(msg, myClientID); 635 + break; 591 636 case 'edit': 592 - applyRemoteEdit(msg); // pass full message object 637 + applyRemoteEdit(msg); // legacy full-replace path 593 638 break; 594 639 case 'sync': 595 640 applyRemoteEdit(msg.content); // sync is always full-content string ··· 598 643 } 599 644 600 645 function applyRemoteEdit(msg) { 601 - // msg may be a full-content string (legacy sync path) or an object with content. 646 + // Legacy sync fallback — only used for 'sync' and legacy 'edit' message types. 647 + // Remote edits via the new step protocol go through CollabClient instead. 602 648 if (applyingRemote) return; 603 - applyingRemote = true; 604 - try { 605 - // Always use the server's authoritative content echo for applying remote edits. 606 - // Attempting to apply deltas directly is unreliable because the positions in 607 - // the delta batch are relative to the sender's pre-change document, not the 608 - // receiver's current state (which may have diverged). 609 - const content = typeof msg === 'string' ? msg : msg.content; 610 - if (!content) return; 649 + const content = typeof msg === 'string' ? msg : msg.content; 650 + if (!content) return; 611 651 612 - if (currentMode === 'source' && cmView) { 613 - if (cmView.state.doc.toString() !== content) { 652 + if (currentMode === 'source' && cmView) { 653 + if (cmView.state.doc.toString() !== content) { 654 + applyingRemote = true; 655 + try { 614 656 cmView.dispatch({ 615 657 changes: { from: 0, to: cmView.state.doc.length, insert: content }, 616 658 annotations: [remoteEditAnnotation.of(true)], 617 659 }); 660 + } finally { 661 + applyingRemote = false; 618 662 } 619 663 updatePreview(content); 620 - } else if (currentMode === 'rich' && milkdownEditor) { 621 - createMilkdownEditor(content); 622 664 } 623 - } finally { 624 - applyingRemote = false; 625 665 } 666 + // Rich mode no longer falls back to full recreate here; 667 + // remote steps are applied via CollabClient in Task 8. 626 668 } 627 669 628 670 // Debounce timer for WebSocket sends (50ms batches rapid keystrokes).