From 596cdbb6caf6022947285722949de1f52272b673 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 19 Oct 2026 03:30:33 +0900 Subject: [PATCH] refac --- .../components/common/DiffEditorModal.svelte | 57 +++++-- src/lib/components/common/diffEditor.ts | 154 +++++++++++++++--- .../components/common/diffEditorAlignment.ts | 86 +++++----- 3 files changed, 223 insertions(+), 74 deletions(-) diff --git a/src/lib/components/common/DiffEditorModal.svelte b/src/lib/components/common/DiffEditorModal.svelte index 554965ae63..35afb38bf7 100644 --- a/src/lib/components/common/DiffEditorModal.svelte +++ b/src/lib/components/common/DiffEditorModal.svelte @@ -10,7 +10,7 @@ import { LanguageDescription, indentUnit } from '@codemirror/language'; import { languages } from '@codemirror/language-data'; import { oneDark } from '@codemirror/theme-one-dark'; - import type { Chunk, MergeView } from '@codemirror/merge'; + import type { Chunk } from '@codemirror/merge'; import Dropdown from './Dropdown.svelte'; import DropdownMenu from './DropdownMenu.svelte'; import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; @@ -42,7 +42,7 @@ chunkOverview, changedLineCounts, nearestOverviewChunk, - diffEditorConfig, + DiffEditorView, type MergeDirection } from './diffEditor'; @@ -78,7 +78,7 @@ let working = ''; let proposal = ''; let comparing = false; - let merge: MergeView | null = null; + let merge: DiffEditorView | null = null; let visibleChunks: readonly Chunk[] = []; let showOptions = false; let optionsDropdown: Dropdown; @@ -323,7 +323,6 @@ activeChunk = 0; if (merge) { clearLineAlignment(merge.a, merge.b); - merge.reconfigure({ gutter: !ignoreWhitespace && !ignoreEmphasis }); } updatePresentation?.(true); refresh(); @@ -331,10 +330,21 @@ const mountMerge = (node: HTMLElement) => { let disposed = false; - let view: MergeView | null = null; + let view: DiffEditorView | null = null; let observer: MutationObserver | undefined; let alignmentFrame = 0; let presentationFrame = 0; + let scrollAnchor: { editor: EditorView; position: number; top: number } | null = null; + const cancelScrollAnchor = () => { + scrollAnchor = null; + }; + for (const event of ['touchmove', 'pointerdown']) + node.addEventListener(event, cancelScrollAnchor, { passive: true }); + const advanceScrollAnchor = (event: WheelEvent) => { + if (scrollAnchor && event.deltaMode === 0) scrollAnchor.top -= event.deltaY; + else cancelScrollAnchor(); + }; + node.addEventListener('wheel', advanceScrollAnchor, { passive: true }); let expanded: CollapsedPair[] = []; const schedulePresentation = (reset = false) => { if (reset) expanded = []; @@ -396,6 +406,7 @@ 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) { @@ -417,6 +428,14 @@ 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, @@ -433,28 +452,39 @@ resized); alignmentDocuments = documents; if (!alignmentGeometry || resized) alignmentGeometry = geometry; + const top = view.dom.getBoundingClientRect().top + 1; + for (const editor of [view.a, view.b]) { + const position = editor.posAtCoords({ + x: editor.contentDOM.getBoundingClientRect().left + 4, + y: top + }); + const coords = position === null ? null : editor.coordsAtPos(position); + if (position !== null && coords) { + scrollAnchor = { editor, position, top: coords.top }; + break; + } + } if (!reset || !clearLineAlignment(view.a, view.b)) alignChangedLines(view.a, view.b, view.chunks); + if (!alignmentFrame) scrollAnchor = null; } }); } }), EditorView.theme({ '&': { fontSize: '0.75rem' }, + '&, .cm-scroller': { height: 'auto', overflowY: 'visible' }, '.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }, '&.cm-focused': { outline: 'none' } }) ]; void (async () => { try { - const { MergeView } = await import('@codemirror/merge'); if (disposed) return; - view = new MergeView({ + view = new DiffEditorView({ a: { doc: working, extensions: extensions('a', 0) }, b: { doc: proposal, extensions: extensions('b', 1) }, - parent: node, - gutter: !ignoreWhitespace && !ignoreEmphasis, - diffConfig: diffEditorConfig + parent: node }); merge = view; refresh(); @@ -489,6 +519,9 @@ 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; @@ -962,6 +995,8 @@ overflow: auto; } .merge-container :global(.cm-mergeViewEditor) { + flex: 1 1 0; + overflow: hidden; min-width: 0; } .merge-container :global(.cm-merge-a .cm-changedLine), @@ -1022,6 +1057,8 @@ background: #101413 !important; } .merge-container :global(.cm-mergeViewEditors) { + display: flex; + align-items: stretch; gap: 1rem; } .diff-editor :global(.cm-gutters) { diff --git a/src/lib/components/common/diffEditor.ts b/src/lib/components/common/diffEditor.ts index 4138cbf950..2dba552bb0 100644 --- a/src/lib/components/common/diffEditor.ts +++ b/src/lib/components/common/diffEditor.ts @@ -1,10 +1,82 @@ -import { StateEffect, StateField, type ChangeSpec, type Text } from '@codemirror/state'; -import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view'; +import { + StateEffect, + StateField, + RangeSet, + ChangeSet, + EditorState, + type EditorStateConfig, + type Transaction, + type ChangeSpec, + type Text +} from '@codemirror/state'; +import { + Decoration, + EditorView, + GutterMarker, + gutter, + WidgetType, + type DecorationSet +} from '@codemirror/view'; import { Chunk, type DiffConfig } from '@codemirror/merge'; // A scanLimit also gives up based on document length, even for sparse, tiny edits. export const diffEditorConfig: DiffConfig = { timeout: 100 }; +// The existing row alignment owns spacing; MergeView's second spacer pass would fight it. +export class DiffEditorView { + readonly a: EditorView; + readonly b: EditorView; + readonly dom: HTMLDivElement; + chunks: readonly Chunk[]; + constructor(config: { a: EditorStateConfig; b: EditorStateConfig; parent: HTMLElement }) { + const states = (['a', 'b'] as const).map((side) => + EditorState.create({ + ...config[side], + extensions: [ + config[side].extensions ?? [], + EditorView.editorAttributes.of({ class: `cm-merge-${side}` }) + ] + }) + ); + this.chunks = Chunk.build(states[0].doc, states[1].doc, diffEditorConfig); + this.dom = document.createElement('div'); + this.dom.className = 'cm-mergeView'; + const editors = this.dom.appendChild(document.createElement('div')); + editors.className = 'cm-mergeViewEditors'; + const create = (side: 'a' | 'b', index: number) => { + const wrapper = editors.appendChild(document.createElement('div')); + wrapper.className = 'cm-mergeViewEditor'; + return new EditorView({ + state: states[index], + parent: wrapper, + dispatchTransactions: (transactions) => this.dispatch(transactions, side) + }); + }; + this.a = create('a', 0); + this.b = create('b', 1); + config.parent.appendChild(this.dom); + } + private dispatch(transactions: readonly Transaction[], side: 'a' | 'b') { + if (transactions.some((transaction) => transaction.docChanged)) { + const document = transactions[transactions.length - 1].newDoc; + const changes = transactions.reduce( + (changes, transaction) => changes.compose(transaction.changes), + ChangeSet.empty(transactions[0].startState.doc.length) + ); + this.chunks = + 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[side].update(transactions); + } + destroy() { + this.a.destroy(); + this.b.destroy(); + this.dom.remove(); + } +} + export type MergeDirection = 'to-working' | 'to-proposal'; // Match rows by the text preserved inside a chunk, not their ordinal position. @@ -217,8 +289,29 @@ const presentation = StateField.define({ }, provide: (field) => EditorView.decorations.from(field) }); +class ChangedLineMarker extends GutterMarker { + elementClass = 'cm-changedLineGutter'; +} +const changedLineMarker = new ChangedLineMarker(); + export const diffPresentation = [ presentation, + gutter({ + class: 'cm-changeGutter', + renderEmptyElements: true, + markers: (view) => { + const markers = []; + for (let cursor = view.state.field(presentation).iter(); cursor.value; cursor.next()) + if (cursor.value.spec.class === 'cm-visibleChangedLine') + markers.push(changedLineMarker.range(cursor.from)); + return RangeSet.of(markers, true); + } + }), + EditorView.baseTheme({ + '.cm-changeGutter': { width: '3px' }, + '&.cm-merge-a .cm-changedLineGutter': { backgroundColor: '#f0a394' }, + '&.cm-merge-b .cm-changedLineGutter': { backgroundColor: '#7dd3c5' } + }), EditorView.atomicRanges.of((view) => view.state .field(presentation) @@ -237,34 +330,41 @@ export function presentationDecorations( expand: (pair: CollapsedPair) => void ) { const decorations = []; - if (ignoreWhitespace || ignoreEmphasis) { - const visible = ignoreWhitespace ? (ignoreEmphasis ? /[^\s_*]+/g : /\S+/g) : /[^_*]+/g; - const rows = new Set(); - for (const chunk of chunks) { - const offset = side === 'a' ? chunk.fromA : chunk.fromB; - for (const change of chunk.changes) { - const from = offset + (side === 'a' ? change.fromA : change.fromB); - const to = offset + (side === 'a' ? change.toA : change.toB); - for (const match of doc.sliceString(from, to).matchAll(visible)) { - const start = from + match.index; - decorations.push( - Decoration.mark({ class: 'cm-visibleChangedText' }).range( - start, - start + match[0].length - ) - ); - for ( - let line = doc.lineAt(start).number; - line <= doc.lineAt(start + match[0].length - 1).number; - line++ - ) - rows.add(doc.line(line).from); - } + const visible = ignoreWhitespace + ? ignoreEmphasis + ? /[^\s_*]+/g + : /\S+/g + : ignoreEmphasis + ? /[^_*]+/g + : /[\s\S]+/g; + const rows = new Set(); + for (const chunk of chunks) { + const offset = side === 'a' ? chunk.fromA : chunk.fromB; + if (!ignoreWhitespace && !ignoreEmphasis) { + const end = side === 'a' ? chunk.toA : chunk.toB; + for (let from = offset; from < end && from <= doc.length; from = doc.lineAt(from).to + 1) + rows.add(from); + } + for (const change of chunk.changes) { + const from = offset + (side === 'a' ? change.fromA : change.fromB); + const to = offset + (side === 'a' ? change.toA : change.toB); + for (const match of doc.sliceString(from, to).matchAll(visible)) { + const start = from + match.index; + decorations.push( + Decoration.mark({ class: 'cm-visibleChangedText' }).range(start, start + match[0].length) + ); + for ( + let line = doc.lineAt(start).number; + line <= doc.lineAt(start + match[0].length - 1).number; + line++ + ) + rows.add(doc.line(line).from); } } - for (const from of rows) - decorations.push(Decoration.line({ class: 'cm-visibleChangedLine' }).range(from)); } + for (const from of rows) + decorations.push(Decoration.line({ class: 'cm-visibleChangedLine' }).range(from)); + for (const pair of folds) { const range = pair[side]; decorations.push( diff --git a/src/lib/components/common/diffEditorAlignment.ts b/src/lib/components/common/diffEditorAlignment.ts index 7e5269dca2..f93531a81e 100644 --- a/src/lib/components/common/diffEditorAlignment.ts +++ b/src/lib/components/common/diffEditorAlignment.ts @@ -9,8 +9,8 @@ import { 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[]) { - // 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)); } @@ -52,8 +52,8 @@ export function clearLineAlignment(a: EditorView, b: EditorView) { return changed; } -// MergeView owns alignment between chunks. Only align rows inside each changed -// chunk, using local heights so its spacers cannot feed back into ours. +// 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[]) { const previous = (view: EditorView) => { const result = new Map(); @@ -65,6 +65,8 @@ 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) @@ -74,43 +76,53 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly : null; return text?.from === pos ? text.top : null; }; - for (const chunk of chunks) { + const align = (posA: number, posB: number) => { if ( - chunk.toA < a.viewport.from || - chunk.toB < b.viewport.from || - chunk.fromA > a.viewport.to || - chunk.fromB > b.viewport.to + (posA < a.viewport.from || posA > a.viewport.to) && + (posB < b.viewport.from || posB > b.viewport.to) ) - 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; + 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); } + 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 (