From 47bbb659e653c9d79dab64cb80b0b5736ca65d29 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Tue, 19 May 2026 22:22:52 +0900 Subject: [PATCH] [backport core/1.44] fix: stop trackpad pinch/swipe gestures from breaking the UI (#12290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Alexander Brown --- index.html | 4 + src/base/wheelGestures.ts | 20 ++++ .../core/canvas/useCanvasInteractions.test.ts | 111 +++++++++++++++++- .../core/canvas/useCanvasInteractions.ts | 31 +++-- .../form/dropdown/FormDropdownMenu.test.ts | 51 +++++++- .../form/dropdown/FormDropdownMenu.vue | 14 +++ 6 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 src/base/wheelGestures.ts diff --git a/index.html b/index.html index 5d72443e53..f737e6c766 100644 --- a/index.html +++ b/index.html @@ -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; diff --git a/src/base/wheelGestures.ts b/src/base/wheelGestures.ts new file mode 100644 index 0000000000..3e4f952759 --- /dev/null +++ b/src/base/wheelGestures.ts @@ -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) diff --git a/src/renderer/core/canvas/useCanvasInteractions.test.ts b/src/renderer/core/canvas/useCanvasInteractions.test.ts index f73d717b70..3a158c3a5f 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.test.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.test.ts @@ -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 = { 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) + }) }) }) diff --git a/src/renderer/core/canvas/useCanvasInteractions.ts b/src/renderer/core/canvas/useCanvasInteractions.ts index b93d4ff150..da70845c09 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.ts @@ -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) } /** diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts index c13628fa0f..5125065aa4 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts @@ -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) + }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue index 1ee7cccc4d..d3ad3aabe9 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue @@ -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(() => 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() +}