Compare commits

...

22 Commits

Author SHA1 Message Date
Johnpaul
7840b1c05c WIP Template filters 2025-08-07 00:46:12 +01:00
Johnpaul
6316dde209 fix PR comments 2025-08-04 19:57:58 +01:00
Johnpaul
fbc44b31be fix unit tests 2025-08-01 17:46:08 +01:00
Johnpaul Chiwetelu
650e9d0710 Merge branch 'main' into scroll-templates-better 2025-08-01 03:37:42 +01:00
Christian Byrne
eae4b954d0 [fix] Preserve per-workflow subgraph navigation state (#4616)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 19:37:17 -07:00
Johnpaul Chiwetelu
cc67ee035d Merge branch 'main' into scroll-templates-better 2025-08-01 03:31:23 +01:00
Johnpaul
e46f682da3 [fix] Improve lazy loading logic in LazyImage component 2025-08-01 03:30:54 +01:00
Christian Byrne
baea47c493 Extract selection filtering logic to useSelectedLiteGraphItems composable and don't show toolbox when selecting Reroutes (#4634) 2025-07-31 18:02:08 -07:00
Comfy Org PR Bot
8673e0e6c4 1.25.3 (#4633)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-31 15:11:36 -07:00
Christian Byrne
b125e0aa3a [feat] Move partial execution to the backend and make work with subgraphs (#4624) 2025-07-31 13:28:52 -07:00
Johnpaul Chiwetelu
bfdad0e475 Merge branch 'main' into scroll-templates-better 2025-07-31 02:27:15 +01:00
Christian Byrne
aabea4b78d [feat] Viewport persistence for subgraph navigation (#4613) 2025-07-30 17:54:35 -07:00
Christian Byrne
f85df302fb [fix] show tooltip on color picker button in selection toolbox (#4612) 2025-07-30 17:24:48 -07:00
Christian Byrne
b2b50ac012 [Style] Update "convert to subgraph" icon (#4611) 2025-07-30 13:29:30 -07:00
Christian Byrne
fe475403b0 [feat] Add theme-aware colors to minimap (#4598) 2025-07-30 12:41:02 -07:00
Christian Byrne
efb08bf2ba [Style] Fix node preview header/title overflow (#4610) 2025-07-30 11:46:50 -07:00
Christian Byrne
2c84ecbf6e [fix] Make minimap reactive to subgraph context changes (#4597) 2025-07-30 10:41:17 -07:00
Johnpaul Chiwetelu
7e6a3cd4ff Merge branch 'main' into scroll-templates-better 2025-07-30 01:25:43 +01:00
Johnpaul
d6074cd9ee feat: add clearFilters, loadingMore, and searchPlaceholder translations for multiple locales 2025-07-30 01:15:24 +01:00
Johnpaul
6b69225bbf feat: add useIntersectionObserver, useLazyPagination, useTemplateFiltering, and mediaCacheService for improved component functionality 2025-07-30 01:15:02 +01:00
Johnpaul
0b2d985fbf feat: implement LazyImage component for thumbnails and search bar 2025-07-30 01:14:14 +01:00
Johnpaul
939b94f85f feat: add lazy loading image component and skeleton for workflow cards 2025-07-30 01:13:28 +01:00
57 changed files with 3620 additions and 409 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.2",
"version": "1.25.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.2",
"version": "1.25.3",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.25.2",
"version": "1.25.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -0,0 +1,124 @@
<template>
<div
ref="containerRef"
class="relative overflow-hidden w-full h-full flex items-center justify-center"
>
<Skeleton
v-if="!isImageLoaded"
width="100%"
height="100%"
class="absolute inset-0"
/>
<img
v-show="isImageLoaded"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
draggable="false"
:class="imageClass"
:style="imageStyle"
@load="onImageLoad"
@error="onImageError"
/>
<div
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
>
<i class="pi pi-image text-2xl" />
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
const {
src,
alt = '',
imageClass = '',
imageStyle,
rootMargin = '300px'
} = defineProps<{
src: string
alt?: string
imageClass?: string | string[] | Record<string, boolean>
imageStyle?: Record<string, any>
rootMargin?: string
}>()
const containerRef = ref<HTMLElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const isIntersecting = ref(false)
const isImageLoaded = ref(false)
const hasError = ref(false)
const cachedSrc = ref<string | undefined>(undefined)
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
// Use intersection observer to detect when the image container comes into view
useIntersectionObserver(
containerRef,
(entries) => {
const entry = entries[0]
isIntersecting.value = entry?.isIntersecting ?? false
},
{
rootMargin,
threshold: 0.1
}
)
// Only start loading the image when it's in view
const shouldLoad = computed(() => isIntersecting.value)
watch(
shouldLoad,
async (shouldLoad) => {
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
try {
const cachedMedia = await getCachedMedia(src)
if (cachedMedia.error) {
hasError.value = true
} else if (cachedMedia.objectUrl) {
const acquiredUrl = acquireUrl(src)
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
} else {
cachedSrc.value = src
}
} catch (error) {
console.warn('Failed to load cached media:', error)
cachedSrc.value = src
}
} else if (!shouldLoad) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
// Hide image when out of view
isImageLoaded.value = false
cachedSrc.value = undefined
hasError.value = false
}
},
{ immediate: true }
)
const onImageLoad = () => {
isImageLoaded.value = true
hasError.value = false
}
const onImageError = () => {
hasError.value = true
isImageLoaded.value = false
}
onUnmounted(() => {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
})
</script>

View File

@@ -17,26 +17,28 @@ import { createBounds } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useCanvasStore } from '@/stores/graphStore'
const canvasStore = useCanvasStore()
const { style, updatePosition } = useAbsolutePosition()
const { getSelectableItems } = useSelectedLiteGraphItems()
const visible = ref(false)
const showBorder = ref(false)
const positionSelectionOverlay = () => {
const { selectedItems } = canvasStore.getCanvas()
showBorder.value = selectedItems.size > 1
const selectableItems = getSelectableItems()
showBorder.value = selectableItems.size > 1
if (!selectedItems.size) {
if (!selectableItems.size) {
visible.value = false
return
}
visible.value = true
const bounds = createBounds(selectedItems)
const bounds = createBounds(selectableItems)
if (bounds) {
updatePosition({
pos: [bounds[0], bounds[1]],
@@ -45,7 +47,6 @@ const positionSelectionOverlay = () => {
}
}
// Register listener on canvas creation.
whenever(
() => canvasStore.getCanvas().state.selectionChanged,
() => {

View File

@@ -0,0 +1,122 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import { useCanvasStore } from '@/stores/graphStore'
import { useWorkflowStore } from '@/stores/workflowStore'
// Mock the litegraph module
vi.mock('@comfyorg/litegraph', async () => {
const actual = await vi.importActual('@comfyorg/litegraph')
return {
...actual,
LGraphCanvas: {
node_colors: {
red: { bgcolor: '#ff0000' },
green: { bgcolor: '#00ff00' },
blue: { bgcolor: '#0000ff' }
}
},
LiteGraph: {
NODE_DEFAULT_BGCOLOR: '#353535'
},
isColorable: vi.fn(() => true)
}
})
// Mock the colorUtil module
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_light')
}))
// Mock the litegraphUtil module
vi.mock('@/utils/litegraphUtil', () => ({
getItemsColorOption: vi.fn(() => null),
isLGraphNode: vi.fn((item) => item?.type === 'LGraphNode'),
isLGraphGroup: vi.fn((item) => item?.type === 'LGraphGroup'),
isReroute: vi.fn(() => false)
}))
describe('ColorPickerButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let workflowStore: ReturnType<typeof useWorkflowStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
color: {
noColor: 'No Color',
red: 'Red',
green: 'Green',
blue: 'Blue'
}
}
}
})
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
workflowStore = useWorkflowStore()
// Set up default store state
canvasStore.selectedItems = []
// Mock workflow store
workflowStore.activeWorkflow = {
changeTracker: {
checkState: vi.fn()
}
} as any
})
const createWrapper = () => {
return mount(ColorPickerButton, {
global: {
plugins: [PrimeVue, i18n],
directives: {
tooltip: Tooltip
}
}
})
}
it('should render when nodes are selected', () => {
// Add a mock node to selectedItems
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
const wrapper = createWrapper()
expect(wrapper.find('button').exists()).toBe(true)
})
it('should not render when nothing is selected', () => {
// Keep selectedItems empty
canvasStore.selectedItems = []
const wrapper = createWrapper()
// The button exists but is hidden with v-show
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('button').attributes('style')).toContain(
'display: none'
)
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
const wrapper = createWrapper()
const button = wrapper.find('button')
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
await button.trigger('click')
expect(wrapper.find('.color-picker-container').exists()).toBe(true)
await button.trigger('click')
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
})
})

View File

@@ -2,6 +2,10 @@
<div class="relative">
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
v-tooltip.top="{
value: localizedCurrentColorName ?? t('color.noColor'),
showDelay: 512
}"
severity="secondary"
text
@click="() => (showColorPicker = !showColorPicker)"
@@ -123,6 +127,16 @@ const currentColor = computed(() =>
: null
)
const localizedCurrentColorName = computed(() => {
if (!currentColorOption.value?.bgcolor) return null
const colorOption = colorOptions.find(
(option) =>
option.value.dark === currentColorOption.value?.bgcolor ||
option.value.light === currentColorOption.value?.bgcolor
)
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
})
watch(
() => canvasStore.selectedItems,
(newSelectedItems) => {

View File

@@ -10,7 +10,7 @@
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
>
<template #icon>
<i-comfy:workflow />
<i-lucide:shrink />
</template>
</Button>
</template>

View File

@@ -0,0 +1,132 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import NodePreview from './NodePreview.vue'
describe('NodePreview', () => {
let i18n: ReturnType<typeof createI18n>
let pinia: ReturnType<typeof createPinia>
beforeAll(() => {
// Create a Vue app instance for PrimeVue
const app = createApp({})
app.use(PrimeVue)
// Create i18n instance
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
preview: 'Preview'
}
}
}
})
// Create pinia instance
pinia = createPinia()
})
const mockNodeDef: ComfyNodeDefV2 = {
name: 'TestNode',
display_name:
'Test Node With A Very Long Display Name That Should Overflow',
category: 'test',
output_node: false,
inputs: {
test_input: {
name: 'test_input',
type: 'STRING',
tooltip: 'Test input'
}
},
outputs: [],
python_module: 'test_module',
description: 'Test node description'
}
const mountComponent = (nodeDef: ComfyNodeDefV2 = mockNodeDef) => {
return mount(NodePreview, {
global: {
plugins: [PrimeVue, i18n, pinia],
stubs: {
// Stub stores if needed
}
},
props: {
nodeDef
}
})
}
it('renders node preview with correct structure', () => {
const wrapper = mountComponent()
expect(wrapper.find('._sb_node_preview').exists()).toBe(true)
expect(wrapper.find('.node_header').exists()).toBe(true)
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
})
it('applies overflow-ellipsis class to node header for text truncation', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.classes()).toContain('overflow-ellipsis')
expect(nodeHeader.classes()).toContain('mr-4')
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.attributes('title')).toBe(mockNodeDef.display_name)
})
it('displays truncated long node names with ellipsis', () => {
const longNameNodeDef: ComfyNodeDefV2 = {
...mockNodeDef,
display_name:
'This Is An Extremely Long Node Name That Should Definitely Be Truncated With Ellipsis To Prevent Layout Issues'
}
const wrapper = mountComponent(longNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
// Verify the title attribute contains the full name
expect(nodeHeader.attributes('title')).toBe(longNameNodeDef.display_name)
// Verify overflow handling classes are applied
expect(nodeHeader.classes()).toContain('overflow-ellipsis')
// The actual text content should still be the full name (CSS handles truncation)
expect(nodeHeader.text()).toContain(longNameNodeDef.display_name)
})
it('handles short node names without issues', () => {
const shortNameNodeDef: ComfyNodeDefV2 = {
...mockNodeDef,
display_name: 'Short'
}
const wrapper = mountComponent(shortNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.attributes('title')).toBe('Short')
expect(nodeHeader.text()).toContain('Short')
})
it('applies proper spacing to the dot element', () => {
const wrapper = mountComponent()
const headdot = wrapper.find('.headdot')
expect(headdot.classes()).toContain('pr-3')
})
})

View File

@@ -6,13 +6,14 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div class="_sb_node_preview">
<div class="_sb_table">
<div
class="node_header"
class="node_header overflow-ellipsis mr-4"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
color: litegraphColors.NODE_TITLE_COLOR
}"
>
<div class="_sb_dot headdot" />
<div class="_sb_dot headdot pr-3" />
{{ nodeDef.display_name }}
</div>
<div class="_sb_preview_badge">{{ $t('g.preview') }}</div>

View File

