Forward scroll unless focused (#6597)

## Summary

Forward wheel events to the canvas unless a wheel-capturing element is
focused, and ensure the Load3D scene becomes focusable on pointer
interaction so its wheel zoom/pan works after the user clicks into it.

## Changes

- **What**: gate wheel forwarding on focused capture elements; focus the
Load3D scene container on pointerdown to opt into wheel capture.
- **Dependencies**: none

## Review Focus

- Validate wheel forwarding behavior across focusable inputs vs.
non-focusable capture zones.
- Confirm Load3D zoom/pan only captures wheel after a user click (canvas
pan should still work when merely hovering).

## Screenshots (if applicable)

N/A

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Benjamin Lu
2026-02-01 06:32:10 -08:00
committed by GitHub
parent 544ef5bb70
commit 4e20b7522b
3 changed files with 95 additions and 17 deletions

View File

@@ -3,7 +3,8 @@
ref="container"
class="relative h-full w-full min-h-[200px]"
data-capture-wheel="true"
@pointerdown.stop
tabindex="-1"
@pointerdown.stop="focusContainer"
@pointermove.stop
@pointerup.stop
@mousedown.stop
@@ -45,6 +46,10 @@ const props = defineProps<{
const container = ref<HTMLElement | null>(null)
function focusContainer() {
container.value?.focus()
}
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {

View File

@@ -155,5 +155,72 @@ describe('useCanvasInteractions', () => {
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
it('should forward wheel events to canvas when capture element is NOT focused', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent()
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
})
it('should NOT forward wheel events when capture element IS focused', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent()
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
document.body.removeChild(captureElement)
})
it('should forward ctrl+wheel to canvas when capture element IS focused in standard mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(true)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
})
})
})

View File

@@ -26,19 +26,31 @@ export function useCanvasInteractions() {
() => !(canvasStore.canvas?.read_only ?? false)
)
/**
* Returns true if the wheel event target is inside an element that should
* capture wheel events AND that element (or a descendant) currently has focus.
*
* This allows two-finger panning to continue over inputs until the user has
* actively focused the widget, at which point the widget can consume scroll.
*/
const wheelCapturedByFocusedElement = (event: WheelEvent): boolean => {
const target = event.target as HTMLElement | null
const captureElement = target?.closest('[data-capture-wheel="true"]')
const active = document.activeElement as Element | null
return !!(captureElement && active && captureElement.contains(active))
}
const shouldForwardWheelEvent = (event: WheelEvent): boolean =>
!wheelCapturedByFocusedElement(event) ||
(isStandardNavMode.value && (event.ctrlKey || event.metaKey))
/**
* Handles wheel events from UI components that should be forwarded to canvas
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
*/
const handleWheel = (event: WheelEvent) => {
// Check if the wheel event is from an element that wants to capture wheel events
const target = event.target as HTMLElement
const captureElement = target?.closest('[data-capture-wheel="true"]')
if (captureElement) {
// Element wants to capture wheel events, don't forward to canvas
return
}
if (!shouldForwardWheelEvent(event)) return
// In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
@@ -87,14 +99,8 @@ export function useCanvasInteractions() {
const forwardEventToCanvas = (
event: WheelEvent | PointerEvent | MouseEvent
) => {
// Check if the wheel event is from an element that wants to capture wheel events
const target = event.target as HTMLElement
const captureElement = target?.closest('[data-capture-wheel="true"]')
if (captureElement) {
// Element wants to capture wheel events, don't forward to canvas
return
}
// Honor wheel capture only when the element is focused
if (event instanceof WheelEvent && !shouldForwardWheelEvent(event)) return
const canvasEl = app.canvas?.canvas
if (!canvasEl) return