From 81087c111aa9dfe4162189ae7b82ceb7fbd4807d Mon Sep 17 00:00:00 2001 From: Arjan Singh Date: Thu, 25 Sep 2025 20:10:55 -0700 Subject: [PATCH] [fix] Stop widget scroll events from being intercepted by LiteGraph --- src/App.vue | 4 + .../composables/usePreserveWidgetScroll.ts | 72 +++++++++++ .../usePreserveWidgetScroll.test.ts | 117 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 src/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll.ts create mode 100644 tests-ui/tests/composables/usePreserveWidgetScroll.test.ts diff --git a/src/App.vue b/src/App.vue index 0a8ec51c3..330fc9663 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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(() => workspaceStore.spinner) + +// Preserve native scrolling in Vue widgets +usePreserveWidgetScroll() const handleKey = (e: KeyboardEvent) => { workspaceStore.shiftDown = e.shiftKey } diff --git a/src/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll.ts b/src/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll.ts new file mode 100644 index 000000000..a1acb812e --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll.ts @@ -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 } + ) +} diff --git a/tests-ui/tests/composables/usePreserveWidgetScroll.test.ts b/tests-ui/tests/composables/usePreserveWidgetScroll.test.ts new file mode 100644 index 000000000..ae4e08018 --- /dev/null +++ b/tests-ui/tests/composables/usePreserveWidgetScroll.test.ts @@ -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 + + 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() + }) +})