Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
59 changes: 59 additions & 0 deletions src/composables/graph/useCanvasInteractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { computed } from 'vue'

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 = computed(
() => 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.value && (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.value) {
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()
})
})
})
Loading