[fix] Stop widget scroll events from being intercepted by LiteGraph

This commit is contained in:
Arjan Singh
2025-09-25 20:10:55 -07:00
parent ac93a6ba3f
commit 81087c111a
3 changed files with 193 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { usePreserveWidgetScroll } from '@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -24,6 +25,9 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
// Preserve native scrolling in Vue widgets
usePreserveWidgetScroll()
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}

View File

@@ -0,0 +1,72 @@
import { useEventListener } from '@vueuse/core'
const PRIMEVUE_SCROLLABLE_CLASSES = new Set([
'p-select-option',
'p-dropdown-item'
])
const PRIMEVUE_SCROLLABLE_CONTAINERS = [
'.p-select',
'.p-multiselect',
'.p-treeselect',
'.p-select-dropdown',
'.p-dropdown-panel'
].join(', ')
/**
* Check if an element should handle wheel events natively (scrolling)
* instead of letting them bubble to canvas zoom
*/
function isScrollableElement(target: Element): boolean {
// Check common scrollable elements
const tagName = target.tagName.toLowerCase()
if (tagName === 'textarea' || tagName === 'select' || tagName === 'input') {
return true
}
// Check PrimeVue select options and other dropdown elements
for (const className of target.classList) {
if (PRIMEVUE_SCROLLABLE_CLASSES.has(className)) {
return true
}
}
if (target.closest(PRIMEVUE_SCROLLABLE_CONTAINERS)) {
return true
}
// Check for elements with scrollable overflow
const computedStyle = window.getComputedStyle(target)
const overflowY = computedStyle.overflowY
if (overflowY === 'scroll' || overflowY === 'auto') {
return true
}
// Check if element has scrollable content
if (target.scrollHeight > target.clientHeight && overflowY !== 'hidden') {
return true
}
return false
}
/**
* App-level composable that preserves native scrolling behavior in widgets
* by preventing wheel events from bubbling to the canvas zoom handler.
* Call once at the app level to enable native scrolling in textareas, selects, etc.
*/
export function usePreserveWidgetScroll() {
useEventListener(
window,
'wheel',
(event: WheelEvent) => {
if (
event.target instanceof Element &&
isScrollableElement(event.target)
) {
event.stopPropagation()
}
},
{ capture: true, passive: false }
)
}

View File

@@ -0,0 +1,117 @@
/**
* @vitest-environment happy-dom
*/
import { useEventListener } from '@vueuse/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock VueUse
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn()
}))
describe('usePreserveWidgetScroll', () => {
let mockUseEventListener: ReturnType<typeof vi.fn>
beforeEach(() => {
mockUseEventListener = vi.mocked(useEventListener)
vi.resetModules()
})
afterEach(() => {
vi.clearAllMocks()
})
it('should set up window wheel event listener with capture and passive options', async () => {
const { usePreserveWidgetScroll } = await import(
'@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
)
usePreserveWidgetScroll()
expect(mockUseEventListener).toHaveBeenCalledWith(
window,
'wheel',
expect.any(Function),
{ capture: true, passive: false }
)
})
it('should call stopPropagation on textarea wheel events', async () => {
let wheelHandler: (event: WheelEvent) => void
mockUseEventListener.mockImplementation((_target, _event, handler) => {
wheelHandler = handler
})
const { usePreserveWidgetScroll } = await import(
'@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
)
usePreserveWidgetScroll()
// Create real DOM textarea element and dispatch wheel event
const textarea = document.createElement('textarea')
document.body.appendChild(textarea)
const wheelEvent = new WheelEvent('wheel', { bubbles: true })
const stopPropagation = vi.fn()
wheelEvent.stopPropagation = stopPropagation
Object.defineProperty(wheelEvent, 'target', { value: textarea })
wheelHandler!(wheelEvent)
expect(stopPropagation).toHaveBeenCalled()
})
it('should not call stopPropagation on non-scrollable elements', async () => {
let wheelHandler: (event: WheelEvent) => void
mockUseEventListener.mockImplementation((_target, _event, handler) => {
wheelHandler = handler
})
const { usePreserveWidgetScroll } = await import(
'@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
)
usePreserveWidgetScroll()
// Create regular div element
const div = document.createElement('div')
document.body.appendChild(div)
const wheelEvent = new WheelEvent('wheel', { bubbles: true })
const stopPropagation = vi.fn()
wheelEvent.stopPropagation = stopPropagation
Object.defineProperty(wheelEvent, 'target', { value: div })
wheelHandler!(wheelEvent)
expect(stopPropagation).not.toHaveBeenCalled()
})
it('should handle PrimeVue select dropdown elements', async () => {
let wheelHandler: (event: WheelEvent) => void
mockUseEventListener.mockImplementation((_target, _event, handler) => {
wheelHandler = handler
})
const { usePreserveWidgetScroll } = await import(
'@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
)
usePreserveWidgetScroll()
// Create element with PrimeVue select option class
const div = document.createElement('div')
div.classList.add('p-select-option')
document.body.appendChild(div)
const wheelEvent = new WheelEvent('wheel', { bubbles: true })
const stopPropagation = vi.fn()
wheelEvent.stopPropagation = stopPropagation
Object.defineProperty(wheelEvent, 'target', { value: div })
wheelHandler!(wheelEvent)
expect(stopPropagation).toHaveBeenCalled()
})
})