Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
e26b4da6c5 fix: Implement Vue-based spinner for image upload in Nodes 2.0 mode (#9227)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:41:38 +01:00
14 changed files with 198 additions and 206 deletions

View File

@@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
mockFetchApi: vi.fn(),
mockAddAlert: vi.fn(),
mockUpdateInputs: vi.fn()
}))
const { mockFetchApi, mockAddAlert, mockUpdateInputs, mockSetNodeUploading } =
vi.hoisted(() => ({
mockFetchApi: vi.fn(),
mockAddAlert: vi.fn(),
mockUpdateInputs: vi.fn(),
mockSetNodeUploading: vi.fn()
}))
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
@@ -43,6 +45,10 @@ vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({ updateInputs: mockUpdateInputs })
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ setNodeUploading: mockSetNodeUploading })
}))
function createMockNode(): LGraphNode {
return {
isUploading: false,

View File

@@ -7,6 +7,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
const PASTED_IMAGE_EXPIRY_MS = 2000
@@ -92,12 +93,15 @@ export const useNodeImageUpload = (
}
}
const nodeOutputStore = useNodeOutputStore()
const handleUploadBatch = async (files: File[]) => {
if (node.isUploading) {
useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
return []
}
node.isUploading = true
nodeOutputStore.setNodeUploading(node.id, true)
try {
node.imgs = undefined
@@ -114,6 +118,7 @@ export const useNodeImageUpload = (
return validPaths
} finally {
node.isUploading = false
nodeOutputStore.setNodeUploading(node.id, false)
node.graph?.setDirtyCanvas(true)
}
}

View File

@@ -905,14 +905,6 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.pasteFromClipboard()
}
},
{
id: 'Comfy.Canvas.PasteFromClipboardWithConnect',
icon: 'icon-[lucide--clipboard-paste]',
label: () => t('Paste with Connect'),
function: () => {
app.canvas.pasteFromClipboard({ connectInputs: true })
}
},
{
id: 'Comfy.Canvas.SelectAll',
icon: 'icon-[lucide--lasso-select]',
@@ -927,12 +919,6 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Delete Selected Items',
versionAdded: '1.10.5',
function: () => {
if (app.canvas.selectedItems.size === 0) {
app.canvas.canvas.dispatchEvent(
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
)
return
}
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}

View File

@@ -12,7 +12,6 @@ interface UseCurveEditorOptions {
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
const dragIndex = ref(-1)
let cleanupDrag: (() => void) | null = null
let cachedInverseCTM: DOMMatrix | null = null
const curvePath = computed(() => {
const points = modelValue.value
@@ -32,14 +31,16 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
return parts.join('')
})
function svgCoords(
e: PointerEvent,
inverseCTM?: DOMMatrix | null
): [number, number] {
const inv = inverseCTM ?? svgRef.value?.getScreenCTM()?.inverse()
if (!inv) return [0, 0]
function svgCoords(e: PointerEvent): [number, number] {
const svg = svgRef.value
if (!svg) return [0, 0]
const svgPt = new DOMPoint(e.clientX, e.clientY).matrixTransform(inv)
const ctm = svg.getScreenCTM()
if (!ctm) return [0, 0]
const svgPt = new DOMPoint(e.clientX, e.clientY).matrixTransform(
ctm.inverse()
)
return [
Math.max(0, Math.min(1, svgPt.x)),
Math.max(0, Math.min(1, 1 - svgPt.y))
@@ -99,12 +100,11 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
const svg = svgRef.value
if (!svg) return
cachedInverseCTM = svg.getScreenCTM()?.inverse() ?? null
svg.setPointerCapture(e.pointerId)
const onMove = (ev: PointerEvent) => {
if (dragIndex.value < 0) return
const [x, y] = svgCoords(ev, cachedInverseCTM)
const [x, y] = svgCoords(ev)
const movedPoint: CurvePoint = [x, y]
const newPoints = [...modelValue.value]
newPoints[dragIndex.value] = movedPoint
@@ -116,7 +116,6 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
const endDrag = () => {
if (dragIndex.value < 0) return
dragIndex.value = -1
cachedInverseCTM = null
svg.removeEventListener('pointermove', onMove)
svg.removeEventListener('pointerup', endDrag)
svg.removeEventListener('lostpointercapture', endDrag)

View File

@@ -3791,6 +3791,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return
}
private _noItemsSelected(): void {
const event = new CustomEvent('litegraph:no-items-selected', {
bubbles: true
})
this.canvas.dispatchEvent(event)
}
/**
* process a key event
*/
@@ -3835,6 +3842,31 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.node_panel?.close()
this.options_panel?.close()
if (this.node_panel || this.options_panel) block_default = true
} else if (e.keyCode === 65 && e.ctrlKey) {
// select all Control A
this.selectItems()
block_default = true
} else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
// copy
if (this.selected_nodes) {
this.copyToClipboard()
block_default = true
}
} else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) {
// paste
this.pasteFromClipboard({ connectInputs: e.shiftKey })
} else if (e.key === 'Delete' || e.key === 'Backspace') {
// delete or backspace
// @ts-expect-error EventTarget.localName is not in standard types
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
if (this.selectedItems.size === 0) {
this._noItemsSelected()
return
}
this.deleteSelected()
block_default = true
}
}
// TODO

View File

@@ -176,6 +176,7 @@
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"uploadAlreadyInProgress": "Upload already in progress",
"uploading": "Uploading",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} node | {count} nodes",
@@ -1262,7 +1263,6 @@
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Paste": "Paste",
"Paste with Connect": "Paste with Connect",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Select All": "Select All",

View File

@@ -208,52 +208,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
},
{
combo: {
ctrl: true,
key: 'a'
},
commandId: 'Comfy.Canvas.SelectAll',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'c'
},
commandId: 'Comfy.Canvas.CopySelected',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboard',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
shift: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboardWithConnect',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Delete'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Backspace'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
}
]

