Compare commits

...

4 Commits

Author SHA1 Message Date
Glary-Bot
7cfaeb33f8 fix: clamp ContextMenu position against ownerDocument body
Last remaining global `document.body` reference in the positioning
block: switch to `ownerDocument.body` so menus opened inside iframes,
fullscreen elements, or alternate documents are clamped against the
correct viewport.
2026-05-16 03:17:23 +00:00
Glary-Bot
1e381cccf8 fix: address review feedback
- ContextMenu: use the menu's owner document (event target ownerDocument or fallback) for listeners, DOM insertion, and lifecycle events, so menus opened in fullscreen or alternate documents stay consistent.
- raisedSurfaceLiteGraphBridge: track active context-menu ids and release them on scope dispose, so a menu open during unmount doesn't leak a stale store entry.
- ContextMenu tests: close all top-level menus in afterEach to prevent document-level listeners leaking between tests.
2026-05-16 03:12:15 +00:00
Glary-Bot
16dc701f4c fix: prevent escape from exiting subgraph while a context menu is open
Pressing Escape while a right-click context menu is open inside a
subgraph used to fire the global Comfy.Graph.ExitSubgraph keybinding,
exiting the subgraph while leaving the menu open. The keybinding service
only suppressed Escape when a Pinia dialog was open, so other raised
surfaces leaked the event to window-level handlers.

Introduce a raisedSurfaceStore that tracks open popovers, context menus,
and top-level modals as a single source of truth, and consult it from
the keybinding service alongside the existing dialog check. The legacy
LiteGraph ContextMenu now closes itself on Escape (mirroring its
existing outside-pointerdown handler) and reports open/close lifecycle
via document events so the store stays in sync without monkey patches.
NodeContextMenu registers itself via the new useRaisedSurface composable.

Fixes the bug demonstrated in the attached screencast.
2026-05-15 20:39:05 +00:00
Rizumu Ayaka
71092b2011 fix: stop trackpad pinch/swipe gestures from breaking the UI (#12052)
## Summary

On macOS trackpads, several browser default gestures were leaking
through and breaking the workflow:

- **Pinch-zoom on a focused textarea widget** triggered page-level zoom,
pushing `position: fixed` UI (notably `ComfyActionbar`) off-screen until
a reload.
- **Horizontal swipe on a focused textarea widget** triggered browser
back/forward, leaving the workflow.
- **Pinch / horizontal swipe inside the image picker dropdown** had the
same two issues, because PrimeVue `Popover` teleports content to
`document.body` and the `LGraphNode` wheel handler never sees the
events.

Fixes FE-292.

## Why

- **`overscroll-behavior: none` on `html, body`** — horizontal swipe to
back/forward is decided by the browser at gesture start; JS
preventDefault can't reliably beat it. `overscroll-behavior` is the
standards-track signal for opting out, and ComfyUI is a full-screen
editor that never benefits from native overscroll.
- **`useCanvasInteractions` now treats pinch-zoom and
horizontal-dominant wheel as canvas gestures** that override widget
wheel consumption, so the gesture pans/zooms the canvas instead of
falling through to destructive browser defaults. The check is exported
as `isCanvasGestureWheel` for reuse.
- **`FormDropdownMenu` has its own minimal `onWheel`** that only
preventDefaults destructive gestures and deliberately does not forward
to the canvas. The dropdown is its own scroll container and shouldn't
leak interactions into the editor; vertical scrolling stays native.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12052-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3596d73d3650810aac3fcd8a71b29f9e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-15 14:35:54 +00:00
15 changed files with 536 additions and 28 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

@@ -152,6 +152,7 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useLiteGraphContextMenuTracking } from '@/platform/keybindings/raisedSurfaceLiteGraphBridge'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -460,6 +461,7 @@ useNodeBadge()
useGlobalLitegraph()
useContextMenuTranslation()
useLiteGraphContextMenuTracking()
useCopy()
usePaste()
useWorkflowAutoSave()

View File

