diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index d2f0b9f49..8685fb933 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -1336,12 +1336,7 @@ export class Editor implements DiffsEditor { } // Drop the DOM-geometry caches (memoized row elements, measured line Ys, - // last caret x). Wrap offsets are intentionally NOT cleared here: they - // depend only on line text, font metrics, and content width — not on the - // rendered rows — so they stay valid across render-range syncs and row - // rebuilds. The sites where those inputs actually change (text edits in - // #applyChange, width changes in #handleLayoutResize, font remeasure, - // document swaps, cleanUp) invalidate the wrap cache themselves. + // last caret x). #resetCache(): void { this.#lineYCache.clear(); this.#lineElementsCache.clear(); @@ -3158,16 +3153,11 @@ export class Editor implements DiffsEditor { ); } - // A diff re-renders its rows in place after the edits above: a unified diff - // rebuilds its content column on every edit (FileDiff.refreshDiffView swaps - // the column's innerHTML), and any diff rebuilds on a line-count change - // (applyDocumentChange -> full render). That detaches the line elements this - // editor memoized for caret/selection geometry (#lineYCache, - // #lastAccessedLineElement), so a detached row would measure offsetTop 0 and - // the caret would render at the top - the following #scrollToPrimaryCaret - // then scrolls the viewport there. Drop the geometry caches so the overlay - // re-measures against the freshly rebuilt rows and the caret stays put. - if (this.#isDiff && (this.#diffSyle === 'unified' || didLineCountChange)) { + // A line-count change can remove cached rows, while a unified diff rebuilds + // its content column on every edit. Either can detach the line elements + // memoized for caret/selection geometry, making offsetTop read as 0 and + // scrolling the caret to the top. Re-measure against the current rows. + if (didLineCountChange || (this.#isDiff && this.#diffSyle === 'unified')) { this.#resetCache(); } diff --git a/packages/diffs/test/editorVirtualizedEdit.test.ts b/packages/diffs/test/editorVirtualizedEdit.test.ts index a2d086d86..15cd521c3 100644 --- a/packages/diffs/test/editorVirtualizedEdit.test.ts +++ b/packages/diffs/test/editorVirtualizedEdit.test.ts @@ -114,6 +114,68 @@ async function createWindowedEditor( } describe('Editor edits at the bottom of a virtualized window', () => { + test('keeps the last-line caret anchored after Enter, Delete, Enter', async () => { + const { cleanup, content, editor, fileContainer } = + await createWindowedEditor(1_000, makeRange(950, 50)); + try { + Object.defineProperty(window.HTMLElement.prototype, 'offsetTop', { + configurable: true, + get(this: HTMLElement) { + const line = Number(this.dataset.line); + return this.isConnected && line > 0 ? line * 20 : 0; + }, + }); + + editor.setSelections([ + { + start: { line: 999, character: 9 }, + end: { line: 999, character: 9 }, + direction: 'none', + }, + ]); + content.dispatchEvent( + new window.InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + composed: true, + inputType: 'insertParagraph', + }) + ); + await wait(0); + const firstCaretTransform = + fileContainer.shadowRoot?.querySelector('[data-caret]') + ?.style.transform; + expect(firstCaretTransform).toBeDefined(); + + content.dispatchEvent( + new window.InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + composed: true, + inputType: 'deleteContentBackward', + }) + ); + await wait(0); + content.dispatchEvent( + new window.InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + composed: true, + inputType: 'insertParagraph', + }) + ); + await wait(0); + + expect(editor.getState().selections?.at(-1)?.start.line).toBe(1_000); + expect( + fileContainer.shadowRoot?.querySelector('[data-caret]') + ?.style.transform + ).toBe(firstCaretTransform); + } finally { + cleanup(); + } + }); + // A run of Enter presses at the window's bottom edge must keep rendering each // new line. The editor widens its render range when the caret reaches the // last rendered line; if that widened range is not persisted, the next edit