Compare commits

...

8 Commits

Author SHA1 Message Date
bymyself
314ae66258 [feat] implement ECS-style widget specification system
Add comprehensive widget metadata access system for Vue nodes:
- Create WidgetSpecificationService for type-safe input spec lookup
- Add getInputSpecForWidget method to nodeDefStore
- Update SafeWidgetData to include spec field instead of manual mapping
- Implement proper canvasOnly filtering for legacy and new systems
- Create WidgetImageUpload component with PrimeVue integration
- Add useComboBackedOptions and useWidgetImageUpload composables
- Fix TypeScript types and remove all type assertions
- Add i18n support and clean up code formatting

System now provides extensible, type-safe access to widget augmentation
flags (image_upload, video_upload, etc.) without manual field mapping.
2025-09-14 23:54:59 -07:00
bymyself
017a1dc429 get node via current graph 2025-09-14 23:50:54 -07:00
bymyself
e885a0c93c use testing pinia 2025-09-14 23:48:49 -07:00
bymyself
16a23e9081 add unit tests and update cursor pointer 2025-09-14 23:48:49 -07:00
bymyself
68fa58f353 add image outputs on Vue nodes 2025-09-14 23:48:49 -07:00
bymyself
98cd4fb38b fix: update props usage for Vue 3.5 destructured props syntax 2025-09-14 22:51:58 -07:00
bymyself
f2ba2f168a feat: optimize Vue node preview image display with reactive store
- Move preview display logic from inline ternaries to computed properties
- Add useNodePreviewState composable for preview state management
- Implement reactive store approach using Pinia storeToRefs
- Use VueUse useTimeoutFn for modern timeout management instead of window.setTimeout
- Add v-memo optimization for preview image template rendering
- Maintain proper sync between app.nodePreviewImages and reactive store state

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 22:36:59 -07:00
bymyself
96c73e2281 refactor: simplify preview state provider
- Remove unnecessary event listeners and manual syncing
- Use computed() to directly reference app.nodePreviewImages
- Eliminate data duplication and any types
- Rely on Vue's reactivity for automatic updates
- Follow established patterns from execution state provider
2025-09-14 22:33:30 -07:00
20 changed files with 800 additions and 38 deletions

View File

@@ -74,6 +74,7 @@
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
onMounted,
@@ -113,7 +114,10 @@ import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import {
NodePreviewImagesKey,
SelectedNodeIdsKey
} from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
@@ -128,6 +132,7 @@ import { newUserService } from '@/services/newUserService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
@@ -207,6 +212,9 @@ provide(SelectedNodeIdsKey, selectedNodeIds)
// Provide execution state to all Vue nodes
useExecutionStateProvider()
// Provide preview images state to all Vue nodes
const { nodePreviewImages } = storeToRefs(useNodeOutputStore())
provide(NodePreviewImagesKey, nodePreviewImages)
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')

View File

@@ -8,6 +8,8 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetSpec } from '@/services/widgetSpecificationService'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
@@ -44,6 +46,8 @@ export interface SafeWidgetData {
value: WidgetValue
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
/** Input specification for this widget */
spec?: InputSpecV2
}
export interface VueNodeData {
@@ -105,6 +109,9 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Get layout mutations composable
const { moveNode, resizeNode, createNode, deleteNode, setSource } =
useLayoutMutations()
// Get widget specification service
const widgetSpec = useWidgetSpec()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
@@ -178,7 +185,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
@@ -190,12 +196,16 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
value = widget.options.values[0]
}
// Get input spec - no manual mapping needed
const spec = widgetSpec.getInputSpec(node, widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
callback: widget.callback,
spec
}
} catch (error) {
return {
@@ -203,7 +213,8 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
type: widget.type || 'text',
value: undefined, // Already a valid WidgetValue
options: undefined,
callback: undefined
callback: undefined,
spec: undefined
}
}
})

View File

