mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user