Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/graph/GraphCanvasMenu.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<ButtonGroup
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
@wheel="canvasInteractions.handleWheel"
>
<Button
v-tooltip.left="t('graphCanvasMenu.zoomIn')"
Expand Down Expand Up @@ -75,6 +76,7 @@ import ButtonGroup from 'primevue/buttongroup'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
Expand All @@ -83,6 +85,7 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const canvasInteractions = useCanvasInteractions()

const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const linkHidden = computed(
Expand Down
3 changes: 3 additions & 0 deletions src/components/graph/SelectionToolbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
header: 'hidden',
content: 'p-0 flex flex-row'
}"
@wheel="canvasInteractions.handleWheel"
>
<ExecuteButton />
<ColorPickerButton />
Expand Down Expand Up @@ -39,13 +40,15 @@ import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'

const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()

const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
Expand Down
56 changes: 56 additions & 0 deletions src/composables/graph/useCanvasInteractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'

/**
* Composable for handling canvas interactions from Vue components.
* This provides a unified way to forward events to the LiteGraph canvas
* and will be the foundation for migrating canvas interactions to Vue.
*/
export function useCanvasInteractions() {
const settingStore = useSettingStore()

const isStandardNavMode = () =>
settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'

/**
* 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) => {
// In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode() && (event.ctrlKey || event.metaKey)) {
event.preventDefault() // Prevent browser zoom
forwardEventToCanvas(event)
return
}

// In legacy mode, all wheel events go to canvas for zoom
if (!isStandardNavMode()) {
event.preventDefault()
forwardEventToCanvas(event)
return
}

// Otherwise, let the component handle it normally
}

/**
* Forwards an event to the LiteGraph canvas
*/
const forwardEventToCanvas = (
event: WheelEvent | PointerEvent | MouseEvent
) => {
const canvasEl = app.canvas?.canvas
if (!canvasEl) return

// Create new event with same properties
const EventConstructor = event.constructor as typeof WheelEvent
const newEvent = new EventConstructor(event.type, event)
canvasEl.dispatchEvent(newEvent)
}

return {
handleWheel,
forwardEventToCanvas
}
}
126 changes: 126 additions & 0 deletions tests-ui/tests/composables/graph/useCanvasInteractions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { app } from '@/scripts/app'
import * as settingStore from '@/stores/settingStore'

// Mock the app and canvas
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
canvas: null as HTMLCanvasElement | null
}
}
}))

// Mock the setting store
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn()
}))

describe('useCanvasInteractions', () => {
let mockCanvas: HTMLCanvasElement
let mockSettingStore: { get: ReturnType<typeof vi.fn> }
let canvasInteractions: ReturnType<typeof useCanvasInteractions>

beforeEach(() => {
// Clear mocks
vi.clearAllMocks()

// Create mock canvas element
mockCanvas = document.createElement('canvas')
mockCanvas.dispatchEvent = vi.fn()
app.canvas!.canvas = mockCanvas

// Mock setting store
mockSettingStore = { get: vi.fn() }
vi.mocked(settingStore.useSettingStore).mockReturnValue(
mockSettingStore as any
)

canvasInteractions = useCanvasInteractions()
})

describe('handleWheel', () => {
it('should check navigation mode from settings', () => {
mockSettingStore.get.mockReturnValue('standard')

const wheelEvent = new WheelEvent('wheel', {
ctrlKey: true,
deltaY: -100
})

canvasInteractions.handleWheel(wheelEvent)

expect(mockSettingStore.get).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode'
)
})

it('should not forward regular wheel events in standard mode', () => {
mockSettingStore.get.mockReturnValue('standard')

const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})

canvasInteractions.handleWheel(wheelEvent)

expect(mockCanvas.dispatchEvent).not.toHaveBeenCalled()
})

it('should forward all wheel events to canvas in legacy mode', () => {
mockSettingStore.get.mockReturnValue('legacy')

const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
cancelable: true
})

canvasInteractions.handleWheel(wheelEvent)

expect(mockCanvas.dispatchEvent).toHaveBeenCalled()
})

it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
mockSettingStore.get.mockReturnValue('standard')

const wheelEvent = new WheelEvent('wheel', {
ctrlKey: true,
deltaY: -100
})

expect(() => {
canvasInteractions.handleWheel(wheelEvent)
}).not.toThrow()
})
})

describe('forwardEventToCanvas', () => {
it('should dispatch event to canvas element', () => {
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
ctrlKey: true
})

canvasInteractions.forwardEventToCanvas(wheelEvent)

expect(mockCanvas.dispatchEvent).toHaveBeenCalledWith(
expect.any(WheelEvent)
)
})

it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null

const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})

expect(() => {
canvasInteractions.forwardEventToCanvas(wheelEvent)
}).not.toThrow()
})
})
})