View File

@@ -1,11 +1,22 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
vi.mock('@/scripts/app', () => {
return {
app: {
canvas: {
processKey: vi.fn()
}
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
@@ -25,15 +36,13 @@ function createTestKeyboardEvent(
ctrlKey?: boolean
altKey?: boolean
metaKey?: boolean
shiftKey?: boolean
} = {}
): KeyboardEvent {
const {
target = document.body,
ctrlKey = false,
altKey = false,
metaKey = false,
shiftKey = false
metaKey = false
} = options
const event = new KeyboardEvent('keydown', {
@@ -41,7 +50,6 @@ function createTestKeyboardEvent(
ctrlKey,
altKey,
metaKey,
shiftKey,
bubbles: true,
cancelable: true
})
@@ -52,10 +60,8 @@ function createTestKeyboardEvent(
return event
}
describe('keybindingService - Canvas Keybindings', () => {
describe('keybindingService - Event Forwarding', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let canvasContainer: HTMLDivElement
let canvasChild: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
@@ -70,156 +76,94 @@ describe('keybindingService - Canvas Keybindings', () => {
typeof useDialogStore
>)
canvasContainer = document.createElement('div')
canvasContainer.id = 'graph-canvas-container'
canvasChild = document.createElement('canvas')
canvasContainer.appendChild(canvasChild)
document.body.appendChild(canvasContainer)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
afterEach(() => {
canvasContainer.remove()
})
it('should execute DeleteSelectedItems for Delete key on canvas', async () => {
const event = createTestKeyboardEvent('Delete', {
target: canvasChild
})
it('should forward Delete key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.DeleteSelectedItems'
)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute DeleteSelectedItems for Backspace key on canvas', async () => {
const event = createTestKeyboardEvent('Backspace', {
target: canvasChild
})
it('should forward Backspace key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Backspace')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.DeleteSelectedItems'
)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not execute DeleteSelectedItems when typing in input field', async () => {
it('should not forward Delete key when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = createTestKeyboardEvent('Delete', { target: inputElement })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not execute DeleteSelectedItems when typing in textarea', async () => {
it('should not forward Delete key when typing in textarea', async () => {
const textareaElement = document.createElement('textarea')
const event = createTestKeyboardEvent('Delete', {
target: textareaElement
})
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute SelectAll for Ctrl+A on canvas', async () => {
const event = createTestKeyboardEvent('a', {
ctrlKey: true,
target: canvasChild
})
it('should not forward Delete key when canvas processKey is not available', async () => {
// Temporarily replace processKey with undefined - testing edge case
const originalProcessKey = vi.mocked(app.canvas).processKey
vi.mocked(app.canvas).processKey = undefined!
await keybindingService.keybindHandler(event)
const event = createTestKeyboardEvent('Delete')
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.SelectAll'
)
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
}
})
it('should execute CopySelected for Ctrl+C on canvas', async () => {
const event = createTestKeyboardEvent('c', {
ctrlKey: true,
target: canvasChild
})
it('should not forward Delete key when canvas is not available', async () => {
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null!
await keybindingService.keybindHandler(event)
const event = createTestKeyboardEvent('Delete')
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.CopySelected'
)
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
}
})
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboard'
)
})
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
shiftKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboardWithConnect'
)
})
it('should execute graph-canvas bindings by normalizing to graph-canvas-container', async () => {
const event = createTestKeyboardEvent('=', {
altKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.ZoomIn'
)
})
it('should not execute graph-canvas bindings when target is outside canvas', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('=', {
altKey: true,
target: outsideDiv
})
it('should not forward non-canvas keys', async () => {
const event = createTestKeyboardEvent('Enter')
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
it('should not execute canvas commands when target is outside canvas container', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('Delete', {
target: outsideDiv
})
it('should not forward when modifier keys are pressed', async () => {
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
})

View File

@@ -1,5 +1,6 @@
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -14,6 +15,16 @@ export function useKeybindingService() {
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
if (event.ctrlKey || event.altKey || event.metaKey) {
return false
}
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
async function keybindHandler(event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
if (keyCombo.isModifier) {
@@ -33,17 +44,7 @@ export function useKeybindingService() {
}
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding) {
const targetElementId =
keybinding.targetElementId === 'graph-canvas'
? 'graph-canvas-container'
: keybinding.targetElementId
if (targetElementId) {
const container = document.getElementById(targetElementId)
if (!container?.contains(target)) {
return
}
}
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
if (
event.key === 'Escape' &&
!event.ctrlKey &&
@@ -73,6 +74,18 @@ export function useKeybindingService() {
return
}
if (!keybinding && shouldForwardToCanvas(event)) {
const canvas = app.canvas
if (
canvas &&
canvas.processKey &&
typeof canvas.processKey === 'function'
) {
canvas.processKey(event)
return
}
}
if (event.ctrlKey || event.altKey || event.metaKey) {
return
}

View File

@@ -37,7 +37,7 @@
</div>
<!-- Main Image -->
<img
v-if="!imageError"
v-if="!imageError && !isUploading"
:src="currentImageUrl"
:alt="imageAltText"
class="pointer-events-none absolute inset-0 block size-full object-contain"
@@ -45,6 +45,16 @@
@error="handleImageError"
/>
<!-- Upload Spinner Overlay -->
<div
v-if="isUploading"
class="absolute inset-0 flex items-center justify-center"
>
<i
class="icon-[lucide--loader-circle] size-8 animate-spin text-base-foreground"
/>
</div>
<!-- Floating Action Buttons (appear on hover and focus) -->
<div
v-if="isHovered || isFocused"
@@ -85,7 +95,10 @@
<!-- Image Dimensions -->
<div class="pt-2 text-center text-xs text-base-foreground">
<span v-if="imageError" class="text-red-400">
<span v-if="isUploading" class="text-base-foreground">
{{ $t('g.uploading') }}...
</span>
<span v-else-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="showLoader" class="text-base-foreground">
@@ -134,6 +147,8 @@ interface ImagePreviewProps {
readonly imageUrls: readonly string[]
/** Optional node ID for context-aware actions */
readonly nodeId?: string
/** Whether a file is being uploaded to this node */
readonly isUploading?: boolean
}
const props = defineProps<ImagePreviewProps>()

View File

@@ -122,6 +122,7 @@
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
:is-uploading="isNodeUploading"
/>
<NodeContent
v-for="preview in promotedPreviews"
@@ -744,6 +745,8 @@ const nodeMedia = computed(() => {
return { type, urls } as const
})
const isNodeUploading = computed(() => nodeOutputs.isNodeUploading(nodeData.id))
const nodeContainerRef = ref<HTMLDivElement>()
// Drag and drop support

View File

@@ -20,6 +20,7 @@
v-else-if="hasMedia && media?.type === 'image'"
:image-urls="media.urls"
:node-id="nodeId"
:is-uploading="isUploading"
class="mt-2 flex-auto"
/>
</slot>
@@ -43,14 +44,15 @@ interface NodeContentProps {
type: 'image' | 'video' | 'audio'
urls: string[]
}
isUploading?: boolean
}
const props = defineProps<NodeContentProps>()
const { nodeData, media, isUploading = false } = defineProps<NodeContentProps>()
const hasMedia = computed(() => props.media && props.media.urls.length > 0)
const hasMedia = computed(() => media && media.urls.length > 0)
// Get node ID from nodeData
const nodeId = computed(() => props.nodeData?.id?.toString())
const nodeId = computed(() => nodeData?.id?.toString())
// Error boundary implementation
const renderError = ref<string | null>(null)

View File

@@ -676,6 +676,20 @@ export class ComfyApp {
e.stopImmediatePropagation()
return
}
// Ctrl+C Copy
if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
return
}
// Ctrl+V Paste
if (
(e.key === 'v' || e.key == 'V') &&
(e.metaKey || e.ctrlKey) &&
!e.shiftKey
) {
return
}
}
// Fall through to Litegraph defaults

View File

@@ -48,6 +48,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
const latestPreview = ref<string[]>([])
const uploadingNodeIds = ref(new Set<string>())
function setNodeUploading(nodeId: string | number, uploading: boolean) {
const id = String(nodeId)
if (uploading) {
uploadingNodeIds.value.add(id)
} else {
uploadingNodeIds.value.delete(id)
}
uploadingNodeIds.value = new Set(uploadingNodeIds.value)
}
function isNodeUploading(nodeId: string | number): boolean {
return uploadingNodeIds.value.has(String(nodeId))
}
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
scheduledRevoke[locator]?.stop()
@@ -463,6 +479,10 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
restoreOutputs,
resetAllOutputsAndPreviews,
// Upload state
setNodeUploading,
isNodeUploading,
// State
nodeOutputs,
nodePreviewImages,