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

feat: send granular CodeMirror deltas over WebSocket with 50ms debounce

+70 -18
+70 -18
templates/document_edit.html
··· 181 181 if (update.docChanged && currentMode === 'source') { 182 182 const content = update.state.doc.toString(); 183 183 updatePreview(content); 184 - // Skip broadcast if this change came from a remote edit 185 184 if (!update.transactions.some(tr => tr.annotation(remoteEditAnnotation))) { 186 185 scheduleAutoSave(content); 187 - sendEdit(content); 186 + // Extract granular deltas from the ChangeSet. 187 + // fromA/toA are positions in the OLD document (pre-change), 188 + // which is what the server's OT engine needs. 189 + const deltas = []; 190 + update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { 191 + deltas.push({ from: fromA, to: toA, insert: inserted.toString() }); 192 + }); 193 + if (deltas.length > 0) { 194 + queueDeltas(deltas); 195 + } 188 196 } 189 197 } 190 198 }), ··· 573 581 wsMissedPings = 0; 574 582 break; 575 583 case 'edit': 576 - applyRemoteEdit(msg.content); 584 + applyRemoteEdit(msg); // pass full message object 577 585 break; 578 586 case 'sync': 579 - applyRemoteEdit(msg.content); 587 + applyRemoteEdit(msg.content); // sync is always full-content string 580 588 break; 581 589 } 582 590 } 583 591 584 - function applyRemoteEdit(content) { 585 - if (!content || applyingRemote) return; 592 + function applyRemoteEdit(msg) { 593 + // msg may be a full-content string (legacy sync path) or an object with deltas. 594 + if (applyingRemote) return; 586 595 applyingRemote = true; 587 596 try { 588 - if (currentMode === 'source') { 589 - const cur = cmView.state.doc.toString(); 590 - if (cur !== content) { 597 + let content = typeof msg === 'string' ? msg : msg.content; 598 + const deltas = (typeof msg === 'object' && msg.deltas) ? msg.deltas : null; 599 + 600 + if (currentMode === 'source' && cmView) { 601 + if (deltas && deltas.length > 0) { 602 + // Apply granular deltas as a single CodeMirror dispatch. 603 + // All from/to positions are relative to the original document 604 + // (before any delta in this batch), which is what CodeMirror 605 + // expects when changes are batched in a single dispatch call. 606 + const docLen = cmView.state.doc.length; 607 + const changes = deltas.map(d => ({ 608 + from: Math.min(d.from < 0 ? 0 : d.from, docLen), 609 + to: Math.min(d.to < 0 ? docLen : d.to, docLen), 610 + insert: d.insert || '', 611 + })); 612 + cmView.dispatch({ 613 + changes, 614 + annotations: [remoteEditAnnotation.of(true)], 615 + }); 616 + // Use the server's full-content echo as a divergence check. 617 + if (content && cmView.state.doc.toString() !== content) { 618 + // State diverged — fall back to full replacement. 619 + cmView.dispatch({ 620 + changes: { from: 0, to: cmView.state.doc.length, insert: content }, 621 + annotations: [remoteEditAnnotation.of(true)], 622 + }); 623 + } 624 + } else if (content && cmView.state.doc.toString() !== content) { 625 + // Legacy full-replacement path (sync message or old server). 591 626 cmView.dispatch({ 592 627 changes: { from: 0, to: cmView.state.doc.length, insert: content }, 593 - // Don't trigger our own change listener 594 628 annotations: [remoteEditAnnotation.of(true)], 595 629 }); 596 - updatePreview(content); 597 630 } 598 - } else if (milkdownEditor) { 599 - // Recreate Milkdown with the new content 631 + if (content) updatePreview(content); 632 + } else if (currentMode === 'rich' && milkdownEditor && content) { 600 633 createMilkdownEditor(content); 601 634 } 602 635 } finally { ··· 604 637 } 605 638 } 606 639 607 - // Send the full document content as an edit delta (full replacement). 640 + // Debounce timer for WebSocket sends (50ms batches rapid keystrokes). 641 + let wsEditTimer = null; 642 + let pendingDeltas = []; 643 + 644 + // Queue a set of deltas and flush after a short debounce. 645 + function queueDeltas(deltas) { 646 + if (!ws || ws.readyState !== WebSocket.OPEN || applyingRemote) return; 647 + pendingDeltas = pendingDeltas.concat(deltas); 648 + clearTimeout(wsEditTimer); 649 + wsEditTimer = setTimeout(flushDeltas, 50); 650 + } 651 + 652 + function flushDeltas() { 653 + if (!ws || ws.readyState !== WebSocket.OPEN || pendingDeltas.length === 0) { 654 + pendingDeltas = []; 655 + return; 656 + } 657 + ws.send(JSON.stringify({ type: 'edit', deltas: pendingDeltas })); 658 + pendingDeltas = []; 659 + } 660 + 661 + // sendEdit is kept for any future callers; Milkdown switches to diffToOps 662 + // in Chunk 4 and no longer calls this directly. 608 663 function sendEdit(content) { 609 664 if (!ws || ws.readyState !== WebSocket.OPEN || applyingRemote) return; 610 - ws.send(JSON.stringify({ 611 - type: 'edit', 612 - delta: { from: 0, to: -1, insert: content }, 613 - })); 665 + queueDeltas([{ from: 0, to: -1, insert: content }]); 614 666 } 615 667 616 668 // ── Presence ──────────────────────────────────────────────────────────────