Skip to content

Commit 00cd9fa

Browse files
[feat] Prevent browser zoom on UI components with canvas wheel event forwarding (#4574)
1 parent 98d694f commit 00cd9fa

File tree

4 files changed

+191
-0
lines changed

4 files changed

+191
-0
lines changed

src/components/graph/GraphCanvasMenu.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<template>
22
<ButtonGroup
33
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
4+
@wheel="canvasInteractions.handleWheel"
45
>
56
<Button
67
v-tooltip.left="t('graphCanvasMenu.zoomIn')"
@@ -75,6 +76,7 @@ import ButtonGroup from 'primevue/buttongroup'
7576
import { computed } from 'vue'
7677
import { useI18n } from 'vue-i18n'
7778
79+
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
7880
import { useCommandStore } from '@/stores/commandStore'
7981
import { useCanvasStore } from '@/stores/graphStore'
8082
import { useSettingStore } from '@/stores/settingStore'
@@ -83,6 +85,7 @@ const { t } = useI18n()
8385
const commandStore = useCommandStore()
8486
const canvasStore = useCanvasStore()
8587
const settingStore = useSettingStore()
88+
const canvasInteractions = useCanvasInteractions()
8689
8790
const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
8891
const linkHidden = computed(

src/components/graph/SelectionToolbox.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
header: 'hidden',
66
content: 'p-0 flex flex-row'
77
}"
8+
@wheel="canvasInteractions.handleWheel"
89
>
910
<ExecuteButton />
1011
<ColorPickerButton />
@@ -39,13 +40,15 @@ import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
3940
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
4041
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
4142
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
43+
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
4244
import { useExtensionService } from '@/services/extensionService'
4345
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
4446
import { useCanvasStore } from '@/stores/graphStore'
4547
4648
const commandStore = useCommandStore()
4749
const canvasStore = useCanvasStore()
4850
const extensionService = useExtensionService()
51+
const canvasInteractions = useCanvasInteractions()
4952
5053
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
5154
const commandIds = new Set<string>(
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { computed } from 'vue'
2+
3+
import { app } from '@/scripts/app'
4+
import { useSettingStore } from '@/stores/settingStore'
5+
6+
/**
7+
* Composable for handling canvas interactions from Vue components.
8+
* This provides a unified way to forward events to the LiteGraph canvas
9+
* and will be the foundation for migrating canvas interactions to Vue.
10+
*/
11+
export function useCanvasInteractions() {
12+
const settingStore = useSettingStore()
13+
14+
const isStandardNavMode = computed(
15+
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
16+
)
17+
18+
/**
19+
* Handles wheel events from UI components that should be forwarded to canvas
20+
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
21+
*/
22+
const handleWheel = (event: WheelEvent) => {
23+
// In standard mode, Ctrl+wheel should go to canvas for zoom
24+
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
25+
event.preventDefault() // Prevent browser zoom
26+
forwardEventToCanvas(event)
27+
return
28+
}
29+
30+
// In legacy mode, all wheel events go to canvas for zoom
31+
if (!isStandardNavMode.value) {
32+
event.preventDefault()
33+
forwardEventToCanvas(event)
34+
return
35+
}
36+
37+
// Otherwise, let the component handle it normally
38+
}
39+
40+
/**
41+
* Forwards an event to the LiteGraph canvas
42+
*/
43+
const forwardEventToCanvas = (
44+
event: WheelEvent | PointerEvent | MouseEvent
45+
) => {
46+
const canvasEl = app.canvas?.canvas
47+
if (!canvasEl) return
48+
49+
// Create new event with same properties
50+
const EventConstructor = event.constructor as typeof WheelEvent
51+
const newEvent = new EventConstructor(event.type, event)
52+
canvasEl.dispatchEvent(newEvent)
53+
}
54+
55+
return {
56+
handleWheel,
57+
forwardEventToCanvas
58+
}
59+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
4+
import { app } from '@/scripts/app'
5+
import * as settingStore from '@/stores/settingStore'
6+
7+
// Mock the app and canvas
8+
vi.mock('@/scripts/app', () => ({
9+
app: {
10+
canvas: {
11+
canvas: null as HTMLCanvasElement | null
12+
}
13+
}
14+
}))
15+
16+
// Mock the setting store
17+
vi.mock('@/stores/settingStore', () => ({
18+
useSettingStore: vi.fn()
19+
}))
20+
21+
describe('useCanvasInteractions', () => {
22+
let mockCanvas: HTMLCanvasElement
23+
let mockSettingStore: { get: ReturnType<typeof vi.fn> }
24+
let canvasInteractions: ReturnType<typeof useCanvasInteractions>
25+
26+
beforeEach(() => {
27+
// Clear mocks
28+
vi.clearAllMocks()
29+
30+
// Create mock canvas element
31+
mockCanvas = document.createElement('canvas')
32+
mockCanvas.dispatchEvent = vi.fn()
33+
app.canvas!.canvas = mockCanvas
34+
35+
// Mock setting store
36+
mockSettingStore = { get: vi.fn() }
37+
vi.mocked(settingStore.useSettingStore).mockReturnValue(
38+
mockSettingStore as any
39+
)
40+
41+
canvasInteractions = useCanvasInteractions()
42+
})
43+
44+
describe('handleWheel', () => {
45+
it('should check navigation mode from settings', () => {
46+
mockSettingStore.get.mockReturnValue('standard')
47+
48+
const wheelEvent = new WheelEvent('wheel', {
49+
ctrlKey: true,
50+
deltaY: -100
51+
})
52+
53+
canvasInteractions.handleWheel(wheelEvent)
54+
55+
expect(mockSettingStore.get).toHaveBeenCalledWith(
56+
'Comfy.Canvas.NavigationMode'
57+
)
58+
})
59+
60+
it('should not forward regular wheel events in standard mode', () => {
61+
mockSettingStore.get.mockReturnValue('standard')
62+
63+
const wheelEvent = new WheelEvent('wheel', {
64+
deltaY: -100
65+
})
66+
67+
canvasInteractions.handleWheel(wheelEvent)
68+
69+
expect(mockCanvas.dispatchEvent).not.toHaveBeenCalled()
70+
})
71+
72+
it('should forward all wheel events to canvas in legacy mode', () => {
73+
mockSettingStore.get.mockReturnValue('legacy')
74+
75+
const wheelEvent = new WheelEvent('wheel', {
76+
deltaY: -100,
77+
cancelable: true
78+
})
79+
80+
canvasInteractions.handleWheel(wheelEvent)
81+
82+
expect(mockCanvas.dispatchEvent).toHaveBeenCalled()
83+
})
84+
85+
it('should handle missing canvas gracefully', () => {
86+
;(app.canvas as any).canvas = null
87+
mockSettingStore.get.mockReturnValue('standard')
88+
89+
const wheelEvent = new WheelEvent('wheel', {
90+
ctrlKey: true,
91+
deltaY: -100
92+
})
93+
94+
expect(() => {
95+
canvasInteractions.handleWheel(wheelEvent)
96+
}).not.toThrow()
97+
})
98+
})
99+
100+
describe('forwardEventToCanvas', () => {
101+
it('should dispatch event to canvas element', () => {
102+
const wheelEvent = new WheelEvent('wheel', {
103+
deltaY: -100,
104+
ctrlKey: true
105+
})
106+
107+
canvasInteractions.forwardEventToCanvas(wheelEvent)
108+
109+
expect(mockCanvas.dispatchEvent).toHaveBeenCalledWith(
110+
expect.any(WheelEvent)
111+
)
112+
})
113+
114+
it('should handle missing canvas gracefully', () => {
115+
;(app.canvas as any).canvas = null
116+
117+
const wheelEvent = new WheelEvent('wheel', {
118+
deltaY: -100
119+
})
120+
121+
expect(() => {
122+
canvasInteractions.forwardEventToCanvas(wheelEvent)
123+
}).not.toThrow()
124+
})
125+
})
126+
})

0 commit comments

Comments
 (0)