mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 15:24:09 +00:00
Show node output images on Vue Nodes (#5456)
* add image outputs on Vue nodes * add unit tests and update cursor pointer * use testing pinia * properly mock i18n in component test * get node via current graph * use subgraph ID from node creation * add better error handling for downloadFile util * refactor: simplify image preview component architecture - Replace awkward composable pattern with standard Vue component state - Fix reactivity issues where images didn't update on new outputs - Add proper subgraph-aware node resolution using NodeLocatorId - Enhance accessibility with keyboard navigation and ARIA labels - Add comprehensive error handling and loading states - Include PrimeVue Skeleton for better loading UX - Remove unused composable and test files The image preview now properly updates when new outputs are generated and follows standard Vue reactivity patterns. * resolve merge conflict with main - Keep both subgraphId field and hasErrors field from main - No conflicts in other files (LGraphNode.vue and main.json merged cleanly) * Fix LGraphNode test by adding proper Pinia testing setup Added createTestingPinia and i18n configuration following the pattern from working ImagePreview tests. Resolves test failures due to missing Pinia store dependencies. All 6 tests now pass successfully.
This commit is contained in:
41
src/base/common/downloadUtil.ts
Normal file
41
src/base/common/downloadUtil.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
|
||||
/**
|
||||
* Download a file from a URL by creating a temporary anchor element
|
||||
* @param url - The URL of the file to download (must be a valid URL string)
|
||||
* @param filename - Optional filename override (will use URL filename or default if not provided)
|
||||
* @throws {Error} If the URL is invalid or empty
|
||||
*/
|
||||
export const downloadFile = (url: string, filename?: string): void => {
|
||||
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
||||
throw new Error('Invalid URL provided for download')
|
||||
}
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download =
|
||||
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from a URL's query parameters
|
||||
* @param url - The URL to extract filename from
|
||||
* @returns The extracted filename or null if not found
|
||||
*/
|
||||
const extractFilenameFromUrl = (url: string): string | null => {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin)
|
||||
return urlObj.searchParams.get('filename')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export interface VueNodeData {
|
||||
mode: number
|
||||
selected: boolean
|
||||
executing: boolean
|
||||
subgraphId?: string | null
|
||||
widgets?: SafeWidgetData[]
|
||||
inputs?: unknown[]
|
||||
outputs?: unknown[]
|
||||
@@ -167,6 +168,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
const subgraphId =
|
||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const safeWidgets = node.widgets?.map((widget) => {
|
||||
try {
|
||||
@@ -216,6 +222,8 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
mode: node.mode || 0,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
subgraphId,
|
||||
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"noWorkflowsFound": "No workflows found.",
|
||||
"comingSoon": "Coming Soon",
|
||||
"download": "Download",
|
||||
"downloadImage": "Download image",
|
||||
"editOrMaskImage": "Edit or mask image",
|
||||
"removeImage": "Remove image",
|
||||
"viewImageOfTotal": "View image {index} of {total}",
|
||||
"imagePreview": "Image preview - Use arrow keys to navigate between images",
|
||||
"errorLoadingImage": "Error loading image",
|
||||
"failedToDownloadImage": "Failed to download image",
|
||||
"calculatingDimensions": "Calculating dimensions",
|
||||
"import": "Import",
|
||||
"loadAllFolders": "Load All Folders",
|
||||
"refresh": "Refresh",
|
||||
|
||||
258
src/renderer/extensions/vueNodes/components/ImagePreview.vue
Normal file
258
src/renderer/extensions/vueNodes/components/ImagePreview.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="image-preview relative group flex flex-col items-center"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
class="relative rounded-[5px] overflow-hidden w-full max-w-[352px] bg-[#262729]"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="w-full h-[352px] flex flex-col items-center justify-center text-white text-center bg-gray-800/50"
|
||||
>
|
||||
<i-lucide:image-off class="w-12 h-12 mb-2 text-gray-400" />
|
||||
<p class="text-sm text-gray-300">{{ $t('g.imageFailedToLoad') }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ currentImageUrl }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-else-if="isLoading"
|
||||
class="w-full h-[352px]"
|
||||
border-radius="5px"
|
||||
/>
|
||||
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-else
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="w-full h-[352px] object-cover block"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover) -->
|
||||
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
v-if="!hasMultipleImages"
|
||||
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
|
||||
:title="$t('g.editOrMaskImage')"
|
||||
:aria-label="$t('g.editOrMaskImage')"
|
||||
@click="handleEditMask"
|
||||
>
|
||||
<i-lucide:venetian-mask class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
|
||||
:title="$t('g.downloadImage')"
|
||||
:aria-label="$t('g.downloadImage')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i-lucide:download class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
|
||||
:title="$t('g.removeImage')"
|
||||
:aria-label="$t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i-lucide:x class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Images Navigation -->
|
||||
<div
|
||||
v-if="hasMultipleImages"
|
||||
class="absolute bottom-2 left-2 right-2 flex justify-center gap-1"
|
||||
>
|
||||
<button
|
||||
v-for="(_, index) in imageUrls"
|
||||
:key="index"
|
||||
:class="getNavigationDotClass(index)"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
"
|
||||
@click="setCurrentIndex(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-white text-xs text-center mt-2">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-gray-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
readonly imageUrls: readonly string[]
|
||||
/** Optional node ID for context-aware actions */
|
||||
readonly nodeId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<ImagePreviewProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
// Component state
|
||||
const currentIndex = ref(0)
|
||||
const isHovered = ref(false)
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Computed values
|
||||
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
|
||||
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
||||
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
(newUrls) => {
|
||||
// Reset current index if it's out of bounds
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
}
|
||||
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
imageError.value = false
|
||||
isLoading.value = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
isLoading.value = false
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
isLoading.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
|
||||
const handleEditMask = () => {
|
||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch (error) {
|
||||
useToast().add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
life: 3000,
|
||||
group: 'image-preview'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
if (!props.nodeId) return
|
||||
nodeOutputStore.removeNodeOutputs(props.nodeId)
|
||||
}
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
if (index >= 0 && index < props.imageUrls.length) {
|
||||
currentIndex.value = index
|
||||
actualDimensions.value = null
|
||||
isLoading.value = true
|
||||
imageError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
}
|
||||
|
||||
const getNavigationDotClass = (index: number) => {
|
||||
return [
|
||||
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',
|
||||
index === currentIndex.value ? 'bg-white' : 'bg-white/50 hover:bg-white/80'
|
||||
]
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (props.imageUrls.length <= 1) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(
|
||||
currentIndex.value > 0
|
||||
? currentIndex.value - 1
|
||||
: props.imageUrls.length - 1
|
||||
)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(
|
||||
currentIndex.value < props.imageUrls.length - 1
|
||||
? currentIndex.value + 1
|
||||
: 0
|
||||
)
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(0)
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
setCurrentIndex(props.imageUrls.length - 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -119,6 +119,7 @@
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
:image-urls="nodeImageUrls"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -126,7 +127,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onErrorCaptured,
|
||||
provide,
|
||||
ref,
|
||||
toRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
@@ -134,6 +143,10 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
||||
@@ -244,11 +257,10 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Check if node has custom content
|
||||
// Check if node has custom content (like image outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
// Currently all content is handled through widgets
|
||||
// This remains false but provides extensibility point
|
||||
return false
|
||||
// Show custom content if node has image outputs
|
||||
return nodeImageUrls.value.length > 0
|
||||
})
|
||||
|
||||
// Computed classes and conditions for better reusability
|
||||
@@ -318,4 +330,50 @@ const handleSlotClick = (
|
||||
const handleTitleUpdate = (newTitle: string) => {
|
||||
emit('update:title', props.nodeData.id, newTitle)
|
||||
}
|
||||
|
||||
const nodeOutputs = useNodeOutputStore()
|
||||
|
||||
const nodeImageUrls = ref<string[]>([])
|
||||
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
|
||||
// Construct proper locator ID using subgraph ID from VueNodeData
|
||||
const locatorId = props.nodeData.subgraphId
|
||||
? `${props.nodeData.subgraphId}:${props.nodeData.id}`
|
||||
: props.nodeData.id
|
||||
|
||||
// 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) {
|
||||
const urls = nodeOutputs.getNodeImageUrls(node)
|
||||
if (urls) {
|
||||
nodeImageUrls.value = urls
|
||||
}
|
||||
} else {
|
||||
// Clear URLs if no outputs or no images
|
||||
nodeImageUrls.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const nodeOutputLocatorId = computed(() =>
|
||||
props.nodeData.subgraphId
|
||||
? `${props.nodeData.subgraphId}:${props.nodeData.id}`
|
||||
: props.nodeData.id
|
||||
)
|
||||
|
||||
watch(
|
||||
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
|
||||
(newOutputs) => {
|
||||
onNodeOutputsUpdate(newOutputs)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Provide nodeImageUrls to child components
|
||||
provide('nodeImageUrls', nodeImageUrls)
|
||||
</script>
|
||||
|
||||
@@ -5,28 +5,42 @@
|
||||
<div v-else class="lg-node-content">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<!-- This component serves as a placeholder for future extensibility -->
|
||||
<!-- Currently all node content is rendered through the widget system -->
|
||||
<ImagePreview
|
||||
v-if="hasImages"
|
||||
:image-urls="props.imageUrls || []"
|
||||
:node-id="nodeId"
|
||||
class="mt-2"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
interface NodeContentProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
imageUrls?: string[]
|
||||
}
|
||||
|
||||
defineProps<NodeContentProps>()
|
||||
const props = defineProps<NodeContentProps>()
|
||||
|
||||
const hasImages = computed(() => props.imageUrls && props.imageUrls.length > 0)
|
||||
|
||||
// Get node ID from nodeData or node prop
|
||||
const nodeId = computed(() => {
|
||||
return props.nodeData?.id?.toString() || props.node?.id?.toString()
|
||||
})
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
LGraphNode,
|
||||
@@ -40,6 +41,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const { nodeIdToNodeLocatorId } = useWorkflowStore()
|
||||
const { executionIdToNodeLocatorId } = useExecutionStore()
|
||||
|
||||
const nodeOutputs = ref<Record<string, ExecutedWsMessage['output']>>({})
|
||||
|
||||
function getNodeOutputs(
|
||||
node: LGraphNode
|
||||
): ExecutedWsMessage['output'] | undefined {
|
||||
@@ -128,6 +131,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
|
||||
app.nodeOutputs[nodeLocatorId] = outputs
|
||||
nodeOutputs.value[nodeLocatorId] = outputs
|
||||
}
|
||||
|
||||
function setNodeOutputs(
|
||||
@@ -271,17 +275,49 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove node outputs for a specific node
|
||||
* Clears both outputs and preview images
|
||||
*/
|
||||
function removeNodeOutputs(nodeId: number | string) {
|
||||
const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
|
||||
if (!nodeLocatorId) return false
|
||||
|
||||
// Clear from app.nodeOutputs
|
||||
const hadOutputs = !!app.nodeOutputs[nodeLocatorId]
|
||||
delete app.nodeOutputs[nodeLocatorId]
|
||||
|
||||
// Clear from reactive state
|
||||
delete nodeOutputs.value[nodeLocatorId]
|
||||
|
||||
// Clear preview images
|
||||
if (app.nodePreviewImages[nodeLocatorId]) {
|
||||
delete app.nodePreviewImages[nodeLocatorId]
|
||||
}
|
||||
|
||||
return hadOutputs
|
||||
}
|
||||
|
||||
return {
|
||||
// Getters
|
||||
getNodeOutputs,
|
||||
getNodeImageUrls,
|
||||
getNodePreviews,
|
||||
getPreviewParam,
|
||||
|
||||
// Setters
|
||||
setNodeOutputs,
|
||||
setNodeOutputsByExecutionId,
|
||||
setNodePreviewsByExecutionId,
|
||||
setNodePreviewsByNodeId,
|
||||
|
||||
// Cleanup
|
||||
revokePreviewsByExecutionId,
|
||||
revokeAllPreviews,
|
||||
revokeSubgraphPreviews,
|
||||
getPreviewParam
|
||||
removeNodeOutputs,
|
||||
|
||||
// State
|
||||
nodeOutputs
|
||||
}
|
||||
})
|
||||
|
||||
116
tests-ui/tests/base/common/downloadUtil.test.ts
Normal file
116
tests-ui/tests/base/common/downloadUtil.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
|
||||
describe('downloadUtil', () => {
|
||||
let mockLink: HTMLAnchorElement
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock anchor element
|
||||
mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn()
|
||||
} as unknown as HTMLAnchorElement
|
||||
|
||||
// Spy on DOM methods
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockLink)
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink)
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('should create and trigger download with basic URL', () => {
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(document.createElement).toHaveBeenCalledWith('a')
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.download).toBe('download.png') // Default filename
|
||||
expect(document.body.appendChild).toHaveBeenCalledWith(mockLink)
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
expect(document.body.removeChild).toHaveBeenCalledWith(mockLink)
|
||||
})
|
||||
|
||||
it('should use custom filename when provided', () => {
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
const customFilename = 'my-custom-image.png'
|
||||
|
||||
downloadFile(testUrl, customFilename)
|
||||
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.download).toBe(customFilename)
|
||||
})
|
||||
|
||||
it('should extract filename from URL query parameters', () => {
|
||||
const testUrl =
|
||||
'https://example.com/api/file?filename=extracted-image.jpg&other=param'
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.download).toBe('extracted-image.jpg')
|
||||
})
|
||||
|
||||
it('should use default filename when URL has no filename parameter', () => {
|
||||
const testUrl = 'https://example.com/api/file?other=param'
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.download).toBe('download.png')
|
||||
})
|
||||
|
||||
it('should handle invalid URLs gracefully', () => {
|
||||
const invalidUrl = 'not-a-valid-url'
|
||||
|
||||
downloadFile(invalidUrl)
|
||||
|
||||
expect(mockLink.href).toBe(invalidUrl)
|
||||
expect(mockLink.download).toBe('download.png')
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prefer custom filename over extracted filename', () => {
|
||||
const testUrl =
|
||||
'https://example.com/api/file?filename=extracted-image.jpg'
|
||||
const customFilename = 'custom-override.png'
|
||||
|
||||
downloadFile(testUrl, customFilename)
|
||||
|
||||
expect(mockLink.download).toBe(customFilename)
|
||||
})
|
||||
|
||||
it('should handle URLs with empty filename parameter', () => {
|
||||
const testUrl = 'https://example.com/api/file?filename='
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(mockLink.download).toBe('download.png')
|
||||
})
|
||||
|
||||
it('should handle relative URLs by using window.location.origin', () => {
|
||||
const relativeUrl = '/api/file?filename=relative-image.png'
|
||||
|
||||
downloadFile(relativeUrl)
|
||||
|
||||
expect(mockLink.href).toBe(relativeUrl)
|
||||
expect(mockLink.download).toBe('relative-image.png')
|
||||
})
|
||||
|
||||
it('should clean up DOM elements after download', () => {
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
// Verify the element was added and then removed
|
||||
expect(document.body.appendChild).toHaveBeenCalledWith(mockLink)
|
||||
expect(document.body.removeChild).toHaveBeenCalledWith(mockLink)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
|
||||
|
||||
// Mock downloadFile to avoid DOM errors
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn()
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
editOrMaskImage: 'Edit or mask image',
|
||||
downloadImage: 'Download image',
|
||||
removeImage: 'Remove image',
|
||||
viewImageOfTotal: 'View image {index} of {total}',
|
||||
imagePreview:
|
||||
'Image preview - Use arrow keys to navigate between images',
|
||||
errorLoadingImage: 'Error loading image',
|
||||
failedToDownloadImage: 'Failed to download image',
|
||||
calculatingDimensions: 'Calculating dimensions',
|
||||
imageFailedToLoad: 'Image failed to load',
|
||||
loading: 'Loading'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('ImagePreview', () => {
|
||||
const defaultProps = {
|
||||
imageUrls: [
|
||||
'/api/view?filename=test1.png&type=output',
|
||||
'/api/view?filename=test2.png&type=output'
|
||||
]
|
||||
}
|
||||
|
||||
const mountImagePreview = (props = {}) => {
|
||||
return mount(ImagePreview, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
'i-lucide:venetian-mask': true,
|
||||
'i-lucide:download': true,
|
||||
'i-lucide:x': true,
|
||||
'i-lucide:image-off': true,
|
||||
Skeleton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders image preview when imageUrls provided', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('img').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render when no imageUrls provided', () => {
|
||||
const wrapper = mountImagePreview({ imageUrls: [] })
|
||||
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays calculating dimensions text initially', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
expect(wrapper.text()).toContain('Calculating dimensions')
|
||||
})
|
||||
|
||||
it('shows navigation dots for multiple images', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
expect(navigationDots).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show navigation dots for single image', () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
expect(navigationDots).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows action buttons on hover', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
// Initially buttons should not be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(false)
|
||||
|
||||
// Trigger hover
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Action buttons should now be visible
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.action-btn')).toHaveLength(2) // download, remove (no mask for multiple images)
|
||||
})
|
||||
|
||||
it('hides action buttons when not hovering', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
// Trigger hover
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(true)
|
||||
|
||||
// Trigger mouse leave
|
||||
await wrapper.trigger('mouseleave')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows mask/edit button only for single images', async () => {
|
||||
// Multiple images - should not show mask button
|
||||
const multipleImagesWrapper = mountImagePreview()
|
||||
await multipleImagesWrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const maskButtonMultiple = multipleImagesWrapper.find(
|
||||
'[aria-label="Edit or mask image"]'
|
||||
)
|
||||
expect(maskButtonMultiple.exists()).toBe(false)
|
||||
|
||||
// Single image - should show mask button
|
||||
const singleImageWrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
await singleImageWrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const maskButtonSingle = singleImageWrapper.find(
|
||||
'[aria-label="Edit or mask image"]'
|
||||
)
|
||||
expect(maskButtonSingle.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles action button clicks', async () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Test Edit/Mask button - just verify it can be clicked without errors
|
||||
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
|
||||
expect(editButton.exists()).toBe(true)
|
||||
await editButton.trigger('click')
|
||||
|
||||
// Test Remove button - just verify it can be clicked without errors
|
||||
const removeButton = wrapper.find('[aria-label="Remove image"]')
|
||||
expect(removeButton.exists()).toBe(true)
|
||||
await removeButton.trigger('click')
|
||||
})
|
||||
|
||||
it('handles download button click', async () => {
|
||||
const wrapper = mountImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
// Test Download button
|
||||
const downloadButton = wrapper.find('[aria-label="Download image"]')
|
||||
expect(downloadButton.exists()).toBe(true)
|
||||
await downloadButton.trigger('click')
|
||||
|
||||
// Verify the mocked downloadFile was called
|
||||
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
|
||||
})
|
||||
|
||||
it('switches images when navigation dots are clicked', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
// Initially shows first image
|
||||
expect(wrapper.find('img').attributes('src')).toBe(
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
|
||||
// Click second navigation dot
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// After clicking, component shows loading state (Skeleton), not img
|
||||
expect(wrapper.find('skeleton-stub').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
|
||||
// Simulate image load event to clear loading state
|
||||
const component = wrapper.vm as any
|
||||
component.isLoading = false
|
||||
await nextTick()
|
||||
|
||||
// Now should show second image
|
||||
const imgElement = wrapper.find('img')
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
|
||||
})
|
||||
|
||||
it('applies correct classes to navigation dots based on current image', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
|
||||
// First dot should be active (has bg-white class)
|
||||
expect(navigationDots[0].classes()).toContain('bg-white')
|
||||
expect(navigationDots[1].classes()).toContain('bg-white/50')
|
||||
|
||||
// Switch to second image
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Second dot should now be active
|
||||
expect(navigationDots[0].classes()).toContain('bg-white/50')
|
||||
expect(navigationDots[1].classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('loads image without errors', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
|
||||
// Just verify the image element is properly set up
|
||||
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('alt')).toBe('Node output 1')
|
||||
})
|
||||
|
||||
it('updates alt text when switching images', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
// Initially first image
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
|
||||
|
||||
// Switch to second image
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Simulate image load event to clear loading state
|
||||
const component = wrapper.vm as any
|
||||
component.isLoading = false
|
||||
await nextTick()
|
||||
|
||||
// Alt text should update
|
||||
const imgElement = wrapper.find('img')
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('alt')).toBe('Node output 2')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
@@ -40,6 +42,16 @@ vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||
LODLevel: { MINIMAL: 0 }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
'Node Render Error': 'Node Render Error'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('LGraphNode', () => {
|
||||
const mockNodeData: VueNodeData = {
|
||||
id: 'test-node-123',
|
||||
@@ -58,8 +70,21 @@ describe('LGraphNode', () => {
|
||||
return mount(LGraphNode, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
provide: {
|
||||
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
|
||||
},
|
||||
stubs: {
|
||||
NodeHeader: true,
|
||||
NodeSlots: true,
|
||||
NodeWidgets: true,
|
||||
NodeContent: true,
|
||||
SlotConnectionDot: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -82,7 +107,27 @@ describe('LGraphNode', () => {
|
||||
})
|
||||
|
||||
it('should render node title', () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
// Don't stub NodeHeader for this test so we can see the title
|
||||
const wrapper = mount(LGraphNode, {
|
||||
props: { nodeData: mockNodeData },
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
provide: {
|
||||
[SelectedNodeIdsKey as symbol]: ref(new Set())
|
||||
},
|
||||
stubs: {
|
||||
NodeSlots: true,
|
||||
NodeWidgets: true,
|
||||
NodeContent: true,
|
||||
SlotConnectionDot: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Test Node')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user