This commit is contained in:
Timothy Jaeryang Baek 2026-10-19 03:08:52 +09:00
parent 079fb19e75
commit 32170b96fc
2 changed files with 47 additions and 99 deletions

View file

@ -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({});
}
});
}

View file

@ -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<number, number>();
@ -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<number, number>, next: Map<number, number>) => {
if (