fix(editor): support Shift+wheel scrolling in combined diffs (#11756)

* fix(editor): support Shift+wheel in combined diffs

* add active modified-pane test

* fix(editor): skip shift-wheel capture when a diff pane cannot scroll sideways

Word-wrapped panes never overflow horizontally, so consuming the gesture
left it dead instead of reaching the outer combined-diff list.

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
bix
2026-09-06 23:02:12 -07:00
committed by GitHub
co-authored by m4air
parent d53cbed43f
commit bd242a0158
3 changed files with 223 additions and 1 deletions
@@ -14,6 +14,7 @@ import { LargeDiffLoadPrompt } from './LargeDiffLoadPrompt'
import { buildDiffEditorWhitespaceOptions } from './diff-editor-whitespace-options'
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
import { monacoFindOptions } from './monaco-find-options'
import { installDiffEditorShiftWheelScroll } from './diff-editor-shift-wheel-scroll'
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
@@ -77,6 +78,11 @@ export function DiffSectionBody({
onMount
}: DiffSectionBodyProps): React.JSX.Element {
const renderLimit = section.largeDiffRenderLimit?.limited ? section.largeDiffRenderLimit : null
const handleEditorMount: DiffOnMount = (editor, monaco) => {
const cleanupShiftWheelScroll = installDiffEditorShiftWheelScroll(editor)
editor.onDidDispose(cleanupShiftWheelScroll)
onMount(editor, monaco)
}
return (
<div
@@ -190,7 +196,7 @@ export function DiffSectionBody({
original={section.originalContent}
modified={section.modifiedContent}
theme={isDark ? 'vs-dark' : 'vs'}
onMount={onMount}
onMount={handleEditorMount}
// Why: @monaco-editor/react can dispose models before widget teardown.
// Keep them through unmount and dispose unattached models next tick.
originalModelPath={`${modelPathBase}:original`}
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { installDiffEditorShiftWheelScroll } from './diff-editor-shift-wheel-scroll'
type PaneFixture = {
container: HTMLDivElement
input: HTMLDivElement
setScrollLeft: ReturnType<typeof vi.fn<(value: number) => void>>
getScrollLeft: () => number
getContainerDomNode: () => HTMLElement
getScrollWidth: () => number
getLayoutInfo: () => { contentWidth: number }
}
function createPaneFixture(initialScrollLeft = 10, scrollWidth = 1000): PaneFixture {
const container = document.createElement('div')
const input = document.createElement('div')
let scrollLeft = initialScrollLeft
const setScrollLeft = vi.fn((value: number) => {
scrollLeft = value
})
Object.defineProperty(container, 'clientWidth', { value: 200 })
container.appendChild(input)
document.body.appendChild(container)
return {
container,
input,
setScrollLeft,
getScrollLeft: () => scrollLeft,
getContainerDomNode: () => container,
getScrollWidth: () => scrollWidth,
getLayoutInfo: () => ({ contentWidth: 200 })
}
}
function dispatchWheel(target: HTMLElement, init: WheelEventInit): WheelEvent {
const event = new WheelEvent('wheel', { ...init, bubbles: true, cancelable: true })
// Happy DOM's WheelEvent omits mouse modifier fields.
Object.defineProperty(event, 'shiftKey', { value: init.shiftKey ?? false })
target.dispatchEvent(event)
return event
}
afterEach(() => {
document.body.replaceChildren()
})
describe('installDiffEditorShiftWheelScroll', () => {
it.each([
{ label: 'vertical pixel input', init: { deltaY: 24 }, expected: 34 },
{ label: 'platform-converted horizontal input', init: { deltaX: 12 }, expected: 22 },
{
label: 'line-based input',
init: { deltaY: -2, deltaMode: WheelEvent.DOM_DELTA_LINE },
expected: -22
},
{
label: 'page-based input',
init: { deltaY: 1, deltaMode: WheelEvent.DOM_DELTA_PAGE },
expected: 210
}
])('scrolls the pane under the pointer for $label', ({ init, expected }) => {
const original = createPaneFixture()
const modified = createPaneFixture()
const onDownstreamWheel = vi.fn()
original.input.addEventListener('wheel', onDownstreamWheel)
const dispose = installDiffEditorShiftWheelScroll({
getOriginalEditor: () => original,
getModifiedEditor: () => modified
})
const event = dispatchWheel(original.input, { ...init, shiftKey: true })
expect(event.defaultPrevented).toBe(true)
expect(original.setScrollLeft).toHaveBeenCalledWith(expected)
expect(modified.setScrollLeft).not.toHaveBeenCalled()
expect(onDownstreamWheel).not.toHaveBeenCalled()
dispose()
})
it('leaves ordinary vertical wheel input for the outer combined-diff scroller', () => {
const original = createPaneFixture()
const modified = createPaneFixture()
const onDownstreamWheel = vi.fn()
original.input.addEventListener('wheel', onDownstreamWheel)
const dispose = installDiffEditorShiftWheelScroll({
getOriginalEditor: () => original,
getModifiedEditor: () => modified
})
const event = dispatchWheel(original.input, { deltaY: 24 })
expect(event.defaultPrevented).toBe(false)
expect(original.setScrollLeft).not.toHaveBeenCalled()
expect(onDownstreamWheel).toHaveBeenCalledTimes(1)
dispose()
})
it('leaves shift input alone when the pane has no horizontal overflow', () => {
const original = createPaneFixture(0, 200)
const modified = createPaneFixture(0, 200)
const onDownstreamWheel = vi.fn()
original.input.addEventListener('wheel', onDownstreamWheel)
const dispose = installDiffEditorShiftWheelScroll({
getOriginalEditor: () => original,
getModifiedEditor: () => modified
})
const event = dispatchWheel(original.input, { deltaY: 24, shiftKey: true })
expect(event.defaultPrevented).toBe(false)
expect(original.setScrollLeft).not.toHaveBeenCalled()
expect(onDownstreamWheel).toHaveBeenCalledTimes(1)
dispose()
})
// Monaco syncs pane scroll itself; this covers listener routing, not product-level pane independence.
it('routes the wheel event to the pane under the pointer', () => {
const original = createPaneFixture()
const modified = createPaneFixture()
const dispose = installDiffEditorShiftWheelScroll({
getOriginalEditor: () => original,
getModifiedEditor: () => modified
})
const event = dispatchWheel(modified.input, { deltaY: 24, shiftKey: true })
expect(event.defaultPrevented).toBe(true)
expect(modified.setScrollLeft).toHaveBeenCalledWith(34)
expect(original.setScrollLeft).not.toHaveBeenCalled()
dispose()
})
it('removes both pane listeners when disposed', () => {
const original = createPaneFixture()
const modified = createPaneFixture()
const dispose = installDiffEditorShiftWheelScroll({
getOriginalEditor: () => original,
getModifiedEditor: () => modified
})
dispose()
const originalEvent = dispatchWheel(original.input, { deltaY: 24, shiftKey: true })
const modifiedEvent = dispatchWheel(modified.input, { deltaY: 24, shiftKey: true })
expect(originalEvent.defaultPrevented).toBe(false)
expect(modifiedEvent.defaultPrevented).toBe(false)
expect(original.setScrollLeft).not.toHaveBeenCalled()
expect(modified.setScrollLeft).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,64 @@
import type { editor } from 'monaco-editor'
const WHEEL_LINE_PIXELS = 16
type HorizontalScrollEditor = Pick<
editor.ICodeEditor,
'getContainerDomNode' | 'getScrollLeft' | 'setScrollLeft' | 'getScrollWidth'
> & { getLayoutInfo: () => Pick<editor.EditorLayoutInfo, 'contentWidth'> }
type DiffEditorWithPanes = {
getModifiedEditor: () => HorizontalScrollEditor
getOriginalEditor: () => HorizontalScrollEditor
}
function getHorizontalWheelPixels(event: WheelEvent, pageWidth: number): number {
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
return delta * WHEEL_LINE_PIXELS
}
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return delta * pageWidth
}
return delta
}
function canScrollHorizontally(editor: HorizontalScrollEditor): boolean {
return editor.getScrollWidth() > editor.getLayoutInfo().contentWidth
}
function installPaneShiftWheelScroll(editor: HorizontalScrollEditor): () => void {
const container = editor.getContainerDomNode()
const handleWheel = (event: WheelEvent): void => {
if (event.defaultPrevented || !event.shiftKey) {
return
}
// Why: a word-wrapped pane never overflows sideways, so leave the gesture to the outer list.
if (!canScrollHorizontally(editor)) {
return
}
const delta = getHorizontalWheelPixels(event, container.clientWidth)
if (delta === 0) {
return
}
// Why: combined diffs disable Monaco wheel handling so vertical input can reach the outer list.
event.preventDefault()
event.stopPropagation()
editor.setScrollLeft(editor.getScrollLeft() + delta)
}
container.addEventListener('wheel', handleWheel, { capture: true, passive: false })
return () => container.removeEventListener('wheel', handleWheel, true)
}
export function installDiffEditorShiftWheelScroll(editor: DiffEditorWithPanes): () => void {
const cleanupOriginal = installPaneShiftWheelScroll(editor.getOriginalEditor())
const cleanupModified = installPaneShiftWheelScroll(editor.getModifiedEditor())
return () => {
cleanupOriginal()
cleanupModified()
}
}