@@ -52,6 +52,7 @@ import type {
MenuOption,
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useRaisedSurface } from '@/platform/keybindings/raisedSurfaceStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
@@ -69,6 +70,8 @@ const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
const canvasStore = useCanvasStore()
useRaisedSurface('context-menu', isOpen)
// World position (canvas coordinates) where menu was opened
const worldPosition = ref({ x: 0, y: 0 })

View File

@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ContextMenu } from '@/lib/litegraph/src/ContextMenu'
describe('ContextMenu lifecycle events', () => {
let listener: (event: Event) => void
let calls: Event[]
let menus: ContextMenu[]
beforeEach(() => {
document.body.innerHTML = ''
calls = []
menus = []
listener = (event) => calls.push(event)
document.addEventListener('litegraph:contextmenu', listener)
})
afterEach(() => {
for (const menu of menus) menu.close()
document.removeEventListener('litegraph:contextmenu', listener)
})
it('dispatches an "open" event when a top-level menu is constructed', () => {
menus.push(new ContextMenu(['Item A', 'Item B'], {}))
expect(calls).toHaveLength(1)
const detail = (calls[0] as CustomEvent).detail
expect(detail.type).toBe('open')
})
it('dispatches a "close" event when a top-level menu closes', () => {
const menu = new ContextMenu(['Item A'], {})
calls.length = 0
menu.close()
expect(calls).toHaveLength(1)
const detail = (calls[0] as CustomEvent).detail
expect(detail.type).toBe('close')
expect(detail.menu).toBe(menu)
})
it('does not dispatch lifecycle events for submenus', () => {
const parent = new ContextMenu(['Item A'], {})
menus.push(parent)
calls.length = 0
new ContextMenu(['Sub Item'], { parentMenu: parent })
expect(calls).toHaveLength(0)
})
})

View File

