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

import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'

/**
Expand All @@ -10,6 +11,7 @@ import { useSettingStore } from '@/stores/settingStore'
*/
export function useCanvasInteractions() {
const settingStore = useSettingStore()
const { getCanvas } = useCanvasStore()

const isStandardNavMode = computed(
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
Expand Down Expand Up @@ -37,6 +39,27 @@ export function useCanvasInteractions() {
// Otherwise, let the component handle it normally
}

/**
* Handles pointer events from media elements that should potentially
* be forwarded to canvas (e.g., space+drag for panning)
*/
const handlePointer = (event: PointerEvent) => {
// Check if canvas exists using established pattern
const canvas = getCanvas()
if (!canvas) return

// Check conditions for forwarding events to canvas
const isSpacePanningDrag = canvas.read_only && event.buttons === 1 // Space key pressed + left mouse drag
const isMiddleMousePanning = event.buttons === 4 // Middle mouse button for panning

if (isSpacePanningDrag || isMiddleMousePanning) {
event.preventDefault()
event.stopPropagation()
forwardEventToCanvas(event)
return
}
}

/**
* Forwards an event to the LiteGraph canvas
*/
Expand All @@ -54,6 +77,7 @@ export function useCanvasInteractions() {

return {
handleWheel,
handlePointer,
forwardEventToCanvas
}
}
9 changes: 9 additions & 0 deletions src/composables/node/useNodeImage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
Expand Down Expand Up @@ -130,6 +131,8 @@ export const useNodeVideo = (node: LGraphNode) => {
let minHeight = DEFAULT_VIDEO_SIZE
let minWidth = DEFAULT_VIDEO_SIZE

const { handleWheel, handlePointer } = useCanvasInteractions()

const setMinDimensions = (video: HTMLVideoElement) => {
const { minHeight: calculatedHeight, minWidth: calculatedWidth } =
fitDimensionsToNodeWidth(
Expand All @@ -146,6 +149,12 @@ export const useNodeVideo = (node: LGraphNode) => {
new Promise((resolve) => {
const video = document.createElement('video')
Object.assign(video, VIDEO_DEFAULT_OPTIONS)

// Add event listeners for canvas interactions
video.addEventListener('wheel', handleWheel)
video.addEventListener('pointermove', handlePointer)
video.addEventListener('pointerdown', handlePointer)

video.onloadeddata = () => {
setMinDimensions(video)
resolve(video)
Expand Down
215 changes: 137 additions & 78 deletions tests-ui/tests/composables/graph/useCanvasInteractions.test.ts
Original file line number Diff line number Diff line change
@@ -1,126 +1,185 @@
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'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'

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

// 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>
const mockGetCanvas = vi.fn()
const mockGet = vi.fn()

beforeEach(() => {
// Clear mocks
vi.clearAllMocks()
vi.mocked(useCanvasStore, { partial: true }).mockReturnValue({
getCanvas: mockGetCanvas
})
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
get: mockGet
})
})

// Create mock canvas element
mockCanvas = document.createElement('canvas')
mockCanvas.dispatchEvent = vi.fn()
app.canvas!.canvas = mockCanvas
describe('handlePointer', () => {
it('should forward space+drag events to canvas when read_only is true', () => {
// Setup
const mockCanvas = { read_only: true }
mockGetCanvas.mockReturnValue(mockCanvas)

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

canvasInteractions = useCanvasInteractions()
})
// Create mock pointer event
const mockEvent = {
buttons: 1, // Left mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>

describe('handleWheel', () => {
it('should check navigation mode from settings', () => {
mockSettingStore.get.mockReturnValue('standard')
// Test
handlePointer(mockEvent as unknown as PointerEvent)

const wheelEvent = new WheelEvent('wheel', {
ctrlKey: true,
deltaY: -100
})
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})

it('should forward middle mouse button events to canvas', () => {
// Setup
const mockCanvas = { read_only: false }
mockGetCanvas.mockReturnValue(mockCanvas)

const { handlePointer } = useCanvasInteractions()

// Create mock pointer event with middle button
const mockEvent = {
buttons: 4, // Middle mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>

canvasInteractions.handleWheel(wheelEvent)
// Test
handlePointer(mockEvent as unknown as PointerEvent)

expect(mockSettingStore.get).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode'
)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})

it('should not forward regular wheel events in standard mode', () => {
mockSettingStore.get.mockReturnValue('standard')
it('should not prevent default when canvas is not in read_only mode and not middle button', () => {
// Setup
const mockCanvas = { read_only: false }
mockGetCanvas.mockReturnValue(mockCanvas)

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

canvasInteractions.handleWheel(wheelEvent)
// Create mock pointer event
const mockEvent = {
buttons: 1, // Left mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>

expect(mockCanvas.dispatchEvent).not.toHaveBeenCalled()
// Test
handlePointer(mockEvent as unknown as PointerEvent)

// Verify - should not prevent default (let media handle normally)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})

it('should forward all wheel events to canvas in legacy mode', () => {
mockSettingStore.get.mockReturnValue('legacy')
it('should return early when canvas is null', () => {
// Setup
mockGetCanvas.mockReturnValue(null)

const { handlePointer } = useCanvasInteractions()

const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
cancelable: true
})
// Create mock pointer event that would normally trigger forwarding
const mockEvent = {
buttons: 1, // Left mouse button - would trigger space+drag if canvas had read_only=true
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>

canvasInteractions.handleWheel(wheelEvent)
// Test
handlePointer(mockEvent as unknown as PointerEvent)

expect(mockCanvas.dispatchEvent).toHaveBeenCalled()
// Verify early return - no event methods should be called at all
expect(mockGetCanvas).toHaveBeenCalled()
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
})

it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
mockSettingStore.get.mockReturnValue('standard')
describe('handleWheel', () => {
it('should forward ctrl+wheel events to canvas in standard nav mode', () => {
// Setup
mockGet.mockReturnValue('standard')

const { handleWheel } = useCanvasInteractions()

const wheelEvent = new WheelEvent('wheel', {
// Create mock wheel event with ctrl key
const mockEvent = {
ctrlKey: true,
deltaY: -100
})
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>

// Test
handleWheel(mockEvent as unknown as WheelEvent)

expect(() => {
canvasInteractions.handleWheel(wheelEvent)
}).not.toThrow()
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
})
})

describe('forwardEventToCanvas', () => {
it('should dispatch event to canvas element', () => {
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
ctrlKey: true
})
it('should forward all wheel events to canvas in legacy nav mode', () => {
// Setup
mockGet.mockReturnValue('legacy')

const { handleWheel } = useCanvasInteractions()

// Create mock wheel event without modifiers
const mockEvent = {
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>

canvasInteractions.forwardEventToCanvas(wheelEvent)
// Test
handleWheel(mockEvent as unknown as WheelEvent)

expect(mockCanvas.dispatchEvent).toHaveBeenCalledWith(
expect.any(WheelEvent)
)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
})

it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
it('should not prevent default for regular wheel events in standard nav mode', () => {
// Setup
mockGet.mockReturnValue('standard')

const { handleWheel } = useCanvasInteractions()

// Create mock wheel event without modifiers
const mockEvent = {
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>

const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})
// Test
handleWheel(mockEvent as unknown as WheelEvent)

expect(() => {
canvasInteractions.forwardEventToCanvas(wheelEvent)
}).not.toThrow()
// Verify - should not prevent default (let component handle normally)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
})
})
})