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

fix: correct diffToOps traceback — fix last-line length, newline logic, merge replace ops

+42 -12
+42 -12
templates/document_edit.html
··· 671 671 } 672 672 } 673 673 674 - // Trace back to produce diff hunks. 675 - const ops = []; 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)); 677 + 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 = []; 676 683 let i = 0, j = 0; 677 - // Track character offset into oldStr. 678 684 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 685 684 686 while (i < m || j < n) { 685 687 if (i < m && j < n && oldLines[i] === newLines[j]) { 688 + // Matching line — advance both pointers and the offset. 686 689 charOffset += oldLineLengths[i]; 687 690 i++; j++; 688 691 } 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 + // 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 }); 692 697 j++; 693 698 } else { 694 - // Delete oldLines[i] 699 + // Delete oldLines[i]. 695 700 const deleteLen = oldLineLengths[i]; 696 - ops.push({ from: charOffset, to: charOffset + deleteLen, insert: '' }); 701 + rawOps.push({ from: charOffset, to: charOffset + deleteLen, insert: '' }); 697 702 charOffset += deleteLen; 698 703 i++; 699 704 } 700 705 } 706 + 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); 725 + } 726 + } 727 + return merged; 728 + } 729 + 730 + const ops = mergeOps(rawOps); 701 731 702 732 // Fallback: if diff is too fragmented, send a single full replacement. 703 733 if (ops.length > 20) {