@@ -56,6 +56,7 @@ export class ContextMenu<TValue = unknown> {
root: ContextMenuDivElement<TValue>
current_submenu?: ContextMenu<TValue>
lock?: boolean
ownerDocument: Document
controller: AbortController = new AbortController()
@@ -105,7 +106,13 @@ export class ContextMenu<TValue = unknown> {
options.event = undefined
}
const root: ContextMenuDivElement<TValue> = document.createElement('div')
const ownerDocument =
(options.event?.target as Node | null | undefined)?.ownerDocument ??
document
this.ownerDocument = ownerDocument
const root: ContextMenuDivElement<TValue> =
ownerDocument.createElement('div')
let classes = 'litegraph litecontextmenu litemenubar-panel'
if (options.className) classes += ` ${options.className}`
root.className = classes
@@ -117,7 +124,7 @@ export class ContextMenu<TValue = unknown> {
const eventOptions = { capture: true, signal }
if (!this.parentMenu) {
document.addEventListener(
ownerDocument.addEventListener(
'pointerdown',
(e) => {
if (e.target instanceof Node && !this.containsNode(e.target)) {
@@ -126,6 +133,16 @@ export class ContextMenu<TValue = unknown> {
},
eventOptions
)
ownerDocument.addEventListener(
'keydown',
(e) => {
if (e.key !== 'Escape' || e.ctrlKey || e.altKey || e.metaKey) return
this.close()
e.preventDefault()
e.stopPropagation()
},
eventOptions
)
}
// this prevents the default context browser menu to open in case this menu was created when pressing right button
@@ -179,13 +196,9 @@ export class ContextMenu<TValue = unknown> {
}
// insert before checking position
const ownerDocument = (options.event?.target as Node | null | undefined)
?.ownerDocument
const root_document = ownerDocument || document
if (root_document.fullscreenElement)
root_document.fullscreenElement.append(root)
else root_document.body.append(root)
if (ownerDocument.fullscreenElement)
ownerDocument.fullscreenElement.append(root)
else ownerDocument.body.append(root)
// compute best position
let left = options.left || 0
@@ -200,7 +213,7 @@ export class ContextMenu<TValue = unknown> {
left = rect.left + rect.width
}
const body_rect = document.body.getBoundingClientRect()
const body_rect = ownerDocument.body.getBoundingClientRect()
const root_rect = root.getBoundingClientRect()
if (body_rect.height == 0)
console.error(
@@ -219,6 +232,14 @@ export class ContextMenu<TValue = unknown> {
if (LiteGraph.context_menu_scaling && options.scale) {
root.style.transform = `scale(${Math.round(options.scale * 4) * 0.25})`
}
if (!parent) {
ownerDocument.dispatchEvent(
new CustomEvent('litegraph:contextmenu', {
detail: { type: 'open', menu: this }
})
)
}
}
/**
@@ -402,6 +423,13 @@ export class ContextMenu<TValue = unknown> {
}
}
this.current_submenu?.close(e, true)
if (!this.parentMenu) {
this.ownerDocument.dispatchEvent(
new CustomEvent('litegraph:contextmenu', {
detail: { type: 'close', menu: this }
})
)
}
}
/** @deprecated Likely unused, however code search was inconclusive (too many results to check by hand). */

View File

@@ -7,6 +7,7 @@ import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useRaisedSurfaceStore } from '@/platform/keybindings/raisedSurfaceStore'
import { useCommandStore } from '@/stores/commandStore'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -108,6 +109,31 @@ describe('keybindingService - Escape key handling', () => {
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should NOT execute Escape keybinding when a raised surface is open', async () => {
const raisedSurfaceStore = useRaisedSurfaceStore()
raisedSurfaceStore.open('context-menu')
keybindingService = useKeybindingService()
const event = createKeyboardEvent('Escape')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should resume executing Escape keybinding after a raised surface closes', async () => {
const raisedSurfaceStore = useRaisedSurfaceStore()
const id = raisedSurfaceStore.open('context-menu')
raisedSurfaceStore.close(id)
keybindingService = useKeybindingService()
const event = createKeyboardEvent('Escape')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
})
it('should execute Escape keybinding with modifiers regardless of dialog state', async () => {
const dialogStore = useDialogStore()
dialogStore.dialogStack.push(createTestDialogInstance('test-dialog'))

View File

@@ -7,12 +7,14 @@ import { CORE_KEYBINDINGS } from './defaults'
import { KeyComboImpl } from './keyCombo'
import { KeybindingImpl } from './keybinding'
import { useKeybindingStore } from './keybindingStore'
import { useRaisedSurfaceStore } from './raisedSurfaceStore'
export function useKeybindingService() {
const keybindingStore = useKeybindingStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
const raisedSurfaceStore = useRaisedSurfaceStore()
async function keybindHandler(event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
@@ -50,7 +52,10 @@ export function useKeybindingService() {
!event.altKey &&
!event.metaKey
) {
if (dialogStore.dialogStack.length > 0) {
if (
raisedSurfaceStore.isAnyOpen ||
dialogStore.dialogStack.length > 0
) {
return
}
}

View File

@@ -0,0 +1,34 @@
import { onScopeDispose } from 'vue'
import { useRaisedSurfaceStore } from './raisedSurfaceStore'
interface LiteGraphContextMenuEventDetail {
type: 'open' | 'close'
menu: object
}
export function useLiteGraphContextMenuTracking(): void {
const store = useRaisedSurfaceStore()
const idsByMenu = new Map<object, symbol>()
const handler = (event: Event) => {
const detail = (event as CustomEvent<LiteGraphContextMenuEventDetail>)
.detail
if (detail.type === 'open') {
idsByMenu.set(detail.menu, store.open('context-menu'))
return
}
const id = idsByMenu.get(detail.menu)
if (id !== undefined) {
store.close(id)
idsByMenu.delete(detail.menu)
}
}
document.addEventListener('litegraph:contextmenu', handler)
onScopeDispose(() => {
document.removeEventListener('litegraph:contextmenu', handler)
for (const id of idsByMenu.values()) store.close(id)
idsByMenu.clear()
})
}

View File

@@ -0,0 +1,89 @@
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, ref } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import { useRaisedSurface, useRaisedSurfaceStore } from './raisedSurfaceStore'
describe('raisedSurfaceStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('is empty by default', () => {
const store = useRaisedSurfaceStore()
expect(store.isAnyOpen).toBe(false)
expect(store.stack).toHaveLength(0)
})
it('tracks open/close and reports isAnyOpen', () => {
const store = useRaisedSurfaceStore()
const id = store.open('context-menu')
expect(store.isAnyOpen).toBe(true)
expect(store.stack).toHaveLength(1)
store.close(id)
expect(store.isAnyOpen).toBe(false)
expect(store.stack).toHaveLength(0)
})
it('supports concurrent surfaces and closes by id (LIFO not required)', () => {
const store = useRaisedSurfaceStore()
const a = store.open('context-menu')
const b = store.open('popover')
expect(store.stack).toHaveLength(2)
store.close(a)
expect(store.stack).toHaveLength(1)
expect(store.stack[0].kind).toBe('popover')
store.close(b)
expect(store.isAnyOpen).toBe(false)
})
it('close is a no-op for unknown ids', () => {
const store = useRaisedSurfaceStore()
store.open('modal')
store.close(Symbol('stale'))
expect(store.stack).toHaveLength(1)
})
})
describe('useRaisedSurface', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('opens when reactive flag turns true and closes when it turns false', () => {
const store = useRaisedSurfaceStore()
const isOpen = ref(false)
const scope = effectScope()
scope.run(() => useRaisedSurface('context-menu', isOpen))
expect(store.isAnyOpen).toBe(false)
isOpen.value = true
expect(store.isAnyOpen).toBe(true)
isOpen.value = false
expect(store.isAnyOpen).toBe(false)
scope.stop()
})
it('does not double-register when flag is toggled true twice', () => {
const store = useRaisedSurfaceStore()
const isOpen = ref(false)
const scope = effectScope()
scope.run(() => useRaisedSurface('context-menu', isOpen))
isOpen.value = true
isOpen.value = true
expect(store.stack).toHaveLength(1)
scope.stop()
})
it('releases the surface when the owning scope is disposed', () => {
const store = useRaisedSurfaceStore()
const isOpen = ref(true)
const scope = effectScope()
scope.run(() => useRaisedSurface('context-menu', isOpen))
expect(store.isAnyOpen).toBe(true)
scope.stop()
expect(store.isAnyOpen).toBe(false)
})
})

View File

@@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
/**
* Tracks open "raised surfaces" — popovers, context menus, and top-level
* modals — that should suppress global keybindings while they are open.
*
* UX axiom: standard keybindings work in every context EXCEPT when a raised
* surface is open. Consumers register/unregister surfaces here; the keybinding
* service consults {@link isAnyOpen} as its single source of truth.
*/
type RaisedSurfaceKind = 'context-menu' | 'popover' | 'modal'
interface RaisedSurfaceEntry {
id: symbol
kind: RaisedSurfaceKind
}
export const useRaisedSurfaceStore = defineStore('raisedSurface', () => {
const stack = ref<RaisedSurfaceEntry[]>([])
const isAnyOpen = computed(() => stack.value.length > 0)
function open(kind: RaisedSurfaceKind): symbol {
const id = Symbol(kind)
stack.value.push({ id, kind })
return id
}
function close(id: symbol): void {
const index = stack.value.findIndex((entry) => entry.id === id)
if (index !== -1) stack.value.splice(index, 1)
}
return { stack, isAnyOpen, open, close }
})
/**
* Bind a surface's reactive open-state to the raised-surface registry.
*
* @example
* const isOpen = ref(false)
* useRaisedSurface('context-menu', isOpen)
*/
export function useRaisedSurface(
kind: RaisedSurfaceKind,
isOpen: MaybeRefOrGetter<boolean>
): void {
const store = useRaisedSurfaceStore()
let id: symbol | null = null
function release() {
if (id !== null) {
store.close(id)
id = null
}
}
watch(
() => toValue(isOpen),
(open) => {
if (open && id === null) {
id = store.open(kind)
} else if (!open) {
release()
}
},
{ immediate: true, flush: 'sync' }
)
onScopeDispose(release)
}

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"