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

feat: diff-based granular deltas for Milkdown rich text mode

+65 -1
+65 -1
templates/document_edit.html
··· 254 254 ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => { 255 255 if (markdown !== prevMarkdown && !applyingRemote) { 256 256 scheduleAutoSave(markdown); 257 - sendEdit(markdown); 257 + const ops = diffToOps(prevMarkdown || '', markdown); 258 + if (ops.length > 0) { 259 + queueDeltas(ops); 260 + } 258 261 } 259 262 }); 260 263 }) ··· 642 645 // Debounce timer for WebSocket sends (50ms batches rapid keystrokes). 643 646 let wsEditTimer = null; 644 647 let pendingDeltas = []; 648 + 649 + /** 650 + * Compute the minimal edit operations to transform `oldStr` into `newStr`. 651 + * Returns an array of {from, to, insert} suitable for the OT engine. 652 + * 653 + * Uses a line-level diff for performance, then falls back to a single 654 + * full-replacement op if the diff produces more than 20 operations 655 + * (pathological case — not worth the complexity). 656 + */ 657 + function diffToOps(oldStr, newStr) { 658 + if (oldStr === newStr) return []; 659 + 660 + const oldLines = oldStr.split('\n'); 661 + const newLines = newStr.split('\n'); 662 + 663 + // Build line-level LCS table (Wagner-Fischer). 664 + const m = oldLines.length, n = newLines.length; 665 + const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0)); 666 + for (let i = m - 1; i >= 0; i--) { 667 + for (let j = n - 1; j >= 0; j--) { 668 + dp[i][j] = oldLines[i] === newLines[j] 669 + ? dp[i+1][j+1] + 1 670 + : Math.max(dp[i+1][j], dp[i][j+1]); 671 + } 672 + } 673 + 674 + // Trace back to produce diff hunks. 675 + const ops = []; 676 + let i = 0, j = 0; 677 + // Track character offset into oldStr. 678 + let charOffset = 0; 679 + // +1 for the \n separator. The last line has no trailing \n, so its 680 + // length is exact; however the OT engine clamps out-of-range positions, 681 + // so an off-by-one on the final line is safe in practice. 682 + const oldLineLengths = oldLines.map(l => l.length + 1); 683 + 684 + while (i < m || j < n) { 685 + if (i < m && j < n && oldLines[i] === newLines[j]) { 686 + charOffset += oldLineLengths[i]; 687 + i++; j++; 688 + } else if (j < n && (i >= m || dp[i+1][j] <= dp[i][j+1])) { 689 + // Insert newLines[j] 690 + const insertText = newLines[j] + (j < n - 1 ? '\n' : ''); 691 + ops.push({ from: charOffset, to: charOffset, insert: insertText }); 692 + j++; 693 + } else { 694 + // Delete oldLines[i] 695 + const deleteLen = oldLineLengths[i]; 696 + ops.push({ from: charOffset, to: charOffset + deleteLen, insert: '' }); 697 + charOffset += deleteLen; 698 + i++; 699 + } 700 + } 701 + 702 + // Fallback: if diff is too fragmented, send a single full replacement. 703 + if (ops.length > 20) { 704 + return [{ from: 0, to: -1, insert: newStr }]; 705 + } 706 + 707 + return ops; 708 + } 645 709 646 710 // Queue a set of deltas and flush after a short debounce. 647 711 function queueDeltas(deltas) {