diff --git a/src/components/load3d/Load3DScene.vue b/src/components/load3d/Load3DScene.vue index de2afbdb2..7f9e4a8d4 100644 --- a/src/components/load3d/Load3DScene.vue +++ b/src/components/load3d/Load3DScene.vue @@ -3,7 +3,8 @@ ref="container" class="relative h-full w-full min-h-[200px]" data-capture-wheel="true" - @pointerdown.stop + tabindex="-1" + @pointerdown.stop="focusContainer" @pointermove.stop @pointerup.stop @mousedown.stop @@ -45,6 +46,10 @@ const props = defineProps<{ const container = ref(null) +function focusContainer() { + container.value?.focus() +} + const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } = useLoad3dDrag({ onModelDrop: async (file) => { diff --git a/src/renderer/core/canvas/useCanvasInteractions.test.ts b/src/renderer/core/canvas/useCanvasInteractions.test.ts index b1287a2e7..cce86a037 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.test.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.test.ts @@ -155,5 +155,72 @@ describe('useCanvasInteractions', () => { expect(mockEvent.preventDefault).not.toHaveBeenCalled() expect(mockEvent.stopPropagation).not.toHaveBeenCalled() }) + it('should forward wheel events to canvas when capture element is NOT focused', () => { + const { get } = useSettingStore() + vi.mocked(get).mockReturnValue('legacy') + + const captureElement = document.createElement('div') + captureElement.setAttribute('data-capture-wheel', 'true') + const textarea = document.createElement('textarea') + captureElement.appendChild(textarea) + document.body.appendChild(captureElement) + + const { handleWheel } = useCanvasInteractions() + const mockEvent = createMockWheelEvent() + Object.defineProperty(mockEvent, 'target', { value: textarea }) + + handleWheel(mockEvent) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + expect(mockEvent.stopPropagation).toHaveBeenCalled() + + document.body.removeChild(captureElement) + }) + + it('should NOT forward wheel events when capture element IS focused', () => { + const { get } = useSettingStore() + vi.mocked(get).mockReturnValue('legacy') + + 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() + Object.defineProperty(mockEvent, 'target', { value: textarea }) + + handleWheel(mockEvent) + + expect(mockEvent.preventDefault).not.toHaveBeenCalled() + expect(mockEvent.stopPropagation).not.toHaveBeenCalled() + + document.body.removeChild(captureElement) + }) + + it('should forward ctrl+wheel to canvas when capture element IS focused in standard mode', () => { + 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(true) + Object.defineProperty(mockEvent, 'target', { value: textarea }) + + handleWheel(mockEvent) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + expect(mockEvent.stopPropagation).toHaveBeenCalled() + + document.body.removeChild(captureElement) + }) }) }) diff --git a/src/renderer/core/canvas/useCanvasInteractions.ts b/src/renderer/core/canvas/useCanvasInteractions.ts index 5d9804e13..b93d4ff15 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.ts @@ -26,19 +26,31 @@ export function useCanvasInteractions() { () => !(canvasStore.canvas?.read_only ?? false) ) + /** + * Returns true if the wheel event target is inside an element that should + * capture wheel events AND that element (or a descendant) currently has focus. + * + * This allows two-finger panning to continue over inputs until the user has + * actively focused the widget, at which point the widget can consume scroll. + */ + const wheelCapturedByFocusedElement = (event: WheelEvent): boolean => { + const target = event.target as HTMLElement | null + const captureElement = target?.closest('[data-capture-wheel="true"]') + const active = document.activeElement as Element | null + + return !!(captureElement && active && captureElement.contains(active)) + } + + const shouldForwardWheelEvent = (event: WheelEvent): boolean => + !wheelCapturedByFocusedElement(event) || + (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) + /** * Handles wheel events from UI components that should be forwarded to canvas * when appropriate (e.g., Ctrl+wheel for zoom in standard mode) */ const handleWheel = (event: WheelEvent) => { - // Check if the wheel event is from an element that wants to capture wheel events - const target = event.target as HTMLElement - const captureElement = target?.closest('[data-capture-wheel="true"]') - - if (captureElement) { - // Element wants to capture wheel events, don't forward to canvas - return - } + if (!shouldForwardWheelEvent(event)) return // In standard mode, Ctrl+wheel should go to canvas for zoom if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) { @@ -87,14 +99,8 @@ export function useCanvasInteractions() { const forwardEventToCanvas = ( event: WheelEvent | PointerEvent | MouseEvent ) => { - // Check if the wheel event is from an element that wants to capture wheel events - const target = event.target as HTMLElement - const captureElement = target?.closest('[data-capture-wheel="true"]') - - if (captureElement) { - // Element wants to capture wheel events, don't forward to canvas - return - } + // Honor wheel capture only when the element is focused + if (event instanceof WheelEvent && !shouldForwardWheelEvent(event)) return const canvasEl = app.canvas?.canvas if (!canvasEl) return