[backport core/1.44] fix: stop trackpad pinch/swipe gestures from breaking the UI (#12290)

Backport of #12052 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12290-backport-core-1-44-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3616d73d365081d697c9e8fb9b0d489d)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Comfy Org PR Bot
2026-05-19 22:22:52 +09:00
committed by GitHub
parent b059c22def
commit 47bbb659e6
6 changed files with 214 additions and 17 deletions

View File

@@ -26,6 +26,10 @@
width: 100%;
height: 100%;
margin: 0;
/* Disable trackpad two-finger horizontal swipe back/forward navigation
and other overscroll gestures. ComfyUI is a full-screen editor; the
browser's overscroll behaviors only ever leave or break the workflow. */
overscroll-behavior: none;
}
body {
display: grid;

20
src/base/wheelGestures.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Wheel events whose browser default would break the editing experience.
* On macOS trackpads:
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
* recovery short of a page reload.
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
* back/forward navigation, which leaves the workflow.
*
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
* trackpad samples) intentionally falls on the false branch so native
* vertical scroll wins on a tie.
*
* Components that intercept wheel events should suppress the default for
* these gestures even when they otherwise let the browser scroll natively.
*/
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
event.ctrlKey ||
event.metaKey ||
Math.abs(event.deltaX) > Math.abs(event.deltaY)

View File

@@ -46,10 +46,17 @@ function createMockPointerEvent(
return mockEvent as PointerEvent
}
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
function createMockWheelEvent(
ctrlKey = false,
metaKey = false,
deltaX = 0,
deltaY = 0
): WheelEvent {
const mockEvent: Partial<WheelEvent> = {
ctrlKey,
metaKey,
deltaX,
deltaY,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
@@ -222,5 +229,107 @@ describe('useCanvasInteractions', () => {
document.body.removeChild(captureElement)
})
/** Regression: trackpad pinch-zoom inside a focused textarea must not
* fall through to browser page zoom in non-standard navigation modes. */
it.for(['legacy', 'custom'])(
'should forward ctrl+wheel to canvas when capture element IS focused in %s mode',
(mode) => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue(mode)
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)
}
)
it('should forward meta+wheel to canvas when capture element IS focused', () => {
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(false, true)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
})
/** Regression: trackpad two-finger horizontal swipes inside a focused
* textarea must not fall through to browser back/forward navigation. */
it.for(['standard', 'legacy', 'custom'])(
'should forward horizontal-dominant wheel to canvas when capture element IS focused in %s mode',
(mode) => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue(mode)
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(false, false, 30, 5)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
}
)
it('should NOT forward vertical-dominant wheel when capture element IS focused', () => {
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(false, false, 0, 30)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
document.body.removeChild(captureElement)
})
})
})

View File

@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -41,30 +42,34 @@ export function useCanvasInteractions() {
return !!(captureElement && active && captureElement.contains(active))
}
/**
* Forward to canvas when the event is not consumed by a focused widget,
* or when it is a canvas gesture (which must override widget consumption
* to prevent destructive browser defaults).
*/
const shouldForwardWheelEvent = (event: WheelEvent): boolean =>
!wheelCapturedByFocusedElement(event) ||
(isStandardNavMode.value && (event.ctrlKey || event.metaKey))
!wheelCapturedByFocusedElement(event) || isCanvasGestureWheel(event)
/**
* Handles wheel events from UI components that should be forwarded to canvas
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
* when appropriate (e.g., Ctrl+wheel for zoom, two-finger pan in standard
* mode; all wheel events in legacy mode).
*/
const handleWheel = (event: WheelEvent) => {
if (!shouldForwardWheelEvent(event)) return
// In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
forwardEventToCanvas(event)
// In standard mode, only canvas gestures (zoom/pan) are forwarded;
// vertical wheel falls through so the document/widget scrolls normally.
// The re-check is intentional and NOT redundant with shouldForwardWheelEvent:
// that function also returns true for unfocused vertical wheel (its
// `!wheelCapturedByFocusedElement` branch), which here must stay native.
if (isStandardNavMode.value) {
if (isCanvasGestureWheel(event)) forwardEventToCanvas(event)
return
}
// In legacy mode, all wheel events go to canvas for zoom
if (!isStandardNavMode.value) {
forwardEventToCanvas(event)
return
}
// Otherwise, let the component handle it normally
// In legacy mode, all forwardable wheel events go to canvas for zoom/pan.
forwardEventToCanvas(event)
}
/**

View File

@@ -94,14 +94,59 @@ describe('FormDropdownMenu', () => {
})
it('has data-capture-wheel="true" on the root element', () => {
const { container } = render(FormDropdownMenu, {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
expect(
// eslint-disable-next-line testing-library/no-node-access
container.firstElementChild!.getAttribute('data-capture-wheel')
screen
.getByTestId('form-dropdown-menu')
.getAttribute('data-capture-wheel')
).toBe('true')
})
/** Regression: PrimeVue Popover teleports the menu to document.body, so
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
* itself rather than relying on the LGraphNode wheel handler. */
it.for([
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
])('suppresses browser default for $name', ({ overrides }) => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true
})
Object.entries(overrides).forEach(([key, value]) => {
Object.defineProperty(event, key, { value })
})
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
/** Vertical scrolling must remain native so the dropdown's own scroll
* container can scroll its content. */
it('does not suppress vertical scroll', () => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', {
deltaY: 30,
bubbles: true,
cancelable: true
})
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})

View File

@@ -3,6 +3,7 @@ import type { CSSProperties } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import type {
FilterOption,
@@ -93,12 +94,25 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
key: String(item.id)
}))
)
/**
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
* Suppress only the destructive browser defaults (page zoom on pinch and
* back/forward on horizontal swipe); regular vertical scrolling still
* scrolls the dropdown's own content.
*/
const onWheel = (event: WheelEvent) => {
if (isCanvasGestureWheel(event)) event.preventDefault()
}
</script>
<template>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-capture-wheel="true"
data-testid="form-dropdown-menu"
@wheel="onWheel"
>
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"