mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
8 Commits
mai-14-tan
...
vue-nodes/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
314ae66258 | ||
|
|
017a1dc429 | ||
|
|
e885a0c93c | ||
|
|
16a23e9081 | ||
|
|
68fa58f353 | ||
|
|
98cd4fb38b | ||
|
|
f2ba2f168a | ||
|
|
96c73e2281 |
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<slot>
|
||||
<ImagePreview
|
||||
v-if="hasImages"
|
||||
:key="(props.imageUrls || []).join('|')"
|
||||
:image-urls="props.imageUrls || []"
|
||||
:node-id="nodeId"
|
||||
class="mt-2"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
37
src/services/widgetSpecificationService.ts
Normal file
37
src/services/widgetSpecificationService.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user