@@ -0,0 +1,195 @@
<template>
<div class="relative w-full p-4">
<div class="h-12 flex items-center gap-4 justify-between">
<div class="flex-1 max-w-md">
<div class="relative w-full">
<div
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none z-10"
>
<i class="pi pi-search text-surface-400"></i>
</div>
<AutoComplete
v-model.lazy="searchQuery"
:placeholder="$t('templateWorkflows.searchPlaceholder')"
:complete-on-focus="false"
:delay="200"
class="w-full"
:pt="{
pcInputText: {
root: {
class: 'w-full rounded-md pl-10 pr-10'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="() => {}"
/>
<div
v-if="searchQuery"
class="absolute inset-y-0 right-0 flex items-center pr-3 z-10"
>
<button
type="button"
class="text-surface-400 hover:text-surface-600 bg-transparent border-none p-0 cursor-pointer"
@click="searchQuery = ''"
>
<i class="pi pi-times"></i>
</button>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Model Filter Dropdown -->
<div class="relative">
<Button
ref="modelFilterButton"
:label="modelFilterLabel"
icon="pi pi-filter"
outlined
class="rounded-2xl"
@click="toggleModelFilter"
/>
<Popover
ref="modelFilterPopover"
:pt="{
root: { class: 'w-64 max-h-80 overflow-auto' }
}"
>
<div class="p-3">
<div class="font-medium mb-2">
{{ $t('templateWorkflows.modelFilter') }}
</div>
<div class="flex flex-col gap-2">
<div
v-for="model in availableModels"
:key="model"
class="flex items-center"
>
<Checkbox
:model-value="selectedModels.includes(model)"
:binary="true"
@change="toggleModel(model)"
/>
<label class="ml-2 text-sm">{{ model }}</label>
</div>
</div>
</div>
</Popover>
</div>
<!-- Sort Dropdown -->
<Select
v-model="sortBy"
:options="sortOptions"
option-label="label"
option-value="value"
:placeholder="$t('templateWorkflows.sort')"
class="rounded-2xl"
:pt="{
root: { class: 'min-w-32' }
}"
/>
</div>
</div>
<!-- Filter Tags Row -->
<div
v-if="selectedModels.length > 0"
class="flex items-center gap-2 mt-3 overflow-x-auto pb-2"
>
<div class="flex gap-2">
<Tag
v-for="model in selectedModels"
:key="model"
:value="model"
severity="secondary"
>
<template #icon>
<button
type="button"
class="text-surface-400 hover:text-surface-600 bg-transparent border-none p-0 cursor-pointer ml-1"
@click="removeModel(model)"
>
<i class="pi pi-times text-xs"></i>
</button>
</template>
</Tag>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Popover from 'primevue/popover'
import Select from 'primevue/select'
import Tag from 'primevue/tag'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SortOption } from '@/composables/useTemplateFiltering'
const { t } = useI18n()
const { availableModels } = defineProps<{
filteredCount?: number | null
availableModels: string[]
}>()
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const selectedModels = defineModel<string[]>('selectedModels', {
default: () => []
})
const sortBy = defineModel<SortOption>('sortBy', { default: 'recommended' })
// emit removed - no longer needed since clearFilters was removed
const modelFilterButton = ref<HTMLElement>()
const modelFilterPopover = ref()
const sortOptions = [
{ label: t('templateWorkflows.sortRecommended'), value: 'recommended' },
{ label: t('templateWorkflows.sortAlphabetical'), value: 'alphabetical' },
{ label: t('templateWorkflows.sortNewest'), value: 'newest' }
]
const modelFilterLabel = computed(() => {
if (selectedModels.value.length === 0) {
return t('templateWorkflows.modelFilter')
} else if (selectedModels.value.length === 1) {
return selectedModels.value[0]
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModels.value.length
})
}
})
const toggleModelFilter = (event: Event) => {
modelFilterPopover.value.toggle(event)
}
const toggleModel = (model: string) => {
const index = selectedModels.value.indexOf(model)
if (index > -1) {
selectedModels.value.splice(index, 1)
} else {
selectedModels.value.push(model)
}
}
const removeModel = (model: string) => {
const index = selectedModels.value.indexOf(model)
if (index > -1) {
selectedModels.value.splice(index, 1)
}
}
// clearFilters function removed - no longer used since we removed the clear button
</script>

View File

@@ -62,14 +62,49 @@
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex items-center px-4 py-3 relative">
<div class="flex-1 flex flex-col">
<h3 class="line-clamp-2 text-lg font-normal mb-0" :title="title">
{{ title }}
</h3>
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
{{ description }}
</p>
<!-- Description / Action Buttons Container -->
<div class="relative grow">
<!-- Description Text -->
<p
class="line-clamp-2 text-sm text-muted grow transition-opacity duration-200"
:class="{ 'opacity-0': isHovered }"
:title="description"
>
{{ description }}
</p>
<!-- Action Buttons (visible on hover) -->
<div
v-if="isHovered"
class="absolute inset-0 flex items-center gap-2 transition-opacity duration-200"
>
<Button
v-if="template.tutorialUrl"
size="small"
severity="secondary"
outlined
class="flex-1 rounded-lg"
@click.stop="openTutorial"
>
{{ $t('templateWorkflows.tutorial') }}
</Button>
<Button
size="small"
severity="primary"
:class="template.tutorialUrl ? 'flex-1' : 'w-full'"
class="rounded-lg"
@click.stop="$emit('loadWorkflow', template.name)"
>
{{ $t('templateWorkflows.useTemplate') }}
</Button>
</div>
</div>
</div>
</div>
</template>
@@ -78,6 +113,7 @@
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import Button from 'primevue/button'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
@@ -133,6 +169,12 @@ const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
const openTutorial = () => {
if (template.tutorialUrl) {
window.open(template.tutorialUrl, '_blank', 'noopener,noreferrer')
}
}
defineEmits<{
loadWorkflow: [name: string]
}>()

View File