@@ -4,6 +4,9 @@ import {
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'
// NOTE: This extension should always register. In Vue Nodes mode,
// the legacy IMAGEUPLOAD widget will be ignored by the Vue renderer.
import { app } from '../../scripts/app'
// Adds an upload button to the nodes
@@ -29,13 +32,18 @@ const createUploadInput = (
'IMAGEUPLOAD',
{
...imageInputOptions[1],
imageInputName
imageInputName,
// Ensure this legacy widget is not rendered by Vue Nodes
canvasOnly: true
}
]
app.registerExtension({
name: 'Comfy.UploadImage',
beforeRegisterNodeDef(_nodeType, nodeData: ComfyNodeDef) {
// Always inject legacy IMAGEUPLOAD button for canvas mode.
// In Vue Nodes mode, NodeWidgets.vue skips rendering IMAGEUPLOAD,
// so this remains a no-op for the Vue renderer.
const { input } = nodeData ?? {}
const { required } = input ?? {}
if (!required) return

View File

@@ -16,6 +16,7 @@
"errorLoadingImage": "Error loading image",
"failedToDownloadImage": "Failed to download image",
"calculatingDimensions": "Calculating dimensions",
"upload": "Upload",
"import": "Import",
"loadAllFolders": "Load All Folders",
"refresh": "Refresh",

View File

@@ -23,3 +23,10 @@ export const ExecutingNodeIdsKey: InjectionKey<Ref<Set<string>>> =
export const NodeProgressStatesKey: InjectionKey<
Ref<Record<string, NodeProgressState>>
> = Symbol('nodeProgressStates')
/**
* Injection key for providing node preview image URLs to Vue node components.
* Maps NodeLocatorId (string) to an array of preview blob URLs.
*/
export const NodePreviewImagesKey: InjectionKey<Ref<Record<string, string[]>>> =
Symbol('nodePreviewImages')

View File

@@ -120,12 +120,25 @@
:lod-level="lodLevel"
:image-urls="nodeImageUrls"
/>
<!-- Live preview image -->
<div
v-if="shouldShowPreviewImg"
v-memo="[latestPreviewUrl]"
class="px-4 min-h-[220px]"
>
<img
:src="latestPreviewUrl"
alt="preview"
class="w-full h-[220px] object-contain rounded-lg"
/>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import {
computed,
inject,
@@ -144,11 +157,12 @@ import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { useWorkflowStore } from '@/stores/workflowStore'
import { cn } from '@/utils/tailwindUtil'
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
@@ -307,7 +321,6 @@ watch(
// Check if node has custom content (like image outputs)
const hasCustomContent = computed(() => {
// Show custom content if node has image outputs
return nodeImageUrls.value.length > 0
})
@@ -316,6 +329,14 @@ const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-300 h-[1px] mx-0 w-full'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
nodeData.id,
{
isMinimalLOD,
isCollapsed
}
)
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(
() => shouldRenderWidgets.value && nodeData.widgets?.length
@@ -380,32 +401,34 @@ const handleTitleUpdate = (newTitle: string) => {
}
const nodeOutputs = useNodeOutputStore()
const { nodeOutputs: nodeOutputsRef } = storeToRefs(nodeOutputs)
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const nodeLocatorId = computed(() => nodeIdToNodeLocatorId(props.nodeData.id))
const nodeImageUrls = ref<string[]>([])
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
// Construct proper locator ID using subgraph ID from VueNodeData
const locatorId = nodeData.subgraphId
? `${nodeData.subgraphId}:${nodeData.id}`
: nodeData.id
// Get the current graph context (subgraph if viewing one, otherwise root graph)
const currentGraph = app.canvas.graph || app.graph
const node = currentGraph?.getNodeById(Number(nodeData.id))
if (!node) return
// Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph
if (!rootGraph) {
nodeImageUrls.value = []
return
}
const node = getNodeByLocatorId(rootGraph, locatorId)
if (node && newOutputs?.images?.length) {
// Update image URLs
if (newOutputs?.images?.length) {
const urls = nodeOutputs.getNodeImageUrls(node)
if (urls) {
nodeImageUrls.value = urls
}
console.debug('[VueNodes] onNodeOutputsUpdate: updating image URLs', {
nodeId: nodeData.id,
images: newOutputs.images?.length,
urls
})
nodeImageUrls.value = urls ?? []
} else {
// Clear URLs if no outputs or no images
console.debug('[VueNodes] onNodeOutputsUpdate: cleared image URLs', {
nodeId: nodeData.id
})
nodeImageUrls.value = []
}
// No video handling in Vue node content; handled by canvas DOM widget
}
const nodeOutputLocatorId = computed(() =>
@@ -413,13 +436,17 @@ const nodeOutputLocatorId = computed(() =>
)
watch(
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
() => nodeOutputsRef.value[nodeLocatorId.value],
(newOutputs) => {
onNodeOutputsUpdate(newOutputs)
onNodeOutputsUpdate(newOutputs as ExecutedWsMessage['output'])
},
{ deep: true }
{ immediate: true }
)
// Provide nodeImageUrls to child components
provide('nodeImageUrls', nodeImageUrls)
provide(
'nodeId',
computed(() => String(nodeData.id))
)
</script>

View File

@@ -7,6 +7,7 @@
<slot>
<ImagePreview
v-if="hasImages"
:key="(props.imageUrls || []).join('|')"
:image-urls="props.imageUrls || []"
:node-id="nodeId"
class="mt-2"

View File

@@ -50,7 +50,7 @@ import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
// Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import {
getComponent,
getComponentForWidget,
isEssential,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
@@ -102,6 +102,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
}
for (const widget of widgets) {
// Skip legacy IMAGEUPLOAD widget in Vue Nodes unified Image widget handles UI
if (widget.type?.toUpperCase() === 'IMAGEUPLOAD') continue
if (widget.options?.hidden) continue
if (widget.options?.canvasOnly) continue
if (!widget.type) continue
@@ -109,7 +111,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
if (lodLevel === LODLevel.REDUCED && !isEssential(widget.type)) continue
const vueComponent = getComponent(widget.type) || WidgetInputText
const vueComponent = getComponentForWidget(widget) || WidgetInputText
const simplified: SimplifiedWidget = {
name: widget.name,

View File

@@ -0,0 +1,68 @@
import { computed, ref } from 'vue'
import { downloadFile } from '@/base/common/downloadUtil'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
/**
* Composable for managing image preview state and interactions
*/
export const useImagePreview = (imageUrls: string[], nodeId?: string) => {
const currentIndex = ref(0)
const isHovered = ref(false)
const actualDimensions = ref<string | null>(null)
const commandStore = useCommandStore()
const nodeOutputStore = useNodeOutputStore()
const currentImageUrl = computed(() => imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => imageUrls.length > 1)
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
const handleEditMask = () => {
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}
const handleDownload = () => {
downloadFile(currentImageUrl.value)
}
const handleRemove = () => {
if (!nodeId) return
nodeOutputStore.removeNodeOutputs(nodeId)
}
const setCurrentIndex = (index: number) => {
if (index >= 0 && index < imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
}
}
return {
// State
currentIndex,
isHovered,
actualDimensions,
// Computed
currentImageUrl,
hasMultipleImages,
// Event handlers
handleImageLoad,
handleEditMask,
handleDownload,
handleRemove,
// Navigation
setCurrentIndex
}
}

View File

@@ -0,0 +1,53 @@
import { type Ref, computed, inject, ref } from 'vue'
import { NodePreviewImagesKey } from '@/renderer/core/canvas/injectionKeys'
import { useWorkflowStore } from '@/stores/workflowStore'
export const useNodePreviewState = (
nodeId: string,
options?: {
isMinimalLOD?: Ref<boolean>
isCollapsed?: Ref<boolean>
}
) => {
const workflowStore = useWorkflowStore()
const nodePreviewImages = inject(
NodePreviewImagesKey,
ref<Record<string, string[]>>({})
)
const locatorId = computed(() => workflowStore.nodeIdToNodeLocatorId(nodeId))
const previewUrls = computed(() => {
const key = locatorId.value
if (!key) return undefined
const urls = nodePreviewImages.value[key]
return urls && urls.length ? urls : undefined
})
const hasPreview = computed(() => !!previewUrls.value?.length)
const latestPreviewUrl = computed(() => {
const urls = previewUrls.value
return urls && urls.length ? urls[urls.length - 1] : ''
})
const shouldShowPreviewImg = computed(() => {
if (!options?.isMinimalLOD || !options?.isCollapsed) {
return hasPreview.value
}
return (
!options.isMinimalLOD.value &&
!options.isCollapsed.value &&
hasPreview.value
)
})
return {
locatorId,
previewUrls,
hasPreview,
latestPreviewUrl,
shouldShowPreviewImg
}
}

View File

@@ -0,0 +1,108 @@
<template>
<WidgetLayoutField :widget>
<div class="flex flex-col gap-2 w-full">
<Select
v-model="localValue"
:options="selectOptions"
:disabled="readonly"
class="w-full text-xs"
size="small"
@update:model-value="onSelectChange"
/>
<!-- Upload button (minimal UI) -->
<Button
type="button"
size="small"
severity="secondary"
:disabled="readonly"
@click="openUpload"
>
{{ $t('upload') }}
</Button>
<!-- Preview intentionally omitted; NodeContent handles previews universally -->
</div>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { type Ref, computed, inject, unref } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ResultItemType } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useComboBackedOptions } from '../composables/useComboBackedOptions'
import { useWidgetImageUpload } from '../composables/useWidgetImageUpload'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | string[]>
modelValue: string | string[] | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | string[] | undefined]
}>()
const nodeId = inject<string | Ref<string>>('nodeId')
const node = computed<LGraphNode | undefined>(() => {
// Use unref to handle both regular values and refs
const actualNodeId = unref(nodeId)
const id = Number(actualNodeId)
const currentGraph = app.canvas.graph || app.graph
return (currentGraph?.getNodeById?.(id) as LGraphNode | null) || undefined
})
// Extract options
const options = computed(
() => (props.widget.options ?? {}) as Record<string, any>
)
const allowBatch = computed<boolean>(() => options.value.allow_batch === true)
// Animated flag is stored on widget options for downstream consumers if needed
const folder = computed<ResultItemType>(
() => options.value.image_folder ?? 'input'
)
// Use widget value helper (string or string[])
const { localValue } = useWidgetValue<
string | string[],
string | string[] | undefined
>({
widget: props.widget as SimplifiedWidget<string | string[]>,
modelValue: (props.modelValue as string | string[]) ?? '',
defaultValue: allowBatch.value ? ([] as string[]) : '',
emit
})
const { selectOptions, addOptions } = useComboBackedOptions({
nodeRef: node,
widgetName: props.widget.name
})
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const { open: openUpload } = useWidgetImageUpload({
nodeRef: node,
allowBatch,
folder,
accept: ACCEPTED_IMAGE_TYPES,
onUploaded: (paths) => {
addOptions(paths)
const newValue = allowBatch.value ? paths : paths[0]
emit('update:modelValue', newValue)
}
})
function onSelectChange(value: string | string[] | undefined) {
if (value == null) return
emit('update:modelValue', value)
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,50 @@
import { type ComputedRef, computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import { addToComboValues } from '@/utils/litegraphUtil'
interface Params {
nodeRef: ComputedRef<LGraphNode | undefined>
widgetName: string
}
/**
* Utilities for working with a COMBO widget that lives on the LiteGraph node.
* - Resolves select options from the real widget (array | record | function)
* - Provides a safe helper to add options to the real widget
*/
export function useComboBackedOptions({ nodeRef, widgetName }: Params) {
const getRealWidget = () => {
const node = nodeRef.value
return node?.widgets?.find((w) => w.name === widgetName) as
| IComboWidget
| undefined
}
const selectOptions = computed<string[]>(() => {
const real = getRealWidget()
const raw = real?.options?.values
if (Array.isArray(raw)) return raw as string[]
if (typeof raw === 'function') {
try {
const out = raw(real, nodeRef.value)
if (Array.isArray(out)) return out
} catch {
// Ignore function call errors and fall through to default
}
}
if (raw && typeof raw === 'object')
return Object.values(raw as Record<string, string>)
return []
})
const addOptions = (values: string[]) => {
const real = getRealWidget()
if (!real) return
for (const v of values) addToComboValues(real, v)
}
return { selectOptions, addOptions }
}

View File

@@ -23,6 +23,7 @@ import {
addValueControlWidgets
} from '@/scripts/widgets'
import { assetService } from '@/services/assetService'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useSettingStore } from '@/stores/settingStore'
import { useRemoteWidget } from './useRemoteWidget'
@@ -125,6 +126,38 @@ const addComboWidget = (
)
}
// If this combo is an image-capable selector, update node outputs on change
const isImageCombo =
inputSpec.image_upload === true ||
inputSpec.animated_image_upload === true ||
inputSpec.video_upload === true
if (isImageCombo) {
const store = useNodeOutputStore()
const folder = inputSpec.image_folder ?? 'input'
const isAnimated = inputSpec.animated_image_upload === true
const orig = widget.callback
widget.callback = (value) => {
try {
orig?.(value)
} finally {
// Ensure outputs reflect the currently selected image
const val = String(value ?? '')
if (val != null && val !== '') {
store.setNodeOutputs(node, val, { folder, isAnimated })
node.graph?.setDirtyCanvas(true)
}
}
}
// Initial seed from default/current value
const seedVal = (widget.value ?? defaultValue) as string | undefined
if (seedVal) {
queueMicrotask(() => {
store.setNodeOutputs(node, seedVal, { folder, isAnimated })
node.graph?.setDirtyCanvas(true)
})
}
}
return widget as IBaseWidget
}

View File

@@ -98,6 +98,10 @@ export const useImageUploadWidget = () => {
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
console.debug('[ImageUploadWidget] combo changed', {
nodeId: node.id,
value: fileComboWidget.value
})
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})

View File

@@ -0,0 +1,44 @@
import { type ComputedRef, onMounted } from 'vue'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ResultItemType } from '@/schemas/apiSchema'
interface Params {
nodeRef: ComputedRef<LGraphNode | undefined>
allowBatch: ComputedRef<boolean>
folder: ComputedRef<ResultItemType>
accept?: string
onUploaded: (paths: string[]) => void
}
/**
* Thin wrapper around useNodeImageUpload tailored for a Vue widget.
* Attaches upload behaviors to the node and exposes a simple open() trigger.
*/
export function useWidgetImageUpload({
nodeRef,
allowBatch,
folder,
accept,
onUploaded
}: Params) {
let openFileSelection: (() => void) | null = null
onMounted(() => {
const node = nodeRef.value
if (!node) return
const { openFileSelection: open } = useNodeImageUpload(node, {
allow_batch: allowBatch.value,
accept,
folder: folder.value,
fileFilter: (f) => f.type.startsWith('image/'),
onUploadComplete: onUploaded
})
openFileSelection = open
})
return {
open: () => openFileSelection?.()
}
}

View File

@@ -3,12 +3,19 @@
*/
import type { Component } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import {
type InputSpec,
isComboInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import WidgetButton from '../components/WidgetButton.vue'
import WidgetChart from '../components/WidgetChart.vue'
import WidgetColorPicker from '../components/WidgetColorPicker.vue'
import WidgetFileUpload from '../components/WidgetFileUpload.vue'
import WidgetGalleria from '../components/WidgetGalleria.vue'
import WidgetImageCompare from '../components/WidgetImageCompare.vue'
import WidgetImageUpload from '../components/WidgetImageUpload.vue'
import WidgetInputNumber from '../components/WidgetInputNumber.vue'
import WidgetInputText from '../components/WidgetInputText.vue'
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
@@ -143,8 +150,36 @@ export const isEssential = (type: string): boolean => {
export const shouldRenderAsVue = (widget: {
type?: string
options?: Record<string, unknown>
spec?: InputSpec
}): boolean => {
if (widget.options?.canvasOnly) return false
// Check canvasOnly in both widget options (legacy) and input spec (new system)
const specCanvasOnly =
widget.spec && 'canvasOnly' in widget.spec ? widget.spec.canvasOnly : false
if (widget.options?.canvasOnly || specCanvasOnly) {
return false
}
if (!widget.type) return false
return isSupported(widget.type)
}
/**
* Returns the Vue component for a given widget, with special handling for
* image-capable combo inputs in Vue Nodes.
*/
export const getComponentForWidget = (
widget: SafeWidgetData
): Component | null => {
const type = widget.type?.toUpperCase()
const isImageCombo =
type === 'COMBO' &&
widget.spec &&
isComboInputSpec(widget.spec) &&
(widget.spec.image_upload ||
widget.spec.animated_image_upload ||
widget.spec.video_upload)
if (isImageCombo) {
return WidgetImageUpload
}
return getComponent(widget.type)
}

View File

@@ -0,0 +1,37 @@
/**
* Widget Specification Service - ECS-adjacent system for accessing widget metadata
*
* Provides type-safe access to input specifications without requiring data
* to be stored directly on widgets or nodes.
*/
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useNodeDefStore } from '@/stores/nodeDefStore'
/**
* Service for accessing widget specifications from input specs
*/
class WidgetSpecificationService {
private nodeDefStore = useNodeDefStore()
/**
* Get the input specification for a widget
* Use schema type guards (isComboInputSpec, etc.) to access type-specific properties
*/
getInputSpec(node: LGraphNode, widgetName: string): InputSpecV2 | undefined {
return this.nodeDefStore.getInputSpecForWidget(node, widgetName)
}
}
// Singleton instance for the service
let widgetSpecService: WidgetSpecificationService | null = null
/**
* Get the widget specification service (singleton)
*/
export function useWidgetSpec(): WidgetSpecificationService {
if (!widgetSpecService) {
widgetSpecService = new WidgetSpecificationService()
}
return widgetSpecService
}

View File

@@ -1,3 +1,4 @@
import { useTimeoutFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -19,6 +20,8 @@ import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseFilePath } from '@/utils/formatUtil'
import { isVideoNode } from '@/utils/litegraphUtil'
const PREVIEW_REVOKE_DELAY_MS = 400
const createOutputs = (
filenames: string[],
type: ResultItemType,
@@ -40,9 +43,28 @@ interface SetOutputOptions {
export const useNodeOutputStore = defineStore('nodeOutput', () => {
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const { executionIdToNodeLocatorId } = useExecutionStore()
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
if (scheduledRevoke[locator]) {
scheduledRevoke[locator].stop()
}
const { stop } = useTimeoutFn(() => {
delete scheduledRevoke[locator]
cb()
}, PREVIEW_REVOKE_DELAY_MS)
scheduledRevoke[locator] = { stop }
}
const nodeOutputs = ref<Record<string, ExecutedWsMessage['output']>>({})
// Reactive state for node preview images - mirrors app.nodePreviewImages
const nodePreviewImages = ref<Record<string, string[]>>(
app.nodePreviewImages || {}
)
function getNodeOutputs(
node: LGraphNode
): ExecutedWsMessage['output'] | undefined {
@@ -90,18 +112,34 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function getNodeImageUrls(node: LGraphNode): string[] | undefined {
const previews = getNodePreviews(node)
if (previews?.length) return previews
if (previews?.length) {
console.debug('[NodeOutputStore] using preview images', {
nodeId: node.id,
count: previews.length
})
return previews
}
const outputs = getNodeOutputs(node)
if (!outputs?.images?.length) return
if (!outputs?.images?.length) {
console.debug('[NodeOutputStore] no output images for node', {
nodeId: node.id
})
return
}
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
return outputs.images.map((image) => {
const urls = outputs.images.map((image) => {
const imgUrlPart = new URLSearchParams(image)
return api.apiURL(`/view?${imgUrlPart}${previewParam}${rand}`)
})
console.debug('[NodeOutputStore] computed image URLs', {
nodeId: node.id,
urls
})
return urls
}
/**
@@ -196,8 +234,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
/**
@@ -212,7 +254,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
previewImages: string[]
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
/**
@@ -224,8 +271,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function revokePreviewsByExecutionId(executionId: string) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
revokePreviewsByLocatorId(nodeLocatorId)
scheduleRevoke(nodeLocatorId, () =>
revokePreviewsByLocatorId(nodeLocatorId)
)
}
/**
@@ -243,6 +291,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
delete app.nodePreviewImages[nodeLocatorId]
delete nodePreviewImages.value[nodeLocatorId]
}
/**
@@ -259,6 +308,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
}
app.nodePreviewImages = {}
nodePreviewImages.value = {}
}
/**
@@ -293,6 +343,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// Clear preview images
if (app.nodePreviewImages[nodeLocatorId]) {
delete app.nodePreviewImages[nodeLocatorId]
delete nodePreviewImages.value[nodeLocatorId]
}
return hadOutputs
@@ -318,6 +369,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
removeNodeOutputs,
// State
nodeOutputs
nodeOutputs,
nodePreviewImages
}
})

View File

@@ -352,6 +352,19 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
return nodeDef
}
function getInputSpecForWidget(
node: LGraphNode,
widgetName: string
): InputSpecV2 | undefined {
const nodeDef = fromLGraphNode(node)
if (!nodeDef) {
return undefined
}
// V2 inputs are stored as Record<string, InputSpecV2> keyed by input name
return nodeDef.inputs[widgetName]
}
/**
* Registers a node definition filter.
* @param filter - The filter to register
@@ -424,6 +437,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
updateNodeDefs,
addNodeDef,
fromLGraphNode,
getInputSpecForWidget,
registerNodeDefFilter,
unregisterNodeDefFilter
}

View File

@@ -0,0 +1,199 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useImagePreview } from '@/renderer/extensions/vueNodes/composables/useImagePreview'
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
execute: vi.fn().mockResolvedValue(undefined)
}))
}))
vi.mock('@/stores/imagePreviewStore', () => ({
useNodeOutputStore: vi.fn(() => ({
removeNodeOutputs: vi.fn().mockReturnValue(true)
}))
}))
describe('useImagePreview', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
vi.restoreAllMocks()
})
const mockImageUrls = [
'/api/view?filename=test1.png&type=output',
'/api/view?filename=test2.png&type=output',
'/api/view?filename=test3.png&type=output'
]
// Helper function to create properly typed mock image events
const createMockImageEvent = (
naturalWidth: number,
naturalHeight: number
): Event => {
// Create a mock that satisfies HTMLImageElement type checking
const mockImg = Object.create(HTMLImageElement.prototype)
// Define naturalWidth and naturalHeight as properties since they're readonly on HTMLImageElement
Object.defineProperty(mockImg, 'naturalWidth', {
value: naturalWidth,
writable: false,
configurable: true
})
Object.defineProperty(mockImg, 'naturalHeight', {
value: naturalHeight,
writable: false,
configurable: true
})
Object.assign(mockImg, {
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
removeEventListener: vi.fn()
})
return {
target: mockImg,
currentTarget: mockImg,
srcElement: mockImg,
bubbles: false,
cancelBubble: false,
cancelable: false,
composed: false,
defaultPrevented: false,
eventPhase: 0,
isTrusted: false,
returnValue: true,
timeStamp: 0,
type: 'load',
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
stopPropagation: vi.fn(),
composedPath: vi.fn(() => []),
initEvent: vi.fn(),
NONE: 0,
CAPTURING_PHASE: 1,
AT_TARGET: 2,
BUBBLING_PHASE: 3
} satisfies Event
}
it('initializes with correct default state', () => {
const { currentIndex, isHovered, actualDimensions, hasMultipleImages } =
useImagePreview(mockImageUrls)
expect(currentIndex.value).toBe(0)
expect(isHovered.value).toBe(false)
expect(actualDimensions.value).toBeNull()
expect(mockImageUrls.length > 0).toBe(true)
expect(hasMultipleImages.value).toBe(true)
})
it('handles single image correctly', () => {
const singleImageUrl = [mockImageUrls[0]]
const { hasMultipleImages } = useImagePreview(singleImageUrl)
expect(mockImageUrls.length > 0).toBe(true)
expect(hasMultipleImages.value).toBe(false)
})
it('handles empty image array correctly', () => {
const { hasMultipleImages, currentImageUrl } = useImagePreview([])
expect([].length > 0).toBe(false)
expect(hasMultipleImages.value).toBe(false)
expect(currentImageUrl.value).toBeUndefined()
})
it('computes currentImageUrl correctly', () => {
const { currentImageUrl, setCurrentIndex } = useImagePreview(mockImageUrls)
expect(currentImageUrl.value).toBe(mockImageUrls[0])
setCurrentIndex(1)
expect(currentImageUrl.value).toBe(mockImageUrls[1])
})
it('handles setCurrentIndex with bounds checking', () => {
const { currentIndex, setCurrentIndex } = useImagePreview(mockImageUrls)
// Valid index
setCurrentIndex(2)
expect(currentIndex.value).toBe(2)
// Invalid index (too high)
setCurrentIndex(5)
expect(currentIndex.value).toBe(2) // Should remain unchanged
// Invalid index (negative)
setCurrentIndex(-1)
expect(currentIndex.value).toBe(2) // Should remain unchanged
})
it('handles download action', () => {
// Mock DOM methods
const mockLink = {
href: '',
download: '',
click: vi.fn()
}
const mockCreateElement = vi
.spyOn(document, 'createElement')
.mockReturnValue(mockLink as unknown as HTMLAnchorElement)
const mockAppendChild = vi
.spyOn(document.body, 'appendChild')
.mockImplementation(() => mockLink as unknown as HTMLAnchorElement)
const mockRemoveChild = vi
.spyOn(document.body, 'removeChild')
.mockImplementation(() => mockLink as unknown as HTMLAnchorElement)
const { handleDownload } = useImagePreview(mockImageUrls)
handleDownload()
expect(mockCreateElement).toHaveBeenCalledWith('a')
expect(mockLink.href).toBe(mockImageUrls[0])
expect(mockLink.click).toHaveBeenCalled()
expect(mockAppendChild).toHaveBeenCalledWith(mockLink)
expect(mockRemoveChild).toHaveBeenCalledWith(mockLink)
mockCreateElement.mockRestore()
mockAppendChild.mockRestore()
mockRemoveChild.mockRestore()
})
it('handles image load event correctly', () => {
const { handleImageLoad, actualDimensions } = useImagePreview(mockImageUrls)
const mockEvent = createMockImageEvent(1024, 768)
handleImageLoad(mockEvent)
expect(actualDimensions.value).toBe('1024 x 768')
})
it('handles image load event with invalid dimensions', () => {
const { handleImageLoad, actualDimensions } = useImagePreview(mockImageUrls)
const mockEvent = createMockImageEvent(0, 0)
handleImageLoad(mockEvent)
expect(actualDimensions.value).toBeNull()
})
it('resets dimensions when changing images', () => {
const { actualDimensions, setCurrentIndex, handleImageLoad } =
useImagePreview(mockImageUrls)
// Set dimensions for first image
const mockEvent = createMockImageEvent(1024, 768)
handleImageLoad(mockEvent)
expect(actualDimensions.value).toBe('1024 x 768')
// Change to second image
setCurrentIndex(1)
expect(actualDimensions.value).toBeNull()
})
})