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

fix: rewrite diffToOps traceback to collect contiguous hunks as single replace ops

+25 -52
+25 -52
templates/document_edit.html
··· 660 660 const oldLines = oldStr.split('\n'); 661 661 const newLines = newStr.split('\n'); 662 662 663 - // Build line-level LCS table (Wagner-Fischer). 664 663 const m = oldLines.length, n = newLines.length; 665 664 const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0)); 666 665 for (let i = m - 1; i >= 0; i--) { ··· 671 670 } 672 671 } 673 672 674 - // Line lengths in the original document. 675 - // All lines except the last have a trailing \n; the last does not. 676 - const oldLineLengths = oldLines.map((l, idx) => l.length + (idx < oldLines.length - 1 ? 1 : 0)); 673 + // Line length in old string. Last line has no trailing \n. 674 + const oldLineLengths = oldLines.map((l, idx) => 675 + l.length + (idx < oldLines.length - 1 ? 1 : 0)); 677 676 678 - // Trace back to produce raw diff ops. 679 - // Traceback preference: insert before delete. charOffset tracks the 680 - // character position in the ORIGINAL document (only advances on matches 681 - // and deletes, never on inserts — inserts reference the pre-delete offset). 682 - const rawOps = []; 683 - let i = 0, j = 0; 684 - let charOffset = 0; 677 + const ops = []; 678 + let i = 0, j = 0, charOffset = 0; 685 679 686 680 while (i < m || j < n) { 687 681 if (i < m && j < n && oldLines[i] === newLines[j]) { 688 - // Matching line — advance both pointers and the offset. 682 + // Matching line — advance past it. 689 683 charOffset += oldLineLengths[i]; 690 684 i++; j++; 691 - } else if (j < n && (i >= m || dp[i+1][j] <= dp[i][j+1])) { 692 - // Insert newLines[j] at the current position in the old doc. 693 - // Append \n if more new lines follow OR if there are still old 694 - // lines remaining after this insertion point (line is mid-doc). 695 - const insertText = newLines[j] + (j < n - 1 || i < m ? '\n' : ''); 696 - rawOps.push({ from: charOffset, to: charOffset, insert: insertText }); 697 - j++; 698 685 } else { 699 - // Delete oldLines[i]. 700 - const deleteLen = oldLineLengths[i]; 701 - rawOps.push({ from: charOffset, to: charOffset + deleteLen, insert: '' }); 702 - charOffset += deleteLen; 703 - i++; 704 - } 705 - } 686 + // Non-matching run: collect all consecutive deletions and 687 + // insertions into a single replace op so positions stay consistent. 688 + const hunkFrom = charOffset; 689 + let hunkTo = charOffset; 690 + let hunkInsert = ''; 691 + 692 + while (i < m && (j >= n || dp[i][j] === dp[i+1][j])) { 693 + hunkTo += oldLineLengths[i]; 694 + charOffset += oldLineLengths[i]; 695 + i++; 696 + } 697 + while (j < n && (i >= m || dp[i][j] === dp[i][j+1])) { 698 + hunkInsert += newLines[j] + (j < newLines.length - 1 || hunkTo > hunkFrom ? '\n' : ''); 699 + j++; 700 + } 706 701 707 - // Post-process: merge adjacent insert-at-X + delete-at-X pairs into a 708 - // single replace op. The traceback emits insert before delete for the 709 - // same position (insert doesn't advance charOffset, so the delete that 710 - // follows still references the same offset). Merging is required because 711 - // the server applies ops sequentially — a separate insert followed by a 712 - // separate delete would hit the wrong bytes after the insert shifts things. 713 - function mergeOps(raw) { 714 - const merged = []; 715 - for (let k = 0; k < raw.length; k++) { 716 - const op = raw[k]; 717 - const next = raw[k + 1]; 718 - // Insert at X (pure insert: to === from) followed immediately by 719 - // delete starting at X (pure delete: insert === ''). 720 - if (op.to === op.from && next && next.insert === '' && next.from === op.from) { 721 - merged.push({ from: op.from, to: next.to, insert: op.insert }); 722 - k++; // skip next 723 - } else { 724 - merged.push(op); 702 + // Only emit if something actually changed. 703 + if (hunkTo > hunkFrom || hunkInsert !== '') { 704 + ops.push({ from: hunkFrom, to: hunkTo, insert: hunkInsert }); 725 705 } 726 706 } 727 - return merged; 728 707 } 729 708 730 - const ops = mergeOps(rawOps); 731 - 732 - // Fallback: if diff is too fragmented, send a single full replacement. 733 - if (ops.length > 20) { 734 - return [{ from: 0, to: -1, insert: newStr }]; 735 - } 736 - 709 + if (ops.length > 20) return [{ from: 0, to: -1, insert: newStr }]; 737 710 return ops; 738 711 } 739 712