@@ -0,0 +1,30 @@
<template>
<Card
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<Skeleton width="16rem" height="12rem" />
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<Skeleton width="80%" height="1.25rem" class="mb-2" />
<Skeleton width="100%" height="0.875rem" class="mb-1" />
<Skeleton width="90%" height="0.875rem" />
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
@@ -28,6 +29,15 @@ vi.mock('primevue/selectbutton', () => ({
}
}))
vi.mock('primevue/button', () => ({
default: {
name: 'Button',
template: '<button class="p-button"><slot></slot></button>',
props: ['severity', 'outlined', 'size', 'class'],
emits: ['click']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
default: {
template: `
@@ -53,10 +63,63 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
}
}))
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
default: {
template: '<div class="mock-search-bar"></div>',
props: [
'searchQuery',
'filteredCount',
'availableModels',
'selectedModels',
'sortBy'
],
emits: [
'update:searchQuery',
'update:selectedModels',
'update:sortBy',
'clearFilters'
]
}
}))
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
default: {
template: '<div class="mock-skeleton"></div>'
}
}))
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => 'grid'
}))
vi.mock('@/composables/useIntersectionObserver', () => ({
useIntersectionObserver: vi.fn()
}))
vi.mock('@/composables/useLazyPagination', () => ({
useLazyPagination: (items: any) => ({
paginatedItems: items,
isLoading: { value: false },
hasMoreItems: { value: false },
loadNextPage: vi.fn(),
reset: vi.fn()
})
}))
vi.mock('@/composables/useTemplateFiltering', () => ({
useTemplateFiltering: (templates: any) => ({
searchQuery: { value: '' },
selectedModels: { value: [] },
selectedSubcategory: { value: null },
sortBy: { value: 'recommended' },
availableSubcategories: { value: [] },
availableModels: { value: [] },
filteredTemplates: templates,
filteredCount: { value: templates.value?.length || 0 },
resetFilters: vi.fn()
})
}))
describe('TemplateWorkflowView', () => {
const createTemplate = (name: string): TemplateInfo => ({
name,
@@ -67,6 +130,18 @@ describe('TemplateWorkflowView', () => {
})
const mountView = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWorkflows: {
loadingMore: 'Loading more...'
}
}
}
})
return mount(TemplateWorkflowView, {
props: {
title: 'Test Templates',
@@ -78,7 +153,12 @@ describe('TemplateWorkflowView', () => {
createTemplate('template-3')
],
loading: null,
availableSubcategories: [],
selectedSubcategory: null,
...props
},
global: {
plugins: [i18n]
}
})
}

View File

@@ -1,24 +1,57 @@
<template>
<DataView
:value="templates"
:value="finalDisplayTemplates"
:layout="layout"
data-key="name"
:lazy="true"
pt:root="h-full grid grid-rows-[auto_1fr]"
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
pt:content="p-2 overflow-auto"
>
<template #header>
<div class="flex justify-between items-center">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
<div class="flex flex-col">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
</div>
<!-- Subcategory Navigation -->
<div
v-if="availableSubcategories.length > 0"
class="mb-4 overflow-x-scroll flex max-w-[1000px]"
>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
<div class="flex gap-2 pb-2">
<Button
:severity="selectedSubcategory === null ? 'primary' : 'secondary'"
:outlined="selectedSubcategory !== null"
size="small"
class="rounded-2xl whitespace-nowrap"
@click="$emit('update:selectedSubcategory', null)"
>
{{ $t('templateWorkflows.allSubcategories') }}
</Button>
<Button
v-for="subcategory in availableSubcategories"
:key="subcategory"
:severity="
selectedSubcategory === subcategory ? 'primary' : 'secondary'
"
:outlined="selectedSubcategory !== subcategory"
size="small"
class="rounded-2xl whitespace-nowrap"
@click="$emit('update:selectedSubcategory', subcategory)"
>
{{ subcategory }}
</Button>
</div>
</div>
</div>
</template>
@@ -33,18 +66,35 @@
</template>
<template #grid="{ items }">
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
<div>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
<TemplateWorkflowCardSkeleton
v-for="n in shouldUsePagination && isLoadingMore
? skeletonCount
: 0"
:key="`skeleton-${n}`"
/>
</div>
<div
v-if="shouldUsePagination && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ t('templateWorkflows.loadingMore') }}
</div>
</div>
</div>
</template>
</DataView>
@@ -52,19 +102,37 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
import DataView from 'primevue/dataview'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
defineProps<{
const { t } = useI18n()
const {
title,
sourceModule,
categoryTitle,
loading,
templates,
availableSubcategories,
selectedSubcategory
} = defineProps<{
title: string
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
availableSubcategories: string[]
selectedSubcategory: string | null
}>()
const layout = useLocalStorage<'grid' | 'list'>(
@@ -72,8 +140,59 @@ const layout = useLocalStorage<'grid' | 'list'>(
'grid'
)
const skeletonCount = 6
const loadTrigger = ref<HTMLElement | null>(null)
// Since filtering is now handled at parent level, we just use the templates directly
const displayTemplates = computed(() => templates || [])
// Simplified pagination - only show pagination when we have lots of templates
const shouldUsePagination = computed(() => templates.length > 12)
// Lazy pagination setup using templates directly
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset: resetPagination
} = useLazyPagination(displayTemplates, {
itemsPerPage: 12
})
// Final templates to display with pagination
const finalDisplayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: displayTemplates.value
})
// Intersection observer for auto-loading (only when not searching)
useIntersectionObserver(
loadTrigger,
(entries) => {
const entry = entries[0]
if (
entry?.isIntersecting &&
shouldUsePagination.value &&
hasMoreTemplates.value &&
!isLoadingMore.value
) {
void loadNextPage()
}
},
{
rootMargin: '200px',
threshold: 0.1
}
)
watch([() => templates], () => {
resetPagination()
})
const emit = defineEmits<{
loadWorkflow: [name: string]
'update:selectedSubcategory': [value: string | null]
}>()
const onLoadWorkflow = (name: string) => {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
class="flex flex-col h-[83vh] w-[90vw] relative pb-6 px-8 mx-auto"
data-testid="template-workflows-content"
>
<Button
@@ -12,12 +12,13 @@
@click="toggleSideNav"
/>
<Divider
class="m-0 [&::before]:border-surface-border/70 [&::before]:border-t-2"
class="my-0 w-[90vw] -mx-8 relative [&::before]:border-surface-border/70 [&::before]:border-t-2"
/>
<div class="flex flex-1 relative overflow-hidden">
<aside
v-if="isSideNavOpen"
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
class="absolute translate-x-0 top-0 left-0 h-full w-60 shadow-md z-5 transition-transform duration-300 ease-in-out"
>
<ProgressSpinner
v-if="!isTemplatesLoaded || !isReady"
@@ -25,27 +26,64 @@
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
@update:selected-tab="handleTabSelection"
:selected-subcategory="selectedSubcategory"
:selected-view="selectedView"
@update:selected-subcategory="handleSubcategory"
@update:selected-view="handleViewSelection"
/>
</aside>
<div
class="flex-1 transition-all duration-300"
:class="{
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-60': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
}"
>
<TemplateWorkflowView
v-if="isReady && selectedTemplate"
class="px-12 py-4"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
/>
<div
v-if="
isReady &&
(selectedView === 'all' ||
selectedView === 'recent' ||
selectedSubcategory)
"
class="flex flex-col h-full"
>
<div class="px-8 sm:px-12 py-4 border-b border-surface-border/20">
<TemplateSearchBar
v-model:search-query="searchQuery"
v-model:selected-models="selectedModels"
v-model:sort-by="sortBy"
:filtered-count="filteredCount"
:available-models="availableModels"
@clear-filters="resetFilters"
/>
</div>
<TemplateWorkflowView
class="px-8 sm:px-12 flex-1"
:title="
selectedSubcategory
? selectedSubcategory.label
: selectedView === 'all'
? $t('templateWorkflows.view.allTemplates', 'All Templates')
: selectedTemplate?.title || ''
"
:source-module="selectedTemplate?.moduleName || 'all'"
:templates="filteredTemplates"
:available-subcategories="availableSubcategories"
:selected-subcategory="filterSubcategory"
:loading="loadingTemplateId"
:category-title="
selectedSubcategory
? selectedSubcategory.label
: selectedView === 'all'
? $t('templateWorkflows.view.allTemplates', 'All Templates')
: selectedTemplate?.title || ''
"
@load-workflow="handleLoadWorkflow"
@update:selected-subcategory="filterSubcategory = $event"
/>
</div>
</div>
</div>
</div>
@@ -56,13 +94,15 @@ import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { watch } from 'vue'
import { computed, ref, watch } from 'vue'
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
import type { TemplateSubcategory } from '@/types/workflowTemplateTypes'
const {
isSmallScreen,
@@ -76,34 +116,78 @@ const {
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
// State for subcategory selection
const selectedSubcategory = ref<TemplateSubcategory | null>(null)
// State for view selection (all vs recent)
const selectedView = ref<'all' | 'recent'>('all')
// Template filtering for the top-level search
const templatesRef = computed(() => {
// If a subcategory is selected, use all templates from that subcategory
if (selectedSubcategory.value) {
return selectedSubcategory.value.modules.flatMap(
(module) => module.templates
)
}
// If "All Templates" view is selected and no subcategory, show all templates across all groups
if (selectedView.value === 'all') {
return allTemplateGroups.value.flatMap((group) =>
group.subcategories.flatMap((subcategory) =>
subcategory.modules.flatMap((module) => module.templates)
)
)
}
// Otherwise, use the selected template's templates (for recent view or fallback)
return selectedTemplate.value?.templates || []
})
const {
searchQuery,
selectedModels,
selectedSubcategory: filterSubcategory,
sortBy,
availableSubcategories,
availableModels,
filteredTemplates,
filteredCount,
resetFilters
} = useTemplateFiltering(templatesRef)
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
// Start with "All Templates" view by default instead of selecting first subcategory
selectedView.value = 'all'
selectedSubcategory.value = null
}
},
{ once: true }
)
const handleTabSelection = (selection: WorkflowTemplates | null) => {
if (selection !== null) {
selectTemplateCategory(selection)
const handleSubcategory = (subcategory: TemplateSubcategory) => {
selectedSubcategory.value = subcategory
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
}
// On small screens, close the sidebar when a subcategory is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
}
}
const handleViewSelection = (view: 'all' | 'recent') => {
selectedView.value = view
// Clear subcategory selection when switching to header views
selectedSubcategory.value = null
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false

View File

@@ -1,6 +1,6 @@
<template>
<div>
<h3 class="px-4">
<h3 class="px-8 text-base font-medium">
<span>{{ $t('templateWorkflows.title') }}</span>
</h3>
</div>

View File

@@ -1,50 +1,142 @@
<template>
<ScrollPanel class="w-80" style="height: calc(83vh - 48px)">
<Listbox
:model-value="selectedTab"
:options="tabs"
option-group-label="label"
option-label="localizedTitle"
option-group-children="modules"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
option: { class: 'px-12 py-3 text-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
list-style="max-height:unset"
@update:model-value="handleTabSelection"
<ScrollPanel class="w-60" style="height: calc(83vh - 48px)">
<!-- View Selection Header -->
<div
class="text-left py-3 border-b border-surface-200 dark-theme:border-surface-700"
>
<template #optiongroup="slotProps">
<div class="text-left py-3 px-12">
<h2 class="text-lg">
{{ slotProps.option.label }}
</h2>
<Listbox
:model-value="selectedView"
:options="viewOptions"
option-label="label"
option-value="value"
:multiple="false"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
header: { class: 'px-0' },
option: {
class: 'px-0 py-2 text-sm font-medium flex items-center gap-1'
}
}"
@update:model-value="handleViewSelection"
>
<template #option="slotProps">
<div class="flex self-center items-center gap-1 py-1 px-4">
<i :class="slotProps.option.icon"></i>
<div>{{ slotProps.option.label }}</div>
</div>
</template>
</Listbox>
</div>
<!-- Template Groups and Subcategories -->
<div class="w-full">
<div v-for="group in props.tabs" :key="group.label" class="mb-2">
<!-- Group Header -->
<div class="text-left py-1">
<h3
class="text-muted text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 px-4"
>
{{ group.label }}
</h3>
</div>
</template>
</Listbox>
<!-- Subcategories as Listbox -->
<Listbox
:model-value="selectedSubcategory"
:options="group.subcategories"
option-label="label"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
option: { class: 'px-4 py-3 text-sm' }
}"
@update:model-value="handleSubcategorySelection"
>
<template #option="slotProps">
<div class="flex items-center">
<div>{{ slotProps.option.label }}</div>
</div>
</template>
</Listbox>
</div>
</div>
</ScrollPanel>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
TemplateGroup,
WorkflowTemplates
TemplateSubcategory
} from '@/types/workflowTemplateTypes'
defineProps<{
const { t } = useI18n()
const props = defineProps<{
tabs: TemplateGroup[]
selectedTab: WorkflowTemplates | null
selectedSubcategory: TemplateSubcategory | null
selectedView: 'all' | 'recent' | null
}>()
const emit = defineEmits<{
(e: 'update:selectedTab', tab: WorkflowTemplates): void
(e: 'update:selectedSubcategory', subcategory: TemplateSubcategory): void
(e: 'update:selectedView', view: 'all' | 'recent'): void
}>()
const handleTabSelection = (tab: WorkflowTemplates) => {
emit('update:selectedTab', tab)
const viewOptions = computed(() => {
// Create a comprehensive "All Templates" subcategory that aggregates all modules
const allTemplatesSubcategory = {
label: t('templateWorkflows.view.allTemplates', 'All Templates'),
modules: props.tabs.flatMap((group: TemplateGroup) =>
group.subcategories.flatMap(
(subcategory: TemplateSubcategory) => subcategory.modules
)
)
}
const recentItemsSubcategory = {
label: t('templateWorkflows.view.recentItems', 'Recent Items'),
modules: [] // Could be populated with recent templates if needed
}
return [
{
...allTemplatesSubcategory,
icon: 'pi pi-list mr-2'
},
{
...recentItemsSubcategory,
icon: 'pi pi-clock mr-2'
}
]
})
const handleSubcategorySelection = (subcategory: TemplateSubcategory) => {
emit('update:selectedSubcategory', subcategory)
}
const handleViewSelection = (subcategory: TemplateSubcategory | null) => {
// Prevent deselection - always keep a view selected
if (subcategory !== null) {
// Emit as subcategory selection since these now have comprehensive module data
emit('update:selectedSubcategory', subcategory)
// Also emit view selection for backward compatibility
const viewValue = subcategory.label.includes('All Templates')
? 'all'
: 'recent'
emit('update:selectedView', viewValue)
}
// If subcategory is null, we don't emit anything, keeping the current selection
}
</script>
<style scoped>
:deep(.p-listbox-header) {
padding: 0;
}
</style>

View File

@@ -12,6 +12,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
@@ -35,23 +44,24 @@ describe('CompareSliderThumbnail', () => {
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe('/base-image.jpg')
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages.length).toBe(2)
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
})
it('applies clip-path style to overlay image', () => {
const wrapper = mountThumbnail()
const overlay = wrapper.findAll('img')[1]
expect(overlay.attributes('style')).toContain('clip-path')
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageStyle = overlayLazyImage.props('imageStyle')
expect(imageStyle.clipPath).toContain('inset')
})
it('renders slider divider', () => {

View File

@@ -1,24 +1,24 @@
<template>
<BaseThumbnail :is-hovered="isHovered">
<img
<LazyImage
:src="baseImageSrc"
:alt="alt"
:class="
:image-class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
/>
<div ref="containerRef" class="absolute inset-0">
<img
<LazyImage
:src="overlayImageSrc"
:alt="alt"
:class="
:image-class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
:style="{
:image-style="{
clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`
}"
/>
@@ -36,6 +36,7 @@
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const SLIDER_START_POSITION = 50

View File

@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
describe('DefaultThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(DefaultThumbnail, {
@@ -25,9 +34,9 @@ describe('DefaultThumbnail', () => {
it('renders image with correct src and alt', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('/test-image.jpg')
expect(img.attributes('alt')).toBe('Test Image')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('src')).toBe('/test-image.jpg')
expect(lazyImage.props('alt')).toBe('Test Image')
})
it('applies scale transform when hovered', () => {
@@ -35,35 +44,43 @@ describe('DefaultThumbnail', () => {
isHovered: true,
hoverZoom: 10
})
const img = wrapper.find('img')
expect(img.attributes('style')).toContain('scale(1.1)')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
})
it('does not apply scale transform when not hovered', () => {
const wrapper = mountThumbnail({
isHovered: false
})
const img = wrapper.find('img')
expect(img.attributes('style')).toBeUndefined()
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toBeUndefined()
})
it('applies video styling for video type', () => {
const wrapper = mountThumbnail({
isVideo: true
})
const img = wrapper.find('img')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
expect(img.classes()).toContain('object-cover')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('w-full')
expect(classString).toContain('h-full')
expect(classString).toContain('object-cover')
})
it('applies image styling for non-video type', () => {
const wrapper = mountThumbnail({
isVideo: false
})
const img = wrapper.find('img')
expect(img.classes()).toContain('max-w-full')
expect(img.classes()).toContain('object-contain')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('max-w-full')
expect(classString).toContain('object-contain')
})
it('applies correct styling for webp images', () => {
@@ -71,8 +88,12 @@ describe('DefaultThumbnail', () => {
src: '/test-video.webp',
isVideo: true
})
const img = wrapper.find('img')
expect(img.classes()).toContain('object-cover')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('object-cover')
})
it('image is not draggable', () => {
@@ -83,11 +104,15 @@ describe('DefaultThumbnail', () => {
it('applies transition classes', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.classes()).toContain('transform-gpu')
expect(img.classes()).toContain('transition-transform')
expect(img.classes()).toContain('duration-300')
expect(img.classes()).toContain('ease-out')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transform-gpu')
expect(classString).toContain('transition-transform')
expect(classString).toContain('duration-300')
expect(classString).toContain('ease-out')
})
it('passes correct props to BaseThumbnail', () => {

View File

@@ -1,25 +1,23 @@
<template>
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
<div class="overflow-hidden w-full h-full flex items-center justify-center">
<img
:src="src"
:alt="alt"
draggable="false"
:class="[
'transform-gpu transition-transform duration-300 ease-out',
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
]"
:style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
"
/>
</div>
<LazyImage
:src="src"
:alt="alt"
:image-class="[
'transform-gpu transition-transform duration-300 ease-out',
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
]"
:image-style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
"
/>
</BaseThumbnail>
</template>
<script setup lang="ts">
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const { src, isVideo } = defineProps<{

View File

@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
describe('HoverDissolveThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(HoverDissolveThumbnail, {
@@ -27,31 +36,39 @@ describe('HoverDissolveThumbnail', () => {
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe('/base-image.jpg')
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages.length).toBe(2)
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
})
it('makes overlay image visible when hovered', () => {
const wrapper = mountThumbnail({ isHovered: true })
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('opacity-100')
expect(overlayImage.classes()).not.toContain('opacity-0')
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('opacity-100')
expect(classString).not.toContain('opacity-0')
})
it('makes overlay image hidden when not hovered', () => {
const wrapper = mountThumbnail({ isHovered: false })
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('opacity-0')
expect(overlayImage.classes()).not.toContain('opacity-100')
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('opacity-0')
expect(classString).not.toContain('opacity-100')
})
it('passes isHovered prop to BaseThumbnail', () => {
@@ -62,21 +79,33 @@ describe('HoverDissolveThumbnail', () => {
it('applies transition classes to overlay image', () => {
const wrapper = mountThumbnail()
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('transition-opacity')
expect(overlayImage.classes()).toContain('duration-300')
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transition-opacity')
expect(classString).toContain('duration-300')
})
it('applies correct positioning to both images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
// Check base image
expect(images[0].classes()).toContain('absolute')
expect(images[0].classes()).toContain('inset-0')
const baseImageClass = lazyImages[0].props('imageClass')
const baseClassString = Array.isArray(baseImageClass)
? baseImageClass.join(' ')
: baseImageClass
expect(baseClassString).toContain('absolute')
expect(baseClassString).toContain('inset-0')
// Check overlay image
expect(images[1].classes()).toContain('absolute')
expect(images[1].classes()).toContain('inset-0')
const overlayImageClass = lazyImages[1].props('imageClass')
const overlayClassString = Array.isArray(overlayImageClass)
? overlayImageClass.join(' ')
: overlayImageClass
expect(overlayClassString).toContain('absolute')
expect(overlayClassString).toContain('inset-0')
})
})

View File

@@ -1,37 +1,23 @@
<template>
<BaseThumbnail :is-hovered="isHovered">
<div class="relative w-full h-full">
<img
:src="baseImageSrc"
:alt="alt"
draggable="false"
class="absolute inset-0"
:class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
/>
<img
<LazyImage :src="baseImageSrc" :alt="alt" :image-class="baseImageClass" />
<LazyImage
:src="overlayImageSrc"
:alt="alt"
draggable="false"
class="absolute inset-0 transition-opacity duration-300"
:class="[
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain',
{ 'opacity-100': isHovered, 'opacity-0': !isHovered }
]"
:image-class="overlayImageClass"
/>
</div>
</BaseThumbnail>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const { baseImageSrc, overlayImageSrc, isVideo } = defineProps<{
const { baseImageSrc, overlayImageSrc, isVideo, isHovered } = defineProps<{
baseImageSrc: string
overlayImageSrc: string
alt: string
@@ -44,4 +30,17 @@ const isVideoType =
baseImageSrc?.toLowerCase().endsWith('.webp') ||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
false
const baseImageClass = computed(() => {
return `absolute inset-0 ${isVideoType ? 'w-full h-full object-cover' : 'max-w-full max-h-64 object-contain'}`
})
const overlayImageClass = computed(() => {
const baseClasses = 'absolute inset-0 transition-opacity duration-300'
const sizeClasses = isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
const opacityClasses = isHovered ? 'opacity-100' : 'opacity-0'
return `${baseClasses} ${sizeClasses} ${opacityClasses}`
})
</script>

View File

@@ -0,0 +1,71 @@
import { Positionable, Reroute } from '@comfyorg/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
/**
* Composable for handling selected LiteGraph items filtering and operations.
* This provides utilities for working with selected items on the canvas,
* including filtering out items that should not be included in selection operations.
*/
export function useSelectedLiteGraphItems() {
const canvasStore = useCanvasStore()
/**
* Items that should not show in the selection overlay are ignored.
* @param item - The item to check.
* @returns True if the item should be ignored, false otherwise.
*/
const isIgnoredItem = (item: Positionable): boolean => {
return item instanceof Reroute
}
/**
* Filter out items that should not show in the selection overlay.
* @param items - The Set of items to filter.
* @returns The filtered Set of items.
*/
const filterSelectableItems = (
items: Set<Positionable>
): Set<Positionable> => {
const result = new Set<Positionable>()
for (const item of items) {
if (!isIgnoredItem(item)) {
result.add(item)
}
}
return result
}
/**
* Get the filtered selected items from the canvas.
* @returns The filtered Set of selected items.
*/
const getSelectableItems = (): Set<Positionable> => {
const { selectedItems } = canvasStore.getCanvas()
return filterSelectableItems(selectedItems)
}
/**
* Check if there are any selectable items.
* @returns True if there are selectable items, false otherwise.
*/
const hasSelectableItems = (): boolean => {
return getSelectableItems().size > 0
}
/**
* Check if there are multiple selectable items.
* @returns True if there are multiple selectable items, false otherwise.
*/
const hasMultipleSelectableItems = (): boolean => {
return getSelectableItems().size > 1
}
return {
isIgnoredItem,
filterSelectableItems,
getSelectableItems,
hasSelectableItems,
hasMultipleSelectableItems
}
}

View File

@@ -29,7 +29,11 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil'
import {
getAllNonIoNodesInSubgraph,
getExecutionIdsForSelectedNodes
} from '@/utils/graphTraversalUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -363,10 +367,10 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.19.6',
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
const queueNodeIds = getSelectedNodes()
.filter((node) => node.constructor.nodeData?.output_node)
.map((node) => node.id)
if (queueNodeIds.length === 0) {
const selectedNodes = getSelectedNodes()
const selectedOutputNodes = filterOutputNodes(selectedNodes)
if (selectedOutputNodes.length === 0) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToQueue'),
@@ -375,7 +379,11 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
await app.queuePrompt(0, batchCount, queueNodeIds)
// Get execution IDs for all selected output nodes and their descendants
const executionIds =
getExecutionIdsForSelectedNodes(selectedOutputNodes)
await app.queuePrompt(0, batchCount, executionIds)
}
},
{

View File

@@ -0,0 +1,60 @@
import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
export interface UseIntersectionObserverOptions
extends IntersectionObserverInit {
immediate?: boolean
}
export function useIntersectionObserver(
target: Ref<Element | null>,
callback: IntersectionObserverCallback,
options: UseIntersectionObserverOptions = {}
) {
const { immediate = true, ...observerOptions } = options
const isSupported =
typeof window !== 'undefined' && 'IntersectionObserver' in window
const isIntersecting = ref(false)
let observer: IntersectionObserver | null = null
const cleanup = () => {
if (observer) {
observer.disconnect()
observer = null
}
}
const observe = () => {
cleanup()
if (!isSupported || !target.value) return
observer = new IntersectionObserver((entries) => {
isIntersecting.value = entries.some((entry) => entry.isIntersecting)
callback(entries, observer!)
}, observerOptions)
observer.observe(target.value)
}
const unobserve = () => {
if (observer && target.value) {
observer.unobserve(target.value)
}
}
if (immediate) {
watch(target, observe, { immediate: true, flush: 'post' })
}
onBeforeUnmount(cleanup)
return {
isSupported,
isIntersecting,
observe,
unobserve,
cleanup
}
}

View File

@@ -0,0 +1,107 @@
import { type Ref, computed, ref, shallowRef, watch } from 'vue'
export interface LazyPaginationOptions {
itemsPerPage?: number
initialPage?: number
}
export function useLazyPagination<T>(
items: Ref<T[]> | T[],
options: LazyPaginationOptions = {}
) {
const { itemsPerPage = 12, initialPage = 1 } = options
const currentPage = ref(initialPage)
const isLoading = ref(false)
const loadedPages = shallowRef(new Set<number>([]))
// Get reactive items array
const itemsArray = computed(() => {
const itemData = 'value' in items ? items.value : items
return Array.isArray(itemData) ? itemData : []
})
// Simulate pagination by slicing the items
const paginatedItems = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return []
}
const loadedPageNumbers = Array.from(loadedPages.value).sort(
(a, b) => a - b
)
const maxLoadedPage = Math.max(...loadedPageNumbers, 0)
const endIndex = maxLoadedPage * itemsPerPage
return itemData.slice(0, endIndex)
})
const hasMoreItems = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return false
}
const loadedPagesArray = Array.from(loadedPages.value)
const maxLoadedPage = Math.max(...loadedPagesArray, 0)
return maxLoadedPage * itemsPerPage < itemData.length
})
const totalPages = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return 0
}
return Math.ceil(itemData.length / itemsPerPage)
})
const loadNextPage = async () => {
if (isLoading.value || !hasMoreItems.value) return
isLoading.value = true
const loadedPagesArray = Array.from(loadedPages.value)
const nextPage = Math.max(...loadedPagesArray, 0) + 1
// Simulate network delay
// await new Promise((resolve) => setTimeout(resolve, 5000))
const newLoadedPages = new Set(loadedPages.value)
newLoadedPages.add(nextPage)
loadedPages.value = newLoadedPages
currentPage.value = nextPage
isLoading.value = false
}
// Initialize with first page
watch(
() => itemsArray.value.length,
(length) => {
if (length > 0 && loadedPages.value.size === 0) {
loadedPages.value = new Set([1])
}
},
{ immediate: true }
)
const reset = () => {
currentPage.value = initialPage
loadedPages.value = new Set([])
isLoading.value = false
// Immediately load first page if we have items
const itemData = itemsArray.value
if (itemData.length > 0) {
loadedPages.value = new Set([1])
}
}
return {
paginatedItems,
isLoading,
hasMoreItems,
currentPage,
totalPages,
loadNextPage,
reset
}
}

View File

@@ -5,8 +5,11 @@ import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
@@ -17,6 +20,8 @@ interface GraphCallbacks {
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const workflowStore = useWorkflowStore()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
@@ -52,12 +57,18 @@ export function useMinimap() {
const width = 250
const height = 200
const nodeColor = '#0B8CE999'
const linkColor = '#F99614'
const slotColor = '#F99614'
const viewportColor = '#FFF'
const backgroundColor = '#15161C'
const borderColor = '#333'
// Theme-aware colors for canvas drawing
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const nodeColor = computed(
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
)
const linkColor = computed(
() => (isLightTheme.value ? '#FFB347' : '#F99614') // lighter orange for light theme
)
const slotColor = computed(() => linkColor.value)
const containerRect = ref({
left: 0,
@@ -97,13 +108,36 @@ export function useMinimap() {
}
const canvas = computed(() => canvasStore.canvas)
const graph = computed(() => canvas.value?.graph)
const graph = ref(app.canvas?.graph)
// Update graph ref when subgraph context changes
watch(
() => workflowStore.activeSubgraph,
() => {
graph.value = app.canvas?.graph
// Force viewport update when switching subgraphs
if (initialized.value && visible.value) {
updateViewport()
}
}
)
// Update viewport when switching workflows
watch(
() => workflowStore.activeWorkflow,
() => {
// Force viewport update when switching workflows
if (initialized.value && visible.value) {
updateViewport()
}
}
)
const containerStyles = computed(() => ({
width: `${width}px`,
height: `${height}px`,
backgroundColor: backgroundColor,
border: `1px solid ${borderColor}`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
@@ -111,8 +145,8 @@ export function useMinimap() {
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
height: `${viewportTransform.value.height}px`,
border: `2px solid ${viewportColor}`,
backgroundColor: `${viewportColor}33`,
border: `2px solid ${isLightTheme.value ? '#E0E0E0' : '#FFF'}`,
backgroundColor: `#FFF33`,
willChange: 'transform',
backfaceVisibility: 'hidden' as const,
perspective: '1000px',
@@ -121,7 +155,7 @@ export function useMinimap() {
const calculateGraphBounds = () => {
const g = graph.value
if (!g?._nodes || g._nodes.length === 0) {
if (!g || !g._nodes || g._nodes.length === 0) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
@@ -195,7 +229,7 @@ export function useMinimap() {
const h = node.size[1] * scale.value
// Render solid node blocks
ctx.fillStyle = nodeColor
ctx.fillStyle = nodeColor.value
ctx.fillRect(x, y, w, h)
}
}
@@ -208,7 +242,7 @@ export function useMinimap() {
const g = graph.value
if (!g) return
ctx.strokeStyle = linkColor
ctx.strokeStyle = linkColor.value
ctx.lineWidth = 1.4
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
@@ -257,7 +291,7 @@ export function useMinimap() {
}
// Render connection slots on top
ctx.fillStyle = slotColor
ctx.fillStyle = slotColor.value
for (const conn of connections) {
// Output slot
ctx.beginPath()
@@ -272,13 +306,14 @@ export function useMinimap() {
}
const renderMinimap = () => {
if (!canvasRef.value || !graph.value) return
const g = graph.value
if (!canvasRef.value || !g) return
const ctx = canvasRef.value.getContext('2d')
if (!ctx) return
// Fast path for 0 nodes - just show background
if (!graph.value._nodes || graph.value._nodes.length === 0) {
if (!g._nodes || g._nodes.length === 0) {
ctx.clearRect(0, 0, width, height)
return
}

View File

@@ -0,0 +1,134 @@
import { type Ref, computed, ref } from 'vue'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
export type SortOption = 'recommended' | 'alphabetical' | 'newest'
export interface TemplateFilterOptions {
searchQuery?: string
selectedModels?: string[]
selectedSubcategory?: string | null
sortBy?: SortOption
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const searchQuery = ref('')
const selectedModels = ref<string[]>([])
const selectedSubcategory = ref<string | null>(null)
const sortBy = ref<SortOption>('recommended')
const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates
return Array.isArray(templateData) ? templateData : []
})
// Get unique subcategories (tags) from current templates
const availableSubcategories = computed(() => {
const subcategorySet = new Set<string>()
templatesArray.value.forEach((template) => {
template.tags?.forEach((tag) => subcategorySet.add(tag))
})
return Array.from(subcategorySet).sort()
})
// Get unique models from all current templates (don't filter by subcategory for model list)
const availableModels = computed(() => {
const modelSet = new Set<string>()
templatesArray.value.forEach((template) => {
template.models?.forEach((model) => modelSet.add(model))
})
return Array.from(modelSet).sort()
})
const filteredTemplates = computed(() => {
let templateData = templatesArray.value
if (templateData.length === 0) {
return []
}
// Filter by search query
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim()
templateData = templateData.filter((template) => {
const searchableText = [
template.name,
template.title,
template.description,
template.sourceModule,
...(template.tags || [])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchableText.includes(query)
})
}
// Filter by subcategory
if (selectedSubcategory.value) {
templateData = templateData.filter((template) =>
template.tags?.includes(selectedSubcategory.value!)
)
}
// Filter by selected models
if (selectedModels.value.length > 0) {
templateData = templateData.filter((template) =>
template.models?.some((model) => selectedModels.value.includes(model))
)
}
// Sort templates
const sortedData = [...templateData]
switch (sortBy.value) {
case 'alphabetical':
sortedData.sort((a, b) =>
(a.title || a.name).localeCompare(b.title || b.name)
)
break
case 'newest':
sortedData.sort((a, b) => {
const dateA = new Date(a.date || '1970-01-01')
const dateB = new Date(b.date || '1970-01-01')
return dateB.getTime() - dateA.getTime()
})
break
case 'recommended':
default:
// Keep original order for recommended (assumes templates are already in recommended order)
break
}
return sortedData
})
const resetFilters = () => {
searchQuery.value = ''
selectedModels.value = []
selectedSubcategory.value = null
sortBy.value = 'recommended'
}
const resetModelFilters = () => {
selectedModels.value = []
}
const filteredCount = computed(() => filteredTemplates.value.length)
return {
searchQuery,
selectedModels,
selectedSubcategory,
sortBy,
availableSubcategories,
availableModels,
filteredTemplates,
filteredCount,
resetFilters,
resetModelFilters
}
}

View File

@@ -41,8 +41,14 @@ export function useTemplateWorkflows() {
*/
const selectFirstTemplateCategory = () => {
if (allTemplateGroups.value.length > 0) {
const firstCategory = allTemplateGroups.value[0].modules[0]
selectTemplateCategory(firstCategory)
const firstGroup = allTemplateGroups.value[0]
if (
firstGroup.subcategories.length > 0 &&
firstGroup.subcategories[0].modules.length > 0
) {
const firstCategory = firstGroup.subcategories[0].modules[0]
selectTemplateCategory(firstCategory)
}
}
}
@@ -63,9 +69,9 @@ export function useTemplateWorkflows() {
index = ''
) => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
// sourceModule === 'default'
api.fileURL(`/templates/${template.name}-1`)
// : api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
@@ -106,16 +112,21 @@ export function useTemplateWorkflows() {
try {
// Handle "All" category as a special case
if (sourceModule === 'all') {
// Find "All" category in the ComfyUI Examples group
const comfyExamplesGroup = allTemplateGroups.value.find(
(g) =>
g.label ===
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
// Find "All" category in the USE CASES group
const useCasesGroup = allTemplateGroups.value.find(
(g) => g.label === t('templateWorkflows.group.useCases', 'USE CASES')
)
const allCategory = comfyExamplesGroup?.modules.find(
(m) => m.moduleName === 'all'
const allSubcategory = useCasesGroup?.subcategories.find(
(s) =>
s.label ===
t('templateWorkflows.subcategory.allTemplates', 'All Templates')
)
const allCategory = allSubcategory?.modules.find(
(m: WorkflowTemplates) => m.moduleName === 'all'
)
const template = allCategory?.templates.find(
(t: TemplateInfo) => t.name === id
)
const template = allCategory?.templates.find((t) => t.name === id)
if (!template || !template.sourceModule) return false

View File

@@ -25,6 +25,7 @@
"confirmed": "Confirmed",
"reset": "Reset",
"resetAll": "Reset All",
"clearFilters": "Clear Filters",
"resetAllKeybindingsTooltip": "Reset all keybindings to default",
"customizeFolder": "Customize Folder",
"icon": "Icon",
@@ -549,6 +550,17 @@
},
"templateWorkflows": {
"title": "Get Started with a Template",
"loadingMore": "Loading more templates...",
"searchPlaceholder": "Search templates...",
"modelFilter": "Model",
"sort": "Sort",
"sortRecommended": "Recommended",
"sortAlphabetical": "Alphabetical",
"sortNewest": "Newest",
"modelsSelected": "{count} models",
"allSubcategories": "All",
"tutorial": "Tutorial",
"useTemplate": "Use Template",
"category": {
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
@@ -566,6 +578,26 @@
"LLM API": "LLM API",
"All": "All Templates"
},
"group": {
"useCases": "USE CASES",
"toolsBuilding": "TOOLS & BUILDING",
"customCommunity": "CUSTOM & COMMUNITY"
},
"subcategory": {
"allTemplates": "All Templates",
"imageCreation": "Image Creation",
"videoAnimation": "Video & Animation",
"spatial": "3D & Spatial",
"audio": "Audio",
"advancedApis": "Advanced APIs",
"postProcessing": "Post-Processing & Utilities",
"customNodes": "Custom Nodes",
"communityPicks": "Community Picks"
},
"view": {
"allTemplates": "All Templates",
"recentItems": "Recent Items"
},
"templateDescription": {
"Basics": {
"default": "Generate images from text prompts.",

View File

@@ -272,6 +272,7 @@
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
"color": "Color",
"comingSoon": "Próximamente",
@@ -1231,6 +1232,8 @@
"Video": "Video",
"Video API": "API de Video"
},
"loadingMore": "Cargando más plantillas...",
"searchPlaceholder": "Buscar plantillas...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",

View File

@@ -272,6 +272,7 @@
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"clear": "Effacer",
"clearFilters": "Effacer les filtres",
"close": "Fermer",
"color": "Couleur",
"comingSoon": "Bientôt disponible",
@@ -1231,6 +1232,8 @@
"Video": "Vidéo",
"Video API": "API vidéo"
},
"loadingMore": "Chargement de plus de modèles...",
"searchPlaceholder": "Rechercher des modèles...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",

View File

@@ -272,6 +272,7 @@
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"clearFilters": "フィルターをクリア",
"close": "閉じる",
"color": "色",
"comingSoon": "近日公開",
@@ -1231,6 +1232,8 @@
"Video": "ビデオ",
"Video API": "動画API"
},
"loadingMore": "さらにテンプレートを読み込み中...",
"searchPlaceholder": "テンプレートを検索...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",

View File

@@ -272,6 +272,7 @@
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"clearFilters": "필터 지우기",
"close": "닫기",
"color": "색상",
"comingSoon": "곧 출시 예정",
@@ -1231,6 +1232,8 @@
"Video": "비디오",
"Video API": "비디오 API"
},
"loadingMore": "템플릿을 더 불러오는 중...",
"searchPlaceholder": "템플릿 검색...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",

View File

@@ -272,6 +272,7 @@
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
"clearFilters": "Сбросить фильтры",
"close": "Закрыть",
"color": "Цвет",
"comingSoon": "Скоро будет",
@@ -1231,6 +1232,8 @@
"Video": "Видео",
"Video API": "Video API"
},
"loadingMore": "Загрузка дополнительных шаблонов...",
"searchPlaceholder": "Поиск шаблонов...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",

View File

@@ -272,6 +272,7 @@
"category": "分類",
"choose_file_to_upload": "選擇要上傳的檔案",
"clear": "清除",
"clearFilters": "清除篩選",
"close": "關閉",
"color": "顏色",
"comingSoon": "即將推出",
@@ -1231,6 +1232,8 @@
"Video": "影片",
"Video API": "影片 API"
},
"loadingMore": "正在載入更多範本...",
"searchPlaceholder": "搜尋範本...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",

View File

@@ -272,6 +272,7 @@
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"clearFilters": "清除篩選",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
@@ -1231,6 +1232,8 @@
"Video": "视频生成",
"Video API": "视频 API"
},
"loadingMore": "正在載入更多範本...",
"searchPlaceholder": "搜尋範本...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "混元3D 2.0 图生模型",

View File

@@ -35,11 +35,13 @@ import type {
NodeId
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
interface QueuePromptRequestBody {
client_id: string
prompt: ComfyApiWorkflow
partial_execution_targets?: NodeExecutionId[]
extra_data: {
extra_pnginfo: {
workflow: ComfyWorkflowJSON
@@ -80,6 +82,18 @@ interface QueuePromptRequestBody {
number?: number
}
/**
* Options for queuePrompt method
*/
interface QueuePromptOptions {
/**
* Optional list of node execution IDs to execute (partial execution).
* Each ID represents a node's position in nested subgraphs.
* Format: Colon-separated path of node IDs (e.g., "123:456:789")
*/
partialExecutionTargets?: NodeExecutionId[]
}
/** Dictionary of Frontend-generated API calls */
interface FrontendApiCalls {
graphChanged: ComfyWorkflowJSON
@@ -610,18 +624,23 @@ export class ComfyApi extends EventTarget {
/**
* Queues a prompt to be executed
* @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
* @param {object} prompt The prompt data to queue
* @param {object} data The prompt data to queue
* @param {QueuePromptOptions} options Optional execution options
* @throws {PromptExecutionError} If the prompt fails to execute
*/
async queuePrompt(
number: number,
data: { output: ComfyApiWorkflow; workflow: ComfyWorkflowJSON }
data: { output: ComfyApiWorkflow; workflow: ComfyWorkflowJSON },
options?: QueuePromptOptions
): Promise<PromptResponse> {
const { output: prompt, workflow } = data
const body: QueuePromptRequestBody = {
client_id: this.clientId ?? '', // TODO: Unify clientId access
prompt,
...(options?.partialExecutionTargets && {
partial_execution_targets: options.partialExecutionTargets
}),
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,

View File

@@ -59,6 +59,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import {
@@ -127,7 +128,7 @@ export class ComfyApp {
#queueItems: {
number: number
batchCount: number
queueNodeIds?: NodeId[]
queueNodeIds?: NodeExecutionId[]
}[] = []
/**
* If the queue is currently being processed
@@ -1239,20 +1240,16 @@ export class ComfyApp {
})
}
async graphToPrompt(
graph = this.graph,
options: { queueNodeIds?: NodeId[] } = {}
) {
async graphToPrompt(graph = this.graph) {
return graphToPrompt(graph, {
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave'),
queueNodeIds: options.queueNodeIds
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
})
}
async queuePrompt(
number: number,
batchCount: number = 1,
queueNodeIds?: NodeId[]
queueNodeIds?: NodeExecutionId[]
): Promise<boolean> {
this.#queueItems.push({ number, batchCount, queueNodeIds })
@@ -1281,11 +1278,13 @@ export class ComfyApp {
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
}
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
const p = await this.graphToPrompt(this.graph)
try {
api.authToken = comfyOrgAuthToken
api.apiKey = comfyOrgApiKey ?? undefined
const res = await api.queuePrompt(number, p)
const res = await api.queuePrompt(number, p, {
partialExecutionTargets: queueNodeIds
})
delete api.authToken
delete api.apiKey
executionStore.lastNodeErrors = res.node_errors ?? null

View File

@@ -73,7 +73,8 @@ export class ChangeTracker {
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
}
const navigation = useSubgraphNavigationStore().exportState()
this.subgraphState = navigation.length ? { navigation } : undefined
// Always store the navigation state, even if empty (root level)
this.subgraphState = { navigation }
}
restore() {
@@ -90,8 +91,14 @@ export class ChangeTracker {
const activeId = navigation.at(-1)
if (activeId) {
// Navigate to the saved subgraph
const subgraph = app.graph.subgraphs.get(activeId)
if (subgraph) app.canvas.setGraph(subgraph)
if (subgraph) {
app.canvas.setGraph(subgraph)
}
} else {
// Empty navigation array means root level
app.canvas.setGraph(app.graph)
}
}
}

View File

@@ -0,0 +1,226 @@
import { reactive } from 'vue'
export interface CachedMedia {
src: string
blob?: Blob
objectUrl?: string
error?: boolean
isLoading: boolean
lastAccessed: number
}
export interface MediaCacheOptions {
maxSize?: number
maxAge?: number // in milliseconds
preloadDistance?: number // pixels from viewport
}
class MediaCacheService {
public cache = reactive(new Map<string, CachedMedia>())
private readonly maxSize: number
private readonly maxAge: number
private cleanupInterval: number | null = null
private urlRefCount = new Map<string, number>()
constructor(options: MediaCacheOptions = {}) {
this.maxSize = options.maxSize ?? 100
this.maxAge = options.maxAge ?? 30 * 60 * 1000 // 30 minutes
// Start cleanup interval
this.startCleanupInterval()
}
private startCleanupInterval() {
// Clean up every 5 minutes
this.cleanupInterval = window.setInterval(
() => {
this.cleanup()
},
5 * 60 * 1000
)
}
private cleanup() {
const now = Date.now()
const keysToDelete: string[] = []
// Find expired entries
for (const [key, entry] of Array.from(this.cache.entries())) {
if (now - entry.lastAccessed > this.maxAge) {
// Only revoke object URL if no components are using it
if (entry.objectUrl) {
const refCount = this.urlRefCount.get(entry.objectUrl) || 0
if (refCount === 0) {
URL.revokeObjectURL(entry.objectUrl)
this.urlRefCount.delete(entry.objectUrl)
keysToDelete.push(key)
}
// Don't delete cache entry if URL is still in use
} else {
keysToDelete.push(key)
}
}
}
// Remove expired entries
keysToDelete.forEach((key) => this.cache.delete(key))
// If still over size limit, remove oldest entries that aren't in use
if (this.cache.size > this.maxSize) {
const entries = Array.from(this.cache.entries())
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
let removedCount = 0
const targetRemoveCount = this.cache.size - this.maxSize
for (const [key, entry] of entries) {
if (removedCount >= targetRemoveCount) break
if (entry.objectUrl) {
const refCount = this.urlRefCount.get(entry.objectUrl) || 0
if (refCount === 0) {
URL.revokeObjectURL(entry.objectUrl)
this.urlRefCount.delete(entry.objectUrl)
this.cache.delete(key)
removedCount++
}
} else {
this.cache.delete(key)
removedCount++
}
}
}
}
async getCachedMedia(src: string): Promise<CachedMedia> {
let entry = this.cache.get(src)
if (entry) {
// Update last accessed time
entry.lastAccessed = Date.now()
return entry
}
// Create new entry
entry = {
src,
isLoading: true,
lastAccessed: Date.now()
}
// Update cache with loading entry
this.cache.set(src, entry)
try {
// Fetch the media
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
// Update entry with successful result
const updatedEntry: CachedMedia = {
src,
blob,
objectUrl,
isLoading: false,
lastAccessed: Date.now()
}
this.cache.set(src, updatedEntry)
return updatedEntry
} catch (error) {
console.warn('Failed to cache media:', src, error)
// Update entry with error
const errorEntry: CachedMedia = {
src,
error: true,
isLoading: false,
lastAccessed: Date.now()
}
this.cache.set(src, errorEntry)
return errorEntry
}
}
acquireUrl(src: string): string | undefined {
const entry = this.cache.get(src)
if (entry?.objectUrl) {
const currentCount = this.urlRefCount.get(entry.objectUrl) || 0
this.urlRefCount.set(entry.objectUrl, currentCount + 1)
return entry.objectUrl
}
return undefined
}
releaseUrl(src: string): void {
const entry = this.cache.get(src)
if (entry?.objectUrl) {
const count = (this.urlRefCount.get(entry.objectUrl) || 1) - 1
if (count <= 0) {
URL.revokeObjectURL(entry.objectUrl)
this.urlRefCount.delete(entry.objectUrl)
// Remove from cache as well
this.cache.delete(src)
} else {
this.urlRefCount.set(entry.objectUrl, count)
}
}
}
clearCache() {
// Revoke all object URLs
for (const entry of Array.from(this.cache.values())) {
if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl)
}
}
this.cache.clear()
this.urlRefCount.clear()
}
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.clearCache()
}
}
// Global instance
export let mediaCacheInstance: MediaCacheService | null = null
export function useMediaCache(options?: MediaCacheOptions) {
if (!mediaCacheInstance) {
mediaCacheInstance = new MediaCacheService(options)
}
const getCachedMedia = (src: string) =>
mediaCacheInstance!.getCachedMedia(src)
const clearCache = () => mediaCacheInstance!.clearCache()
const acquireUrl = (src: string) => mediaCacheInstance!.acquireUrl(src)
const releaseUrl = (src: string) => mediaCacheInstance!.releaseUrl(src)
return {
getCachedMedia,
clearCache,
acquireUrl,
releaseUrl,
cache: mediaCacheInstance.cache
}
}
// Cleanup on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
if (mediaCacheInstance) {
mediaCacheInstance.destroy()
}
})
}

View File

@@ -1,10 +1,14 @@
import QuickLRU from '@alloc/quick-lru'
import type { Subgraph } from '@comfyorg/litegraph'
import type { DragAndScaleState } from '@comfyorg/litegraph/dist/DragAndScale'
import { defineStore } from 'pinia'
import { computed, shallowReactive, shallowRef, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { app } from '@/scripts/app'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
import { isNonNullish } from '@/utils/typeGuardUtil'
import { useCanvasStore } from './graphStore'
import { useWorkflowStore } from './workflowStore'
/**
@@ -16,19 +20,27 @@ export const useSubgraphNavigationStore = defineStore(
'subgraphNavigation',
() => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
/** The currently opened subgraph. */
const activeSubgraph = shallowRef<Subgraph>()
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
const idStack = shallowReactive<string[]>([])
const idStack = ref<string[]>([])
/** LRU cache for viewport states. Key: subgraph ID or 'root' for root graph */
const viewportCache = new QuickLRU<string, DragAndScaleState>({
maxSize: 32
})
/**
* A stack representing subgraph navigation history from the root graph to
* the current opened subgraph.
*/
const navigationStack = computed(() =>
idStack.map((id) => app.graph.subgraphs.get(id)).filter(isNonNullish)
idStack.value
.map((id) => app.graph.subgraphs.get(id))
.filter(isNonNullish)
)
/**
@@ -37,8 +49,8 @@ export const useSubgraphNavigationStore = defineStore(
* @see exportState
*/
const restoreState = (subgraphIds: string[]) => {
idStack.length = 0
for (const id of subgraphIds) idStack.push(id)
idStack.value.length = 0
for (const id of subgraphIds) idStack.value.push(id)
}
/**
@@ -46,34 +58,93 @@ export const useSubgraphNavigationStore = defineStore(
* @returns The list of subgraph IDs, ending with the currently active subgraph.
* @see restoreState
*/
const exportState = () => [...idStack]
const exportState = () => [...idStack.value]
// Reset on workflow change
watch(
() => workflowStore.activeWorkflow,
() => (idStack.length = 0)
)
/**
* Get the current viewport state.
* @returns The current viewport state, or null if the canvas is not available.
*/
const getCurrentViewport = (): DragAndScaleState | null => {
const canvas = canvasStore.getCanvas()
if (!canvas) return null
// Update navigation stack when opened subgraph changes
return {
scale: canvas.ds.state.scale,
offset: [...canvas.ds.state.offset]
}
}
/**
* Save the current viewport state.
* @param graphId The graph ID to save for. Use 'root' for root graph, or omit to use current context.
*/
const saveViewport = (graphId: string) => {
const viewport = getCurrentViewport()
if (!viewport) return
viewportCache.set(graphId, viewport)
}
/**
* Restore viewport state for a graph.
* @param graphId The graph ID to restore. Use 'root' for root graph, or omit to use current context.
*/
const restoreViewport = (graphId: string) => {
const viewport = viewportCache.get(graphId)
if (!viewport) return
const canvas = app.canvas
if (!canvas) return
canvas.ds.scale = viewport.scale
canvas.ds.offset[0] = viewport.offset[0]
canvas.ds.offset[1] = viewport.offset[1]
canvas.setDirty(true, true)
}
/**
* Update the navigation stack when the active subgraph changes.
* @param subgraph The new active subgraph.
* @param prevSubgraph The previous active subgraph.
*/
const onNavigated = (
subgraph: Subgraph | undefined,
prevSubgraph: Subgraph | undefined
) => {
// Save viewport state for the graph we're leaving
if (prevSubgraph) {
// Leaving a subgraph
saveViewport(prevSubgraph.id)
} else if (!prevSubgraph && subgraph) {
// Leaving root graph to enter a subgraph
saveViewport('root')
}
const isInRootGraph = !subgraph
if (isInRootGraph) {
idStack.value.length = 0
restoreViewport('root')
return
}
const path = findSubgraphPathById(subgraph.rootGraph, subgraph.id)
const isInReachableSubgraph = !!path
if (isInReachableSubgraph) {
idStack.value = [...path]
} else {
// Treat as if opening a new subgraph
idStack.value = [subgraph.id]
}
// Always try to restore viewport for the target subgraph
restoreViewport(subgraph.id)
}
// Update navigation stack when opened subgraph changes (also triggers when switching workflows)
watch(
() => workflowStore.activeSubgraph,
(subgraph) => {
// Navigated back to the root graph
if (!subgraph) {
idStack.length = 0
return
}
const index = idStack.lastIndexOf(subgraph.id)
const lastIndex = idStack.length - 1
if (index === -1) {
// Opened a new subgraph
idStack.push(subgraph.id)
} else if (index !== lastIndex) {
// Navigated to a different subgraph
idStack.splice(index + 1, lastIndex - index)
}
(newValue, oldValue) => {
onNavigated(newValue, oldValue)
}
)
@@ -81,7 +152,10 @@ export const useSubgraphNavigationStore = defineStore(
activeSubgraph,
navigationStack,
restoreState,
exportState
exportState,
saveViewport,
restoreViewport,
viewportCache
}
}
)

View File

@@ -1,4 +1,3 @@
import { groupBy } from 'lodash'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
@@ -101,49 +100,6 @@ export const useWorkflowTemplatesStore = defineStore(
)
})
// Create an "All" category that combines all templates
const createAllCategory = () => {
// First, get core templates with source module added
const coreTemplatesWithSourceModule = coreTemplates.value.flatMap(
(category) =>
// For each template in each category, add the sourceModule and pass through any localized fields
category.templates.map((template) => {
// Get localized template with its original category title for i18n lookup
const localizedTemplate = addLocalizedFieldsToTemplate(
template,
category.title
)
return {
...localizedTemplate,
sourceModule: category.moduleName
}
})
)
// Now handle custom templates
const customTemplatesWithSourceModule = Object.entries(
customTemplates.value
).flatMap(([moduleName, templates]) =>
templates.map((name) => ({
name,
mediaType: 'image',
mediaSubtype: 'jpg',
description: name,
sourceModule: moduleName
}))
)
return {
moduleName: 'all',
title: 'All',
localizedTitle: st('templateWorkflows.category.All', 'All Templates'),
templates: [
...coreTemplatesWithSourceModule,
...customTemplatesWithSourceModule
]
}
}
const groupedTemplates = computed<TemplateGroup[]>(() => {
// Get regular categories
const allTemplates = [
@@ -168,32 +124,156 @@ export const useWorkflowTemplatesStore = defineStore(
)
]
// Group templates by their main category
const groupedByCategory = Object.entries(
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
)
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
)
).map(([label, modules]) => ({ label, modules }))
// Create subcategories based on template types and content
// Insert the "All" category at the top of the "ComfyUI Examples" group
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
(group) =>
group.label ===
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
// USE CASES SUBCATEGORIES
const imageCreationTemplates = allTemplates.filter((template) =>
['Basics', 'Flux', 'Image'].includes(template.title)
)
if (comfyExamplesGroupIndex !== -1) {
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
createAllCategory()
)
const videoAnimationTemplates = allTemplates.filter(
(template) => template.title === 'Video'
)
const spatialTemplates = allTemplates.filter((template) =>
['3D', 'Area Composition'].includes(template.title)
)
const audioTemplates = allTemplates.filter(
(template) => template.title === 'Audio'
)
// TOOLS & BUILDING SUBCATEGORIES
const advancedApisTemplates = allTemplates.filter((template) =>
['Image API', 'Video API', '3D API', 'LLM API'].includes(template.title)
)
const postProcessingTemplates = allTemplates.filter((template) =>
['Upscaling', 'ControlNet'].includes(template.title)
)
// CUSTOM & COMMUNITY SUBCATEGORIES
const customNodesTemplates = allTemplates.filter(
(template) =>
template.moduleName !== 'default' &&
!advancedApisTemplates.includes(template)
)
const communityPicksTemplates: WorkflowTemplates[] = [] // This could be populated with featured community content
const groups: TemplateGroup[] = []
// USE CASES GROUP
const useCasesSubcategories = []
if (imageCreationTemplates.length > 0) {
useCasesSubcategories.push({
label: st(
'templateWorkflows.subcategory.imageCreation',
'Image Creation'
),
modules: imageCreationTemplates
})
}
return groupedByCategory
if (videoAnimationTemplates.length > 0) {
useCasesSubcategories.push({
label: st(
'templateWorkflows.subcategory.videoAnimation',
'Video & Animation'
),
modules: videoAnimationTemplates
})
}
if (spatialTemplates.length > 0) {
useCasesSubcategories.push({
label: st('templateWorkflows.subcategory.spatial', '3D & Spatial'),
modules: spatialTemplates
})
}
if (audioTemplates.length > 0) {
useCasesSubcategories.push({
label: st('templateWorkflows.subcategory.audio', 'Audio'),
modules: audioTemplates
})
}
if (useCasesSubcategories.length > 0) {
groups.push({
label: st('templateWorkflows.group.useCases', 'USE CASES'),
subcategories: useCasesSubcategories
})
}
// TOOLS & BUILDING GROUP
const toolsBuildingSubcategories = []
if (advancedApisTemplates.length > 0) {
toolsBuildingSubcategories.push({
label: st(
'templateWorkflows.subcategory.advancedApis',
'Advanced APIs'
),
modules: advancedApisTemplates
})
}
if (postProcessingTemplates.length > 0) {
toolsBuildingSubcategories.push({
label: st(
'templateWorkflows.subcategory.postProcessing',
'Post-Processing & Utilities'
),
modules: postProcessingTemplates
})
}
if (toolsBuildingSubcategories.length > 0) {
groups.push({
label: st(
'templateWorkflows.group.toolsBuilding',
'TOOLS & BUILDING'
),
subcategories: toolsBuildingSubcategories
})
}
// CUSTOM & COMMUNITY GROUP
const customCommunitySubcategories = []
if (customNodesTemplates.length > 0) {
customCommunitySubcategories.push({
label: st(
'templateWorkflows.subcategory.customNodes',
'Custom Nodes'
),
modules: customNodesTemplates
})
}
if (communityPicksTemplates.length > 0) {
customCommunitySubcategories.push({
label: st(
'templateWorkflows.subcategory.communityPicks',
'Community Picks'
),
modules: communityPicksTemplates
})
}
if (customCommunitySubcategories.length > 0) {
groups.push({
label: st(
'templateWorkflows.group.customCommunity',
'CUSTOM & COMMUNITY'
),
subcategories: customCommunitySubcategories
})
}
return groups
})
async function loadWorkflowTemplates() {

View File

@@ -12,16 +12,25 @@ export interface TemplateInfo {
localizedTitle?: string
localizedDescription?: string
sourceModule?: string
tags?: string[]
models?: string[]
date?: string
}
export interface WorkflowTemplates {
moduleName: string
templates: TemplateInfo[]
title: string
localizedTitle?: string
}
export interface TemplateSubcategory {
label: string
modules: WorkflowTemplates[]
}
export interface TemplateGroup {
label: string
icon?: string
modules: WorkflowTemplates[]
subcategories: TemplateSubcategory[]
}

View File

@@ -1,8 +1,7 @@
import type {
ExecutableLGraphNode,
ExecutionId,
LGraph,
NodeId
LGraph
} from '@comfyorg/litegraph'
import {
ExecutableNodeDTO,
@@ -18,31 +17,6 @@ import type {
import { ExecutableGroupNodeDTO, isGroupNode } from './executableGroupNodeDto'
import { compressWidgetInputSlots } from './litegraphUtil'
/**
* Recursively target node's parent nodes to the new output.
* @param nodeId The node id to add.
* @param oldOutput The old output.
* @param newOutput The new output.
* @returns The new output.
*/
function recursiveAddNodes(
nodeId: NodeId,
oldOutput: ComfyApiWorkflow,
newOutput: ComfyApiWorkflow
) {
const currentId = String(nodeId)
const currentNode = oldOutput[currentId]!
if (newOutput[currentId] == null) {
newOutput[currentId] = currentNode
for (const inputValue of Object.values(currentNode.inputs || [])) {
if (Array.isArray(inputValue)) {
recursiveAddNodes(inputValue[0], oldOutput, newOutput)
}
}
}
return newOutput
}
/**
* Converts the current graph workflow for sending to the API.
* @note Node widgets are updated before serialization to prepare queueing.
@@ -50,14 +24,13 @@ function recursiveAddNodes(
* @param graph The graph to convert.
* @param options The options for the conversion.
* - `sortNodes`: Whether to sort the nodes by execution order.
* - `queueNodeIds`: The output nodes to execute. Execute all output nodes if not provided.
* @returns The workflow and node links
*/
export const graphToPrompt = async (
graph: LGraph,
options: { sortNodes?: boolean; queueNodeIds?: NodeId[] } = {}
options: { sortNodes?: boolean } = {}
): Promise<{ workflow: ComfyWorkflowJSON; output: ComfyApiWorkflow }> => {
const { sortNodes = false, queueNodeIds } = options
const { sortNodes = false } = options
for (const node of graph.computeExecutionOrder(false)) {
const innerNodes = node.getInnerNodes
@@ -104,7 +77,7 @@ export const graphToPrompt = async (
nodeDtoMap.set(dto.id, dto)
}
let output: ComfyApiWorkflow = {}
const output: ComfyApiWorkflow = {}
// Process nodes in order of execution
for (const node of nodeDtoMap.values()) {
// Don't serialize muted nodes
@@ -180,14 +153,5 @@ export const graphToPrompt = async (
}
}
// Partial execution
if (queueNodeIds?.length) {
const newOutput = {}
for (const queueNodeId of queueNodeIds) {
recursiveAddNodes(queueNodeId, output, newOutput)
}
output = newOutput
}
return { workflow: workflow as ComfyWorkflowJSON, output }
}

View File

@@ -1,6 +1,6 @@
import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraphIoNode } from './typeGuardUtil'
@@ -205,7 +205,7 @@ export function findSubgraphByUuid(
targetUuid: string
): Subgraph | null {
// Check all nodes in the current graph
for (const node of graph._nodes) {
for (const node of graph.nodes) {
if (node.isSubgraphNode?.() && node.subgraph) {
if (node.subgraph.id === targetUuid) {
return node.subgraph
@@ -218,6 +218,42 @@ export function findSubgraphByUuid(
return null
}
/**
* Iteratively finds the path of subgraph IDs to a target subgraph.
* @param rootGraph The graph to start searching from.
* @param targetId The ID of the subgraph to find.
* @returns An array of subgraph IDs representing the path, or `null` if not found.
*/
export function findSubgraphPathById(
rootGraph: LGraph,
targetId: string
): string[] | null {
const stack: { graph: LGraph | Subgraph; path: string[] }[] = [
{ graph: rootGraph, path: [] }
]
while (stack.length > 0) {
const { graph, path } = stack.pop()!
// Check if graph exists and has _nodes property
if (!graph || !graph._nodes || !Array.isArray(graph._nodes)) {
continue
}
for (const node of graph._nodes) {
if (node.isSubgraphNode?.() && node.subgraph) {
const newPath = [...path, String(node.subgraph.id)]
if (node.subgraph.id === targetId) {
return newPath
}
stack.push({ graph: node.subgraph, path: newPath })
}
}
}
return null
}
/**
* Get a node by its execution ID from anywhere in the graph hierarchy.
* Execution IDs use hierarchical format like "123:456:789" for nested nodes.
@@ -351,3 +387,106 @@ export function mapSubgraphNodes<T>(
export function getAllNonIoNodesInSubgraph(subgraph: Subgraph): LGraphNode[] {
return subgraph.nodes.filter((node) => !isSubgraphIoNode(node))
}
/**
* Performs depth-first traversal of nodes and their subgraphs.
* Generic visitor pattern that can be used for various node processing tasks.
*
* @param nodes - Starting nodes for traversal
* @param visitor - Function called for each node with its context
* @param expandSubgraphs - Whether to traverse into subgraph nodes (default: true)
*/
export function traverseNodesDepthFirst<T>(
nodes: LGraphNode[],
visitor: (node: LGraphNode, context: T) => T,
initialContext: T,
expandSubgraphs: boolean = true
): void {
type StackItem = { node: LGraphNode; context: T }
const stack: StackItem[] = []
// Initialize stack with starting nodes
for (const node of nodes) {
stack.push({ node, context: initialContext })
}
// Process stack iteratively (DFS)
while (stack.length > 0) {
const { node, context } = stack.pop()!
// Visit node and get updated context for children
const childContext = visitor(node, context)
// If it's a subgraph and we should expand, add children to stack
if (expandSubgraphs && node.isSubgraphNode?.() && node.subgraph) {
// Process children in reverse order to maintain left-to-right DFS processing
// when popping from stack (LIFO). Iterate backwards to avoid array reversal.
const children = node.subgraph.nodes
for (let i = children.length - 1; i >= 0; i--) {
stack.push({ node: children[i], context: childContext })
}
}
}
}
/**
* Collects nodes with custom data during depth-first traversal.
* Generic collector that can gather any type of data per node.
*
* @param nodes - Starting nodes for traversal
* @param collector - Function that returns data to collect for each node
* @param contextBuilder - Function that builds context for child nodes
* @param expandSubgraphs - Whether to traverse into subgraph nodes
* @returns Array of collected data
*/
export function collectFromNodes<T, C>(
nodes: LGraphNode[],
collector: (node: LGraphNode, context: C) => T | null,
contextBuilder: (node: LGraphNode, parentContext: C) => C,
initialContext: C,
expandSubgraphs: boolean = true
): T[] {
const results: T[] = []
traverseNodesDepthFirst(
nodes,
(node, context) => {
const data = collector(node, context)
if (data !== null) {
results.push(data)
}
return contextBuilder(node, context)
},
initialContext,
expandSubgraphs
)
return results
}
/**
* Collects execution IDs for selected nodes and all their descendants.
* Uses the generic DFS traversal with optimized string building.
*
* @param selectedNodes - The selected nodes to process
* @returns Array of execution IDs for selected nodes and all nodes within selected subgraphs
*/
export function getExecutionIdsForSelectedNodes(
selectedNodes: LGraphNode[]
): NodeExecutionId[] {
return collectFromNodes(
selectedNodes,
// Collector: build execution ID for each node
(node, parentExecutionId: string): NodeExecutionId => {
const nodeId = String(node.id)
return parentExecutionId ? `${parentExecutionId}:${nodeId}` : nodeId
},
// Context builder: pass execution ID to children
(node, parentExecutionId: string) => {
const nodeId = String(node.id)
return parentExecutionId ? `${parentExecutionId}:${nodeId}` : nodeId
},
'', // Initial context: empty parent execution ID
true // Expand subgraphs
)
}

View File

@@ -0,0 +1,21 @@
import type { LGraphNode } from '@comfyorg/litegraph'
/**
* Checks if a node is an output node.
* Output nodes are nodes that have the output_node flag set in their nodeData.
*
* @param node - The node to check
* @returns True if the node is an output node, false otherwise
*/
export const isOutputNode = (node: LGraphNode) =>
node.constructor.nodeData?.output_node
/**
* Filters nodes to find only output nodes.
* Output nodes are nodes that have the output_node flag set in their nodeData.
*
* @param nodes - Array of nodes to filter
* @returns Array of output nodes only
*/
export const filterOutputNodes = (nodes: LGraphNode[]): LGraphNode[] =>
nodes.filter(isOutputNode)

View File

@@ -0,0 +1,216 @@
import { Positionable, Reroute } from '@comfyorg/litegraph'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useCanvasStore } from '@/stores/graphStore'
// Mock the litegraph module
vi.mock('@comfyorg/litegraph', () => ({
Reroute: class Reroute {
constructor() {}
}
}))
// Mock Positionable objects
// @ts-expect-error - Mock implementation for testing
class MockNode implements Positionable {
pos: [number, number]
size: [number, number]
constructor(
pos: [number, number] = [0, 0],
size: [number, number] = [100, 100]
) {
this.pos = pos
this.size = size
}
}
class MockReroute extends Reroute implements Positionable {
// @ts-expect-error - Override for testing
override pos: [number, number]
size: [number, number]
constructor(
pos: [number, number] = [0, 0],
size: [number, number] = [20, 20]
) {
// @ts-expect-error - Mock constructor
super()
this.pos = pos
this.size = size
}
}
describe('useSelectedLiteGraphItems', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let mockCanvas: any
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
// Mock canvas with selectedItems Set
mockCanvas = {
selectedItems: new Set<Positionable>()
}
// Mock getCanvas to return our mock canvas
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
})
describe('isIgnoredItem', () => {
it('should return true for Reroute instances', () => {
const { isIgnoredItem } = useSelectedLiteGraphItems()
const reroute = new MockReroute()
expect(isIgnoredItem(reroute)).toBe(true)
})
it('should return false for non-Reroute items', () => {
const { isIgnoredItem } = useSelectedLiteGraphItems()
const node = new MockNode()
// @ts-expect-error - Test mock
expect(isIgnoredItem(node)).toBe(false)
})
})
describe('filterSelectableItems', () => {
it('should filter out Reroute items', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode([0, 0])
const node2 = new MockNode([100, 100])
const reroute = new MockReroute([50, 50])
// @ts-expect-error - Test mocks
const items = new Set<Positionable>([node1, node2, reroute])
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(2)
// @ts-expect-error - Test mocks
expect(filtered.has(node1)).toBe(true)
// @ts-expect-error - Test mocks
expect(filtered.has(node2)).toBe(true)
expect(filtered.has(reroute)).toBe(false)
})
it('should return empty set when all items are ignored', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const reroute1 = new MockReroute([0, 0])
const reroute2 = new MockReroute([50, 50])
const items = new Set<Positionable>([reroute1, reroute2])
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(0)
})
it('should handle empty set', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const items = new Set<Positionable>()
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(0)
})
})
describe('methods', () => {
it('getSelectableItems should return only non-ignored items', () => {
const { getSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
const reroute = new MockReroute()
mockCanvas.selectedItems.add(node1)
mockCanvas.selectedItems.add(node2)
mockCanvas.selectedItems.add(reroute)
const selectableItems = getSelectableItems()
expect(selectableItems.size).toBe(2)
// @ts-expect-error - Test mock
expect(selectableItems.has(node1)).toBe(true)
// @ts-expect-error - Test mock
expect(selectableItems.has(node2)).toBe(true)
expect(selectableItems.has(reroute)).toBe(false)
})
it('hasSelectableItems should be true when there are selectable items', () => {
const { hasSelectableItems } = useSelectedLiteGraphItems()
const node = new MockNode()
expect(hasSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node)
expect(hasSelectableItems()).toBe(true)
})
it('hasSelectableItems should be false when only ignored items are selected', () => {
const { hasSelectableItems } = useSelectedLiteGraphItems()
const reroute = new MockReroute()
mockCanvas.selectedItems.add(reroute)
expect(hasSelectableItems()).toBe(false)
})
it('hasMultipleSelectableItems should be true when there are 2+ selectable items', () => {
const { hasMultipleSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
expect(hasMultipleSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node1)
expect(hasMultipleSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node2)
expect(hasMultipleSelectableItems()).toBe(true)
})
it('hasMultipleSelectableItems should not count ignored items', () => {
const { hasMultipleSelectableItems } = useSelectedLiteGraphItems()
const node = new MockNode()
const reroute1 = new MockReroute()
const reroute2 = new MockReroute()
mockCanvas.selectedItems.add(node)
mockCanvas.selectedItems.add(reroute1)
mockCanvas.selectedItems.add(reroute2)
// Even though there are 3 items total, only 1 is selectable
expect(hasMultipleSelectableItems()).toBe(false)
})
})
describe('dynamic behavior', () => {
it('methods should reflect changes when selectedItems change', () => {
const {
getSelectableItems,
hasSelectableItems,
hasMultipleSelectableItems
} = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
expect(hasSelectableItems()).toBe(false)
expect(hasMultipleSelectableItems()).toBe(false)
// Add first node
mockCanvas.selectedItems.add(node1)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(false)
expect(getSelectableItems().size).toBe(1)
// Add second node
mockCanvas.selectedItems.add(node2)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(true)
expect(getSelectableItems().size).toBe(2)
// Remove a node
mockCanvas.selectedItems.delete(node1)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(false)
expect(getSelectableItems().size).toBe(1)
})
})
})

View File

@@ -112,13 +112,36 @@ vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: {
light_theme: false
}
}))
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
removeEventListener: vi.fn(),
apiURL: vi.fn().mockReturnValue('http://localhost:8188')
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: mockGraph
}
}
}))
vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeSubgraph: null
}))
}))
const { useMinimap } = await import('@/composables/useMinimap')
const { api } = await import('@/scripts/api')
@@ -459,7 +482,9 @@ describe('useMinimap', () => {
expect(minimap.initialized.value).toBe(true)
expect(mockContext2D.fillRect).not.toHaveBeenCalled()
// With the new reactive system, the minimap may still render some elements
// The key test is that it doesn't crash and properly initializes
expect(mockContext2D.clearRect).toHaveBeenCalled()
mockGraph._nodes = originalNodes
})
@@ -736,7 +761,7 @@ describe('useMinimap', () => {
})
describe('container styles', () => {
it('should provide correct container styles', () => {
it('should provide correct container styles for dark theme', () => {
const minimap = useMinimap()
const styles = minimap.containerStyles.value

View File

@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { useMediaCache } from '../../../src/services/mediaCacheService'
// Mock fetch
global.fetch = vi.fn()
global.URL = {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn()
} as any
describe('mediaCacheService', () => {
describe('URL reference counting', () => {
it('should handle URL acquisition for non-existent cache entry', () => {
const { acquireUrl } = useMediaCache()
const url = acquireUrl('non-existent.jpg')
expect(url).toBeUndefined()
})
it('should handle URL release for non-existent cache entry', () => {
const { releaseUrl } = useMediaCache()
// Should not throw error
expect(() => releaseUrl('non-existent.jpg')).not.toThrow()
})
it('should provide acquireUrl and releaseUrl methods', () => {
const cache = useMediaCache()
expect(typeof cache.acquireUrl).toBe('function')
expect(typeof cache.releaseUrl).toBe('function')
})
})
})

View File

@@ -2,20 +2,46 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflow } from '@/stores/workflowStore'
vi.mock('@/scripts/app', () => ({
app: {
graph: {
subgraphs: new Map(),
getNodeById: vi.fn()
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: null,
ds: {
scale: 1,
offset: [0, 0],
state: {
scale: 1,
offset: [0, 0]
}
},
canvas: {
subgraph: null
setDirty: vi.fn()
}
return {
app: {
graph: {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
},
canvas: mockCanvas
}
}
})
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: () => ({
getCanvas: () => (app as any).canvas
})
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
findSubgraphPathById: vi.fn()
}))
describe('useSubgraphNavigationStore', () => {
@@ -51,42 +77,77 @@ describe('useSubgraphNavigationStore', () => {
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
})
it('should clear navigation stack when switching to a different workflow', async () => {
it('should preserve navigation stack per workflow', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock first workflow
const workflow1 = {
path: 'workflow1.json',
filename: 'workflow1.json'
} as ComfyWorkflow
filename: 'workflow1.json',
changeTracker: {
restore: vi.fn(),
store: vi.fn()
}
} as unknown as ComfyWorkflow
// Set the active workflow
workflowStore.activeWorkflow = workflow1 as any
// Simulate being in a subgraph
// Simulate the restore process that happens when loading a workflow
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
// Verify navigation was set
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
// Switch to a different workflow
// Switch to a different workflow with no subgraph state (root level)
const workflow2 = {
path: 'workflow2.json',
filename: 'workflow2.json'
} as ComfyWorkflow
filename: 'workflow2.json',
changeTracker: {
restore: vi.fn(),
store: vi.fn()
}
} as unknown as ComfyWorkflow
workflowStore.activeWorkflow = workflow2 as any
// Wait for Vue's reactivity to process the change
await nextTick()
// Simulate the restore process for workflow2
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState([])
// The navigation stack SHOULD be cleared because we switched workflows
// The navigation stack should be empty for workflow2 (at root level)
expect(navigationStore.exportState()).toHaveLength(0)
// Switch back to workflow1
workflowStore.activeWorkflow = workflow1 as any
// Simulate the restore process for workflow1 again
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
// The navigation stack should be restored for workflow1
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
})
it('should handle null workflow gracefully', async () => {
it('should clear navigation when activeSubgraph becomes undefined', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
// Create mock subgraph and graph structure
const mockSubgraph = {
id: 'subgraph-1',
rootGraph: (app as any).graph,
_nodes: [],
nodes: []
}
// Add the subgraph to the graph's subgraphs map
;(app as any).graph.subgraphs.set('subgraph-1', mockSubgraph)
// First set an active workflow
const mockWorkflow = {
@@ -95,19 +156,29 @@ describe('useSubgraphNavigationStore', () => {
} as ComfyWorkflow
workflowStore.activeWorkflow = mockWorkflow as any
await nextTick()
// Add some items to the navigation stack
navigationStore.restoreState(['subgraph-1'])
expect(navigationStore.exportState()).toHaveLength(1)
// Mock findSubgraphPathById to return the correct path
vi.mocked(findSubgraphPathById).mockReturnValue(['subgraph-1'])
// Set workflow to null
workflowStore.activeWorkflow = null
// Set canvas.subgraph and trigger update to set activeSubgraph
;(app as any).canvas.subgraph = mockSubgraph
workflowStore.updateActiveGraph()
// Wait for Vue's reactivity to process the change
await nextTick()
// Stack should be cleared when workflow becomes null
// Verify navigation was set by the watcher
expect(navigationStore.exportState()).toHaveLength(1)
expect(navigationStore.exportState()).toEqual(['subgraph-1'])
// Clear canvas.subgraph and trigger update (simulating navigating back to root)
;(app as any).canvas.subgraph = null
workflowStore.updateActiveGraph()
// Wait for Vue's reactivity to process the change
await nextTick()
// Stack should be cleared when activeSubgraph becomes undefined
expect(navigationStore.exportState()).toHaveLength(0)
})
})

View File

@@ -0,0 +1,254 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflow } from '@/stores/workflowStore'
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: null,
ds: {
scale: 1,
offset: [0, 0],
state: {
scale: 1,
offset: [0, 0]
}
},
setDirty: vi.fn()
}
return {
app: {
graph: {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
},
canvas: mockCanvas
}
}
})
// Mock canvasStore
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: () => ({
getCanvas: () => (app as any).canvas
})
}))
// Get reference to mock canvas
const mockCanvas = app.canvas as any
describe('useSubgraphNavigationStore - Viewport Persistence', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Reset canvas state
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.state.scale = 1
mockCanvas.ds.state.offset = [0, 0]
mockCanvas.setDirty.mockClear()
})
describe('saveViewport', () => {
it('should save viewport state for root graph', () => {
const navigationStore = useSubgraphNavigationStore()
// Set viewport state
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [100, 200]
// Save viewport for root
navigationStore.saveViewport('root')
// Check it was saved
const saved = navigationStore.viewportCache.get('root')
expect(saved).toEqual({
scale: 2,
offset: [100, 200]
})
})
it('should save viewport state for subgraph', () => {
const navigationStore = useSubgraphNavigationStore()
// Set viewport state
mockCanvas.ds.state.scale = 1.5
mockCanvas.ds.state.offset = [50, 75]
// Save viewport for subgraph
navigationStore.saveViewport('subgraph-123')
// Check it was saved
const saved = navigationStore.viewportCache.get('subgraph-123')
expect(saved).toEqual({
scale: 1.5,
offset: [50, 75]
})
})
it('should save viewport for current context when no ID provided', () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock being in a subgraph
const mockSubgraph = { id: 'sub-456' }
workflowStore.activeSubgraph = mockSubgraph as any
// Set viewport state
mockCanvas.ds.state.scale = 3
mockCanvas.ds.state.offset = [10, 20]
// Save viewport without ID (should default to root since activeSubgraph is not tracked by navigation store)
navigationStore.saveViewport('sub-456')
// Should save for the specified subgraph
const saved = navigationStore.viewportCache.get('sub-456')
expect(saved).toEqual({
scale: 3,
offset: [10, 20]
})
})
})
describe('restoreViewport', () => {
it('should restore viewport state for root graph', () => {
const navigationStore = useSubgraphNavigationStore()
// Save a viewport state
navigationStore.viewportCache.set('root', {
scale: 2.5,
offset: [150, 250]
})
// Restore it
navigationStore.restoreViewport('root')
// Check canvas was updated
expect(mockCanvas.ds.scale).toBe(2.5)
expect(mockCanvas.ds.offset).toEqual([150, 250])
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should restore viewport state for subgraph', () => {
const navigationStore = useSubgraphNavigationStore()
// Save a viewport state
navigationStore.viewportCache.set('sub-789', {
scale: 0.75,
offset: [-50, -100]
})
// Restore it
navigationStore.restoreViewport('sub-789')
// Check canvas was updated
expect(mockCanvas.ds.scale).toBe(0.75)
expect(mockCanvas.ds.offset).toEqual([-50, -100])
})
it('should do nothing if no saved viewport exists', () => {
const navigationStore = useSubgraphNavigationStore()
// Reset canvas
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.setDirty.mockClear()
// Try to restore non-existent viewport
navigationStore.restoreViewport('non-existent')
// Canvas should not change
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
})
describe('navigation integration', () => {
it('should save and restore viewport when navigating between subgraphs', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Create mock subgraph with both _nodes and nodes properties
const mockRootGraph = {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
}
const subgraph1 = {
id: 'sub1',
rootGraph: mockRootGraph,
_nodes: [],
nodes: []
}
// Start at root with custom viewport
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [100, 100]
// Navigate to subgraph
workflowStore.activeSubgraph = subgraph1 as any
await nextTick()
// Root viewport should have been saved automatically
const rootViewport = navigationStore.viewportCache.get('root')
expect(rootViewport).toBeDefined()
expect(rootViewport?.scale).toBe(2)
expect(rootViewport?.offset).toEqual([100, 100])
// Change viewport in subgraph
mockCanvas.ds.state.scale = 0.5
mockCanvas.ds.state.offset = [-50, -50]
// Navigate back to root
workflowStore.activeSubgraph = undefined
await nextTick()
// Subgraph viewport should have been saved automatically
const sub1Viewport = navigationStore.viewportCache.get('sub1')
expect(sub1Viewport).toBeDefined()
expect(sub1Viewport?.scale).toBe(0.5)
expect(sub1Viewport?.offset).toEqual([-50, -50])
// Root viewport should be restored automatically
expect(mockCanvas.ds.scale).toBe(2)
expect(mockCanvas.ds.offset).toEqual([100, 100])
})
it('should preserve viewport cache when switching workflows', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Add some viewport states
navigationStore.viewportCache.set('root', { scale: 2, offset: [0, 0] })
navigationStore.viewportCache.set('sub1', {
scale: 1.5,
offset: [10, 10]
})
expect(navigationStore.viewportCache.size).toBe(2)
// Switch workflows
const workflow1 = { path: 'workflow1.json' } as ComfyWorkflow
const workflow2 = { path: 'workflow2.json' } as ComfyWorkflow
workflowStore.activeWorkflow = workflow1 as any
await nextTick()
workflowStore.activeWorkflow = workflow2 as any
await nextTick()
// Cache should be preserved (LRU will manage memory)
expect(navigationStore.viewportCache.size).toBe(2)
expect(navigationStore.viewportCache.has('root')).toBe(true)
expect(navigationStore.viewportCache.has('sub1')).toBe(true)
})
})
})

View File

@@ -597,7 +597,9 @@ describe('useWorkflowStore', () => {
// Setup mock graph structure with subgraphs
const mockSubgraph = {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
_nodes: []
rootGraph: null as any,
_nodes: [],
nodes: []
}
const mockNode = {
@@ -608,6 +610,7 @@ describe('useWorkflowStore', () => {
const mockRootGraph = {
_nodes: [mockNode],
nodes: [mockNode],
subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]),
getNodeById: (id: string | number) => {
if (String(id) === '123') return mockNode
@@ -615,6 +618,8 @@ describe('useWorkflowStore', () => {
}
}
mockSubgraph.rootGraph = mockRootGraph as any
vi.mocked(comfyApp).graph = mockRootGraph as any
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
store.activeSubgraph = mockSubgraph as any

View File

@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from 'vitest'
import {
collectAllNodes,
collectFromNodes,
findNodeInHierarchy,
findSubgraphByUuid,
forEachNode,
forEachSubgraphNode,
getAllNonIoNodesInSubgraph,
getExecutionIdsForSelectedNodes,
getLocalNodeIdFromExecutionId,
getNodeByExecutionId,
getNodeByLocatorId,
@@ -16,6 +18,7 @@ import {
mapAllNodes,
mapSubgraphNodes,
parseExecutionId,
traverseNodesDepthFirst,
traverseSubgraphPath,
triggerCallbackOnAllNodes,
visitGraphNodes
@@ -807,5 +810,378 @@ describe('graphTraversalUtil', () => {
expect(nonIoNodes).toHaveLength(0)
})
})
describe('traverseNodesDepthFirst', () => {
it('should traverse nodes in depth-first order', () => {
const visited: string[] = []
const nodes = [
createMockNode('1'),
createMockNode('2'),
createMockNode('3')
]
traverseNodesDepthFirst(
nodes,
(node, context) => {
visited.push(`${node.id}:${context}`)
return `${context}-${node.id}`
},
'root'
)
expect(visited).toEqual(['3:root', '2:root', '1:root']) // DFS processes in LIFO order
})
it('should traverse into subgraphs when expandSubgraphs is true', () => {
const visited: string[] = []
const subNode = createMockNode('sub1')
const subgraph = createMockSubgraph('sub-uuid', [subNode])
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
traverseNodesDepthFirst(
nodes,
(node, depth: number) => {
visited.push(`${node.id}:${depth}`)
return depth + 1
},
0
)
expect(visited).toEqual(['2:0', 'sub1:1', '1:0']) // DFS: last node first, then its children
})
it('should skip subgraphs when expandSubgraphs is false', () => {
const visited: string[] = []
const subNode = createMockNode('sub1')
const subgraph = createMockSubgraph('sub-uuid', [subNode])
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
traverseNodesDepthFirst(
nodes,
(node, context) => {
visited.push(String(node.id))
return context
},
null,
false
)
expect(visited).toEqual(['2', '1']) // DFS processes in LIFO order
expect(visited).not.toContain('sub1')
})
it('should handle deeply nested subgraphs', () => {
const visited: string[] = []
const deepNode = createMockNode('300')
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
const midNode = createMockNode('200', {
isSubgraph: true,
subgraph: deepSubgraph
})
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
const topNode = createMockNode('100', {
isSubgraph: true,
subgraph: midSubgraph
})
traverseNodesDepthFirst(
[topNode],
(node, path: string) => {
visited.push(`${node.id}:${path}`)
return path ? `${path}/${node.id}` : String(node.id)
},
''
)
expect(visited).toEqual(['100:', '200:100', '300:100/200'])
})
})
describe('collectFromNodes', () => {
it('should collect data from all nodes', () => {
const nodes = [
createMockNode('1'),
createMockNode('2'),
createMockNode('3')
]
const results = collectFromNodes(
nodes,
(node) => `node-${node.id}`,
(_node, context) => context,
null
)
expect(results).toEqual(['node-3', 'node-2', 'node-1']) // DFS processes in LIFO order
})
it('should filter out null results', () => {
const nodes = [
createMockNode('1'),
createMockNode('2'),
createMockNode('3')
]
const results = collectFromNodes(
nodes,
(node) => (Number(node.id) > 1 ? `node-${node.id}` : null),
(_node, context) => context,
null
)
expect(results).toEqual(['node-3', 'node-2']) // DFS processes in LIFO order, node-1 filtered out
})
it('should collect from subgraphs with context', () => {
const subNodes = [createMockNode('10'), createMockNode('11')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
const results = collectFromNodes(
nodes,
(node, prefix: string) => `${prefix}${node.id}`,
(node, prefix: string) => `${prefix}${node.id}-`,
'node-',
true
)
expect(results).toEqual([
'node-2',
'node-2-10', // Actually processes in original order within subgraph
'node-2-11',
'node-1'
])
})
it('should not expand subgraphs when expandSubgraphs is false', () => {
const subNodes = [createMockNode('10'), createMockNode('11')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
const results = collectFromNodes(
nodes,
(node) => String(node.id),
(_node, context) => context,
null,
false
)
expect(results).toEqual(['2', '1']) // DFS processes in LIFO order
})
})
describe('getExecutionIdsForSelectedNodes', () => {
it('should return simple IDs for top-level nodes', () => {
const nodes = [
createMockNode('123'),
createMockNode('456'),
createMockNode('789')
]
const executionIds = getExecutionIdsForSelectedNodes(nodes)
expect(executionIds).toEqual(['789', '456', '123']) // DFS processes in LIFO order
})
it('should expand subgraph nodes to include all children', () => {
const subNodes = [createMockNode('10'), createMockNode('11')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
const executionIds = getExecutionIdsForSelectedNodes(nodes)
expect(executionIds).toEqual(['2', '2:10', '2:11', '1']) // DFS: node 2 first, then its children
})
it('should handle deeply nested subgraphs correctly', () => {
const deepNodes = [createMockNode('30'), createMockNode('31')]
const deepSubgraph = createMockSubgraph('deep-uuid', deepNodes)
const midNode = createMockNode('20', {
isSubgraph: true,
subgraph: deepSubgraph
})
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
const topNode = createMockNode('10', {
isSubgraph: true,
subgraph: midSubgraph
})
const executionIds = getExecutionIdsForSelectedNodes([topNode])
expect(executionIds).toEqual(['10', '10:20', '10:20:30', '10:20:31'])
})
it('should handle mixed selection of regular and subgraph nodes', () => {
const subNodes = [createMockNode('100'), createMockNode('101')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph }),
createMockNode('3')
]
const executionIds = getExecutionIdsForSelectedNodes(nodes)
expect(executionIds).toEqual([
'3',
'2',
'2:100', // Subgraph children in original order
'2:101',
'1'
])
})
it('should handle empty selection', () => {
const executionIds = getExecutionIdsForSelectedNodes([])
expect(executionIds).toEqual([])
})
it('should handle subgraph with no children', () => {
const emptySubgraph = createMockSubgraph('empty-uuid', [])
const node = createMockNode('1', {
isSubgraph: true,
subgraph: emptySubgraph
})
const executionIds = getExecutionIdsForSelectedNodes([node])
expect(executionIds).toEqual(['1'])
})
it('should handle nodes with very long execution paths', () => {
// Create a chain of 10 nested subgraphs
let currentSubgraph = createMockSubgraph('deep-10', [
createMockNode('10')
])
for (let i = 9; i >= 1; i--) {
const node = createMockNode(`${i}0`, {
isSubgraph: true,
subgraph: currentSubgraph
})
currentSubgraph = createMockSubgraph(`deep-${i}`, [node])
}
const topNode = createMockNode('1', {
isSubgraph: true,
subgraph: currentSubgraph
})
const executionIds = getExecutionIdsForSelectedNodes([topNode])
expect(executionIds).toHaveLength(11)
expect(executionIds[0]).toBe('1')
expect(executionIds[10]).toBe('1:10:20:30:40:50:60:70:80:90:10')
})
it('should handle duplicate node IDs in different subgraphs', () => {
// Create two subgraphs with nodes that have the same IDs
const subgraph1 = createMockSubgraph('sub1-uuid', [
createMockNode('100'),
createMockNode('101')
])
const subgraph2 = createMockSubgraph('sub2-uuid', [
createMockNode('100'), // Same ID as in subgraph1
createMockNode('101') // Same ID as in subgraph1
])
const nodes = [
createMockNode('1', { isSubgraph: true, subgraph: subgraph1 }),
createMockNode('2', { isSubgraph: true, subgraph: subgraph2 })
]
const executionIds = getExecutionIdsForSelectedNodes(nodes)
expect(executionIds).toEqual([
'2',
'2:100',
'2:101',
'1',
'1:100',
'1:101'
])
})
it('should handle subgraphs with many children efficiently', () => {
// Create a subgraph with 100 nodes
const manyNodes = []
for (let i = 0; i < 100; i++) {
manyNodes.push(createMockNode(`child-${i}`))
}
const bigSubgraph = createMockSubgraph('big-uuid', manyNodes)
const node = createMockNode('parent', {
isSubgraph: true,
subgraph: bigSubgraph
})
const start = performance.now()
const executionIds = getExecutionIdsForSelectedNodes([node])
const duration = performance.now() - start
expect(executionIds).toHaveLength(101)
expect(executionIds[0]).toBe('parent')
expect(executionIds[100]).toBe('parent:child-99') // Due to backward iteration optimization
// Should complete quickly even with many nodes
expect(duration).toBeLessThan(50)
})
it('should handle selection of nodes at different depths', () => {
// Create a complex nested structure
const deepNode = createMockNode('300')
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
const midNode1 = createMockNode('201')
const midNode2 = createMockNode('202', {
isSubgraph: true,
subgraph: deepSubgraph
})
const midSubgraph = createMockSubgraph('mid-uuid', [midNode1, midNode2])
const topNode = createMockNode('100', {
isSubgraph: true,
subgraph: midSubgraph
})
// Select nodes at different nesting levels
const selectedNodes = [
createMockNode('1'), // Root level
topNode, // Contains subgraph
createMockNode('2') // Root level
]
const executionIds = getExecutionIdsForSelectedNodes(selectedNodes)
expect(executionIds).toContain('1')
expect(executionIds).toContain('2')
expect(executionIds).toContain('100')
expect(executionIds).toContain('100:201')
expect(executionIds).toContain('100:202')
expect(executionIds).toContain('100:202:300')
})
})
})
})

View File

@@ -0,0 +1,114 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { describe, expect, it } from 'vitest'
import { filterOutputNodes, isOutputNode } from '@/utils/nodeFilterUtil'
describe('nodeFilterUtil', () => {
// Helper to create a mock node
const createMockNode = (
id: number,
isOutputNode: boolean = false
): LGraphNode => {
// Create a custom class with the nodeData static property
class MockNode extends LGraphNode {
static nodeData = isOutputNode ? { output_node: true } : {}
}
const node = new MockNode('')
node.id = id
return node
}
describe('filterOutputNodes', () => {
it('should return empty array when given empty array', () => {
const result = filterOutputNodes([])
expect(result).toEqual([])
})
it('should filter out non-output nodes', () => {
const nodes = [
createMockNode(1, false),
createMockNode(2, true),
createMockNode(3, false),
createMockNode(4, true)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([2, 4])
})
it('should return all nodes if all are output nodes', () => {
const nodes = [
createMockNode(1, true),
createMockNode(2, true),
createMockNode(3, true)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(3)
expect(result).toEqual(nodes)
})
it('should return empty array if no output nodes', () => {
const nodes = [
createMockNode(1, false),
createMockNode(2, false),
createMockNode(3, false)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(0)
})
it('should handle nodes without nodeData', () => {
// Create a plain LGraphNode without custom constructor
const node = new LGraphNode('')
node.id = 1
const result = filterOutputNodes([node])
expect(result).toHaveLength(0)
})
it('should handle nodes with undefined output_node', () => {
class MockNodeWithOtherData extends LGraphNode {
static nodeData = { someOtherProperty: true }
}
const node = new MockNodeWithOtherData('')
node.id = 1
const result = filterOutputNodes([node])
expect(result).toHaveLength(0)
})
})
describe('isOutputNode', () => {
it('should filter selected nodes to only output nodes', () => {
const selectedNodes = [
createMockNode(1, false),
createMockNode(2, true),
createMockNode(3, false),
createMockNode(4, true),
createMockNode(5, false)
]
const result = selectedNodes.filter(isOutputNode)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([2, 4])
})
it('should handle empty selection', () => {
const emptyNodes: LGraphNode[] = []
const result = emptyNodes.filter(isOutputNode)
expect(result).toEqual([])
})
it('should handle selection with no output nodes', () => {
const selectedNodes = [createMockNode(1, false), createMockNode(2, false)]
const result = selectedNodes.filter(isOutputNode)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -49,7 +49,7 @@ export default defineConfig({
},
'/workflow_templates': {
target: DEV_SERVER_COMFYUI_URL
target: 'http://localhost:1238'
},
// Proxy extension assets (images/videos) under /extensions to the ComfyUI backend
@@ -67,7 +67,7 @@ export default defineConfig({
...(!DISABLE_TEMPLATES_PROXY
? {
'/templates': {
target: DEV_SERVER_COMFYUI_URL
target: 'http://localhost:1238'
}
}
: {}),