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()
+}