From aa8ce52c7a3afbb164bc16a3b7c20e40ba6390ed Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 19 Oct 2026 05:01:59 +0900 Subject: [PATCH] refac --- .../components/common/DiffEditorModal.svelte | 51 +------- src/lib/components/common/diffEditor.ts | 75 +++++++++++ .../components/common/diffEditorAlignment.ts | 122 +++++++++--------- 3 files changed, 140 insertions(+), 108 deletions(-) diff --git a/src/lib/components/common/DiffEditorModal.svelte b/src/lib/components/common/DiffEditorModal.svelte index 910eed610f..cd54987d21 100644 --- a/src/lib/components/common/DiffEditorModal.svelte +++ b/src/lib/components/common/DiffEditorModal.svelte @@ -245,6 +245,7 @@ if (!merge || !chunkCount) return; activeChunk = Math.max(0, Math.min(index, chunkCount - 1)); const chunk = visibleChunks[activeChunk]; + merge.scrollSource = chunk.toB > chunk.fromB ? merge.b : merge.a; for (const [view, start] of [ [merge.a, chunk.fromA], [merge.b, chunk.fromB] @@ -334,32 +335,6 @@ let observer: MutationObserver | undefined; let alignmentFrame = 0; let presentationFrame = 0; - let scrollAnchor: { editor: EditorView; position: number; top: number } | null = null; - const captureScrollAnchor = () => { - if (!view) return null; - const y = view.dom.getBoundingClientRect().top + 1; - for (const editor of [view.a, view.b]) { - const position = editor.posAtCoords({ - x: editor.contentDOM.getBoundingClientRect().left + 4, - y - }); - const coords = position === null ? null : editor.coordsAtPos(position); - if (position !== null && coords) return { editor, position, top: coords.top }; - } - return null; - }; - const cancelScrollAnchor = () => { - scrollAnchor = null; - }; - for (const event of ['touchmove', 'pointerdown']) - node.addEventListener(event, cancelScrollAnchor, { passive: true }); - const advanceScrollAnchor = (event: WheelEvent) => { - if (event.deltaMode !== 0) return cancelScrollAnchor(); - scrollAnchor ??= captureScrollAnchor(); - if (scrollAnchor) scrollAnchor.top -= event.deltaY; - scheduleAlignment(); - }; - node.addEventListener('wheel', advanceScrollAnchor, { passive: true }); let expanded: CollapsedPair[] = []; const schedulePresentation = (reset = false) => { if (reset) expanded = []; @@ -403,14 +378,6 @@ alignmentFrame = requestAnimationFrame(() => { alignmentFrame = 0; if (!disposed && view) { - // Restore a visual row after both panes measure new spacers. Anchoring - // to the paragraph start jumps backwards inside long wrapped lines. - if (scrollAnchor) { - const { editor, position, top } = scrollAnchor; - scrollAnchor = null; - const coords = editor.coordsAtPos(position); - if (coords) view.dom.scrollTop += coords.top - top; - } const documents = [view.a.state.doc, view.b.state.doc] as const; const geometry = [ view.a.contentDOM.clientWidth, @@ -427,10 +394,9 @@ resized); alignmentDocuments = documents; if (!alignmentGeometry || resized) alignmentGeometry = geometry; - scrollAnchor = captureScrollAnchor(); if (!reset || !clearLineAlignment(view.a, view.b)) - alignChangedLines(view.a, view.b, view.chunks); - if (!alignmentFrame) scrollAnchor = null; + alignChangedLines(view.a, view.b, view.linePairs); + view.syncScroll(); } }); }; @@ -458,7 +424,6 @@ themes[index].of(document.documentElement.classList.contains('dark') ? oneDark : []), language[index].of([]), EditorView.updateListener.of((update) => { - if (update.docChanged || update.selectionSet) scrollAnchor = null; for (const transaction of update.transactions) { if (transaction.docChanged) { for (const pair of expanded) { @@ -480,8 +445,8 @@ } }), EditorView.theme({ - '&': { fontSize: '0.75rem' }, - '&, .cm-scroller': { height: 'auto', overflowY: 'visible' }, + '&': { fontSize: '0.75rem', height: '100%' }, + '.cm-scroller': { overflowY: 'auto' }, '.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }, '&.cm-focused': { outline: 'none' } }) @@ -527,9 +492,6 @@ return { destroy: () => { disposed = true; - for (const event of ['touchmove', 'pointerdown']) - node.removeEventListener(event, cancelScrollAnchor); - node.removeEventListener('wheel', advanceScrollAnchor); cancelAnimationFrame(alignmentFrame); cancelAnimationFrame(presentationFrame); if (updatePresentation === schedulePresentation) updatePresentation = null; @@ -1000,7 +962,7 @@ } .merge-container :global(.cm-mergeView) { height: 100%; - overflow: auto; + overflow: hidden; } .merge-container :global(.cm-mergeViewEditor) { flex: 1 1 0; @@ -1065,6 +1027,7 @@ background: #101413 !important; } .merge-container :global(.cm-mergeViewEditors) { + height: 100%; display: flex; align-items: stretch; gap: 1rem; diff --git a/src/lib/components/common/diffEditor.ts b/src/lib/components/common/diffEditor.ts index 2dba552bb0..b771d34293 100644 --- a/src/lib/components/common/diffEditor.ts +++ b/src/lib/components/common/diffEditor.ts @@ -11,6 +11,7 @@ import { } from '@codemirror/state'; import { Decoration, + BlockType, EditorView, GutterMarker, gutter, @@ -18,6 +19,7 @@ import { type DecorationSet } from '@codemirror/view'; import { Chunk, type DiffConfig } from '@codemirror/merge'; +import { lineTextTop } from './diffEditorAlignment'; // A scanLimit also gives up based on document length, even for sparse, tiny edits. export const diffEditorConfig: DiffConfig = { timeout: 100 }; @@ -28,6 +30,9 @@ export class DiffEditorView { readonly b: EditorView; readonly dom: HTMLDivElement; chunks: readonly Chunk[]; + linePairs: [number, number][]; + scrollSource: EditorView; + private scrollFrame = 0; constructor(config: { a: EditorStateConfig; b: EditorStateConfig; parent: HTMLElement }) { const states = (['a', 'b'] as const).map((side) => EditorState.create({ @@ -39,6 +44,7 @@ export class DiffEditorView { }) ); this.chunks = Chunk.build(states[0].doc, states[1].doc, diffEditorConfig); + this.linePairs = matchingLinePairs(states[0].doc, states[1].doc, this.chunks); this.dom = document.createElement('div'); this.dom.className = 'cm-mergeView'; const editors = this.dom.appendChild(document.createElement('div')); @@ -55,6 +61,51 @@ export class DiffEditorView { this.a = create('a', 0); this.b = create('b', 1); config.parent.appendChild(this.dom); + // Only the pane receiving input drives scrolling; the other pane's height corrections must not feed back. + this.scrollSource = this.a; + for (const editor of [this.a, this.b]) { + for (const event of ['wheel', 'touchstart', 'pointerdown', 'keydown', 'focusin']) + editor.dom.addEventListener(event, () => (this.scrollSource = editor), { passive: true }); + editor.scrollDOM.addEventListener('scroll', () => this.syncScroll()); + } + } + syncScroll() { + if (this.scrollFrame) return; + this.scrollFrame = requestAnimationFrame(() => { + this.scrollFrame = 0; + const index = this.scrollSource === this.a ? 0 : 1; + const other = index === 0 ? this.b : this.a; + const block = this.scrollSource.lineBlockAtHeight(this.scrollSource.scrollDOM.scrollTop); + const line = block.from; + const text = Array.isArray(block.type) + ? block.type.find((part) => part.type === BlockType.Text) + : block.type === BlockType.Text + ? block + : null; + let pair = this.linePairs[0]; + for (const candidate of this.linePairs) { + if (candidate[index] > line) break; + pair = candidate; + } + if ( + pair && + text && + pair[index] === text.from && + this.scrollSource.scrollDOM.scrollTop < text.bottom && + (pair[1 - index] < other.viewport.from || pair[1 - index] > other.viewport.to) + ) + other.dispatch({ + effects: EditorView.scrollIntoView(pair[1 - index], { y: 'nearest', yMargin: 0 }) + }); + const sourceTop = pair + ? (lineTextTop(this.scrollSource, pair[index]) ?? + this.scrollSource.lineBlockAt(pair[index]).top) + : 0; + const targetTop = pair + ? (lineTextTop(other, pair[1 - index]) ?? other.lineBlockAt(pair[1 - index]).top) + : 0; + other.scrollDOM.scrollTop = targetTop + this.scrollSource.scrollDOM.scrollTop - sourceTop; + }); } private dispatch(transactions: readonly Transaction[], side: 'a' | 'b') { if (transactions.some((transaction) => transaction.docChanged)) { @@ -67,16 +118,40 @@ export class DiffEditorView { side === 'a' ? Chunk.updateA(this.chunks, document, this.b.state.doc, changes, diffEditorConfig) : Chunk.updateB(this.chunks, this.a.state.doc, document, changes, diffEditorConfig); + this.linePairs = matchingLinePairs( + side === 'a' ? document : this.a.state.doc, + side === 'b' ? document : this.b.state.doc, + this.chunks + ); } this[side].update(transactions); } destroy() { + cancelAnimationFrame(this.scrollFrame); this.a.destroy(); this.b.destroy(); this.dom.remove(); } } +// Pair unchanged rows and the rows whose text survives within changed chunks. +function matchingLinePairs(a: Text, b: Text, chunks: readonly Chunk[]): [number, number][] { + const pairs: [number, number][] = []; + let fromA = 0, + fromB = 0; + for (let index = 0; index <= chunks.length; index++) { + const chunk = chunks[index]; + const end = chunk ? chunk.fromA : a.length + 1; + for (let pos = fromA; pos < end; pos = a.lineAt(pos).to + 1) + pairs.push([pos, fromB + pos - fromA]); + if (!chunk) break; + for (const pair of changedLinePairs(a, b, chunk)) pairs.push(pair); + fromA = chunk.toA; + fromB = chunk.toB; + } + return pairs; +} + export type MergeDirection = 'to-working' | 'to-proposal'; // Match rows by the text preserved inside a chunk, not their ordinal position. diff --git a/src/lib/components/common/diffEditorAlignment.ts b/src/lib/components/common/diffEditorAlignment.ts index f93531a81e..78fdf661c4 100644 --- a/src/lib/components/common/diffEditorAlignment.ts +++ b/src/lib/components/common/diffEditorAlignment.ts @@ -6,8 +6,6 @@ import { WidgetType, type DecorationSet } from '@codemirror/view'; -import type { Chunk } from '@codemirror/merge'; -import { changedLinePairs } from './diffEditor'; // Font measurements can differ by fractions of a pixel between virtualized lines. export function alignmentGeometryChanged(previous: readonly number[], next: readonly number[]) { @@ -52,77 +50,68 @@ export function clearLineAlignment(a: EditorView, b: EditorView) { return changed; } -// Keep spacers outside both viewports fixed. Include pairs visible on either side -// so resizing or a large insertion cannot leave the two viewports disconnected. -export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly Chunk[]) { +export function lineTextTop(view: EditorView, pos: number) { + const block = view.lineBlockAt(pos); + const text = Array.isArray(block.type) + ? block.type.find((part) => part.type === BlockType.Text && part.from === pos) + : block.type === BlockType.Text + ? block + : null; + return text?.from === pos ? text.top : null; +} + +// Measure matching rows locally; estimate unmatched ranges so large insertions remain scrollable. +export function alignChangedLines( + a: EditorView, + b: EditorView, + pairs: readonly [number, number][] +) { const previous = (view: EditorView) => { const result = new Map(); for (let cursor = view.state.field(lineAlignment).iter(); cursor.value; cursor.next()) - result.set(cursor.from, cursor.value.spec.widget.height); + result.set(cursor.value.spec.row, cursor.value.spec.widget.height); return result; }; const oldA = previous(a), 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) - ? block.type.find((part) => part.type === BlockType.Text && part.from === pos) - : block.type === BlockType.Text - ? block - : null; - return text?.from === pos ? text.top : null; - }; - const align = (posA: number, posB: number) => { - if ( - (posA < a.viewport.from || posA > a.viewport.to) && - (posB < b.viewport.from || posB > 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, Math.min(a.viewport.from, fromA + b.viewport.from - fromB)) - ) - ).from - ); - for ( - ; - posA < endA && posA <= Math.max(a.viewport.to, fromA + b.viewport.to - fromB); - posA = a.state.doc.lineAt(posA).to + 1 - ) { - const posB = fromB + posA - fromA; - align(posA, posB); + // Compare heights between adjacent matching rows, never estimated offsets from document start. + for (let index = 0; index <= pairs.length; index++) { + const start = index ? pairs[index - 1] : null; + const end = + index < pairs.length ? pairs[index] : [a.state.doc.length + 1, b.state.doc.length + 1]; + const from = start ?? [0, 0]; + const to = [Math.min(end[0], a.state.doc.length), Math.min(end[1], b.state.doc.length)]; + const measured = + from[0] >= a.viewport.from && + from[1] >= b.viewport.from && + to[0] <= a.viewport.to && + to[1] <= b.viewport.to; + if (!measured) { + // Keep estimates fixed until the range is rendered, otherwise they feed back into the height map. + if (oldA.has(end[0]) || oldB.has(end[1])) continue; + const matchedRows = start && index < pairs.length ? 1 : 0; + const unmatched = + a.state.doc.lineAt(to[0]).number - a.state.doc.lineAt(from[0]).number > matchedRows || + b.state.doc.lineAt(to[1]).number - b.state.doc.lineAt(from[1]).number > matchedRows; + if (!unmatched) continue; } - 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 heights = [a, b].map((view, side) => { + const from = start ? (measured ? start[side] : view.state.doc.lineAt(start[side]).to + 1) : 0, + to = Math.min(end[side], view.state.doc.length); + if (!measured && from >= end[side]) return 0; + const top = start ? lineTextTop(view, from) : 0; + const bottom = + end[side] > view.state.doc.length ? view.lineBlockAt(to).bottom : lineTextTop(view, to); + return top === null || bottom === null ? null : bottom - top; + }); + if (heights[0] === null || heights[1] === null) continue; + const difference = heights[0] - (oldA.get(end[0]) ?? 0) - heights[1] + (oldB.get(end[1]) ?? 0); + nextA.delete(end[0]); + nextB.delete(end[1]); + if (difference < -1) nextA.set(end[0], -difference); + if (difference > 1) nextB.set(end[1], difference); } const apply = (view: EditorView, old: Map, next: Map) => { if ( @@ -134,7 +123,12 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly effects: setSpacers.of( Decoration.set( [...next].map(([pos, height]) => - Decoration.widget({ widget: new RowSpacer(height), block: true, side: -1 }).range(pos) + Decoration.widget({ + widget: new RowSpacer(height), + block: true, + side: pos ? 1 : -1, + row: pos + }).range(Math.max(0, pos - 1)) ), true )