diff --git a/src/lib/components/common/DiffEditorModal.svelte b/src/lib/components/common/DiffEditorModal.svelte index 8ba1f7c4ba..554965ae63 100644 --- a/src/lib/components/common/DiffEditorModal.svelte +++ b/src/lib/components/common/DiffEditorModal.svelte @@ -27,7 +27,7 @@ lineAlignment, alignChangedLines, clearLineAlignment, - pairedLinePosition + alignmentGeometryChanged } from './diffEditorAlignment'; import { diffPresentation, @@ -371,7 +371,7 @@ }; updatePresentation = schedulePresentation; let alignmentDocuments: readonly [Text, Text] | null = null; - let alignmentGeometry = ''; + let alignmentGeometry: readonly number[] | null = null; const themes = [new Compartment(), new Compartment()]; const language = [new Compartment(), new Compartment()]; const paths = ['a', 'b'].map((side) => paneFile(side)?.path ?? session?.path); @@ -423,47 +423,18 @@ view.b.contentDOM.clientWidth, view.a.defaultLineHeight, view.b.defaultLineHeight - ].join(':'); + ]; + const resized = + alignmentGeometry && alignmentGeometryChanged(alignmentGeometry, geometry); const reset = alignmentDocuments && (alignmentDocuments[0] !== documents[0] || alignmentDocuments[1] !== documents[1] || - geometry !== alignmentGeometry); - const resized = alignmentDocuments && geometry !== alignmentGeometry; + resized); alignmentDocuments = documents; - alignmentGeometry = geometry; - const previousSpacersA = view.a.state.field(lineAlignment); - const previousSpacersB = view.b.state.field(lineAlignment); + if (!alignmentGeometry || resized) alignmentGeometry = geometry; if (!reset || !clearLineAlignment(view.a, view.b)) alignChangedLines(view.a, view.b, view.chunks); - if (resized) { - // Reflow can leave one viewport entirely inside an old EOF spacer. - // Measure both panes around the row the reader was looking at. - const top = Math.max(0, node.getBoundingClientRect().top - view.a.documentTop); - const position = Math.min( - view.a.lineBlockAtHeight(top).from, - view.a.state.doc.length - ); - const other = pairedLinePosition( - view.a.state.doc, - view.b.state.doc, - view.chunks, - position - ); - view.a.dispatch({ - effects: EditorView.scrollIntoView(position, { y: 'start', yMargin: 0 }) - }); - view.b.dispatch({ - effects: EditorView.scrollIntoView(other, { y: 'start', yMargin: 0 }) - }); - } - // Remeasuring unchanged spacers feeds CodeMirror's own layout updates back into itself. - if ( - resized || - previousSpacersA !== view.a.state.field(lineAlignment) || - previousSpacersB !== view.b.state.field(lineAlignment) - ) - view.reconfigure({}); } }); } diff --git a/src/lib/components/common/diffEditorAlignment.ts b/src/lib/components/common/diffEditorAlignment.ts index 0e4d2f1e9a..7e5269dca2 100644 --- a/src/lib/components/common/diffEditorAlignment.ts +++ b/src/lib/components/common/diffEditorAlignment.ts @@ -1,4 +1,4 @@ -import { StateEffect, StateField, type Text } from '@codemirror/state'; +import { StateEffect, StateField } from '@codemirror/state'; import { BlockType, Decoration, @@ -9,21 +9,9 @@ import { import type { Chunk } from '@codemirror/merge'; import { changedLinePairs } from './diffEditor'; -export function pairedLinePosition(a: Text, b: Text, chunks: readonly Chunk[], position: number) { - let offset = 0; - for (const chunk of chunks) { - if (position < chunk.fromA) break; - if (position < chunk.toA) { - const pairs = changedLinePairs(a, b, chunk); - const line = a.lineAt(position).from; - return ( - (pairs.find(([from]) => from >= line) ?? pairs.at(-1))?.[1] ?? - Math.min(chunk.fromB, b.length) - ); - } - offset = chunk.toB - chunk.toA; - } - return Math.max(0, Math.min(position + offset, b.length)); +export function alignmentGeometryChanged(previous: readonly number[], next: readonly number[]) { + // DOM line-height measurements can vary by a fraction of a pixel while scrolling. + return next.some((value, index) => Math.abs(value - previous[index]) > (index < 2 ? 0 : 0.5)); } class RowSpacer extends WidgetType { @@ -64,8 +52,8 @@ export function clearLineAlignment(a: EditorView, b: EditorView) { return changed; } -// Only remeasure paired rows present in both virtualized viewports. Recomputing -// offscreen spacers from estimated heights would move already aligned content. +// MergeView owns alignment between chunks. Only align rows inside each changed +// chunk, using local heights so its spacers cannot feed back into ours. export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly Chunk[]) { const previous = (view: EditorView) => { const result = new Map(); @@ -77,8 +65,6 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly oldB = previous(b); const nextA = new Map(oldA), nextB = new Map(oldB); - let offsetA = 0, - offsetB = 0; const textTop = (view: EditorView, pos: number) => { const block = view.lineBlockAt(pos); const text = Array.isArray(block.type) @@ -88,52 +74,43 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly : null; return text?.from === pos ? text.top : null; }; - const align = (posA: number, posB: number) => { + for (const chunk of chunks) { if ( - posA < a.viewport.from || - posB < b.viewport.from || - posA > a.viewport.to || - posB > b.viewport.to + chunk.toA < a.viewport.from || + chunk.toB < b.viewport.from || + chunk.fromA > a.viewport.to || + chunk.fromB > b.viewport.to ) - return; - const topA = textTop(a, posA), - topB = textTop(b, posB); - if (topA === null || topB === null) return; - const previousA = oldA.get(posA) ?? 0, - previousB = oldB.get(posB) ?? 0; - const difference = topA - previousA + offsetA - topB + previousB - offsetB; - const sizeA = difference < -1 ? -difference : 0, - sizeB = difference > 1 ? difference : 0; - nextA.delete(posA); - nextB.delete(posB); - if (sizeA) nextA.set(posA, sizeA); - if (sizeB) nextB.set(posB, sizeB); - offsetA += sizeA - previousA; - offsetB += sizeB - previousB; - }; - let fromA = 0, - fromB = 0; - for (let index = 0; index <= chunks.length; index++) { - const chunk = chunks[index]; - const endA = chunk ? chunk.fromA : a.state.doc.length + 1; - let posA = Math.max( - fromA, - a.state.doc.lineAt( - Math.min( - a.state.doc.length, - Math.max(fromA, a.viewport.from, fromA + b.viewport.from - fromB) - ) - ).from - ); - for (; posA < endA && posA <= a.viewport.to; posA = a.state.doc.lineAt(posA).to + 1) { - const posB = fromB + posA - fromA; - if (posB > b.viewport.to) break; - align(posA, posB); + continue; + let previousA = chunk.fromA, + previousB = chunk.fromB, + first = true; + for (const [posA, posB] of changedLinePairs(a.state.doc, b.state.doc, chunk)) { + // Both ends of the interval must be rendered; offscreen estimates are not row heights. + if ( + previousA >= a.viewport.from && + previousB >= b.viewport.from && + posA <= a.viewport.to && + posB <= b.viewport.to + ) { + const topA = textTop(a, posA), + topB = textTop(b, posB); + const startA = first ? a.lineBlockAt(previousA).top : textTop(a, previousA); + const startB = first ? b.lineBlockAt(previousB).top : textTop(b, previousB); + if (topA !== null && topB !== null && startA !== null && startB !== null) { + const heightA = topA - startA - (oldA.get(posA) ?? 0); + const heightB = topB - startB - (oldB.get(posB) ?? 0); + const difference = heightA - heightB; + nextA.delete(posA); + nextB.delete(posB); + if (difference < -1) nextA.set(posA, -difference); + if (difference > 1) nextB.set(posB, difference); + } + } + previousA = posA; + previousB = posB; + first = false; } - if (!chunk) break; - for (const [posA, posB] of changedLinePairs(a.state.doc, b.state.doc, chunk)) align(posA, posB); - fromA = chunk.toA; - fromB = chunk.toB; } const apply = (view: EditorView, old: Map, next: Map) => { if (