···254254 ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
255255 if (markdown !== prevMarkdown && !applyingRemote) {
256256 scheduleAutoSave(markdown);
257257- sendEdit(markdown);
257257+ const ops = diffToOps(prevMarkdown || '', markdown);
258258+ if (ops.length > 0) {
259259+ queueDeltas(ops);
260260+ }
258261 }
259262 });
260263 })
···642645 // Debounce timer for WebSocket sends (50ms batches rapid keystrokes).
643646 let wsEditTimer = null;
644647 let pendingDeltas = [];
648648+649649+ /**
650650+ * Compute the minimal edit operations to transform `oldStr` into `newStr`.
651651+ * Returns an array of {from, to, insert} suitable for the OT engine.
652652+ *
653653+ * Uses a line-level diff for performance, then falls back to a single
654654+ * full-replacement op if the diff produces more than 20 operations
655655+ * (pathological case — not worth the complexity).
656656+ */
657657+ function diffToOps(oldStr, newStr) {
658658+ if (oldStr === newStr) return [];
659659+660660+ const oldLines = oldStr.split('\n');
661661+ const newLines = newStr.split('\n');
662662+663663+ // Build line-level LCS table (Wagner-Fischer).
664664+ const m = oldLines.length, n = newLines.length;
665665+ const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
666666+ for (let i = m - 1; i >= 0; i--) {
667667+ for (let j = n - 1; j >= 0; j--) {
668668+ dp[i][j] = oldLines[i] === newLines[j]
669669+ ? dp[i+1][j+1] + 1
670670+ : Math.max(dp[i+1][j], dp[i][j+1]);
671671+ }
672672+ }
673673+674674+ // Trace back to produce diff hunks.
675675+ const ops = [];
676676+ let i = 0, j = 0;
677677+ // Track character offset into oldStr.
678678+ let charOffset = 0;
679679+ // +1 for the \n separator. The last line has no trailing \n, so its
680680+ // length is exact; however the OT engine clamps out-of-range positions,
681681+ // so an off-by-one on the final line is safe in practice.
682682+ const oldLineLengths = oldLines.map(l => l.length + 1);
683683+684684+ while (i < m || j < n) {
685685+ if (i < m && j < n && oldLines[i] === newLines[j]) {
686686+ charOffset += oldLineLengths[i];
687687+ i++; j++;
688688+ } else if (j < n && (i >= m || dp[i+1][j] <= dp[i][j+1])) {
689689+ // Insert newLines[j]
690690+ const insertText = newLines[j] + (j < n - 1 ? '\n' : '');
691691+ ops.push({ from: charOffset, to: charOffset, insert: insertText });
692692+ j++;
693693+ } else {
694694+ // Delete oldLines[i]
695695+ const deleteLen = oldLineLengths[i];
696696+ ops.push({ from: charOffset, to: charOffset + deleteLen, insert: '' });
697697+ charOffset += deleteLen;
698698+ i++;
699699+ }
700700+ }
701701+702702+ // Fallback: if diff is too fragmented, send a single full replacement.
703703+ if (ops.length > 20) {
704704+ return [{ from: 0, to: -1, insert: newStr }];
705705+ }
706706+707707+ return ops;
708708+ }
645709646710 // Queue a set of deltas and flush after a short debounce.
647711 function queueDeltas(deltas) {