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
10 changed files with 62 additions and 191 deletions

View File

@@ -1,78 +0,0 @@
import { nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import { useDomValueBridge } from './useDomValueBridge'
function createInput(initialValue = ''): HTMLInputElement {
const el = document.createElement('input')
el.value = initialValue
return el
}
function createTextarea(initialValue = ''): HTMLTextAreaElement {
const el = document.createElement('textarea')
el.value = initialValue
return el
}
describe('useDomValueBridge', () => {
it('initializes the ref with the current element value', () => {
const el = createInput('hello')
const bridged = useDomValueBridge(el)
expect(bridged.value).toBe('hello')
})
it('updates the ref when element.value is set programmatically', () => {
const el = createInput('')
const bridged = useDomValueBridge(el)
el.value = 'updated'
expect(bridged.value).toBe('updated')
})
it('updates the ref on user input events', () => {
const el = createInput('')
const bridged = useDomValueBridge(el)
// Simulate user typing by using the original descriptor to set value,
// then dispatching an input event
const proto = Object.getPrototypeOf(el)
const desc = Object.getOwnPropertyDescriptor(proto, 'value')
desc?.set?.call(el, 'typed')
el.dispatchEvent(new Event('input'))
expect(bridged.value).toBe('typed')
})
it('updates the DOM element when the ref is written to', async () => {
const el = createInput('initial')
const bridged = useDomValueBridge(el)
bridged.value = 'from-ref'
await nextTick()
expect(el.value).toBe('from-ref')
})
it('works with textarea elements', () => {
const el = createTextarea('initial')
const bridged = useDomValueBridge(el)
expect(bridged.value).toBe('initial')
el.value = 'new text'
expect(bridged.value).toBe('new text')
})
it('reads element value through the intercepted getter', async () => {
const el = createInput('start')
const bridged = useDomValueBridge(el)
// The getter on element.value should still work
expect(el.value).toBe('start')
bridged.value = 'changed'
await nextTick()
// The element getter should reflect the latest set
expect(el.value).toBe('changed')
})
})

View File

@@ -1,79 +0,0 @@
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
type ValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
/**
* Bridges a DOM element's `.value` property to a Vue reactive ref.
*
* This composable provides a clean, public API for extension authors to
* synchronize DOM widget values with Vue reactivity (and by extension, the
* `widgetValueStore`). It works by:
*
* 1. Intercepting programmatic `.value` writes via `Object.defineProperty`
* 2. Listening for user-driven `input` events on the element
* 3. Exposing a reactive `Ref<string>` that stays in sync with the DOM
*
* When the returned ref is written to, the DOM element's value is updated.
* When the DOM element's value changes (programmatically or via user input),
* the ref is updated.
*
* @param element - The DOM element to bridge (input, textarea, or select)
* @returns A reactive ref that stays in sync with the element's value
*
* @example
* ```ts
* // In a custom widget's getValue/setValue:
* const bridgedValue = useDomValueBridge(inputEl)
* const widget = node.addDOMWidget(name, type, inputEl, {
* getValue: () => bridgedValue.value,
* setValue: (v) => { bridgedValue.value = v }
* })
* ```
*/
export function useDomValueBridge(element: ValueElement): Ref<string> {
const bridgedValue = ref(element.value)
// Capture the original property descriptor so we can chain through it
const proto = Object.getPrototypeOf(element)
const originalDescriptor =
Object.getOwnPropertyDescriptor(element, 'value') ??
Object.getOwnPropertyDescriptor(proto, 'value')
// Intercept programmatic .value writes on the element
// This catches cases where extensions or libraries set element.value directly
try {
Object.defineProperty(element, 'value', {
get() {
return originalDescriptor?.get?.call(this) ?? bridgedValue.value
},
set(newValue: string) {
originalDescriptor?.set?.call(this, newValue)
bridgedValue.value = newValue
},
configurable: true,
enumerable: true
})
} catch {
// If the descriptor is non-configurable, fall back to polling-free sync
// via input events only
}
// Listen for user-driven input events
element.addEventListener('input', () => {
// Read through the original descriptor to avoid infinite loops
const currentValue = originalDescriptor?.get?.call(element) ?? element.value
bridgedValue.value = currentValue
})
// When the ref is written to externally, update the DOM element
watch(bridgedValue, (newValue) => {
const currentDomValue =
originalDescriptor?.get?.call(element) ?? element.value
if (currentDomValue !== newValue) {
originalDescriptor?.set?.call(element, newValue)
}
})
return bridgedValue
}

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

@@ -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",

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

@@ -1,7 +1,6 @@
import _ from 'es-toolkit/compat'
import { type Component, toRaw } from 'vue'
import { useDomValueBridge } from '@/composables/element/useDomValueBridge'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import {
LGraphNode,
@@ -380,16 +379,6 @@ export const addWidget = <W extends BaseDOMWidget<object | string>>(
})
}
function isValueElement(
el: HTMLElement
): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
return (
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
)
}
LGraphNode.prototype.addDOMWidget = function <
T extends HTMLElement,
V extends object | string
@@ -400,19 +389,6 @@ LGraphNode.prototype.addDOMWidget = function <
element: T,
options: DOMWidgetOptions<V> = {}
): DOMWidget<T, V> {
// Auto-bridge value-bearing elements when no getValue/setValue provided.
// This gives extension authors automatic widgetValueStore integration.
if (!options.getValue && !options.setValue && isValueElement(element)) {
const bridgedValue = useDomValueBridge(element)
options = {
...options,
getValue: (() => bridgedValue.value) as () => V,
setValue: ((v: V) => {
bridgedValue.value = String(v)
}) as (v: V) => void
}
}
const widget = new DOMWidgetImpl({
node: this,
name,

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,