mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 01:39:47 +00:00
New Workflow Templates Modal (#5142)
This pull request refactors and simplifies the template workflow card components and related UI in the codebase. The main changes focus on removing unused or redundant components, improving visual and interaction consistency, and enhancing error handling for images. Below are the most important changes grouped by theme: **Template Workflow Card Refactor and Cleanup** * Removed the `TemplateWorkflowCard.vue` component and its associated test file `TemplateWorkflowCard.spec.ts`, as well as the `TemplateWorkflowCardSkeleton.vue` and `TemplateWorkflowList.vue` components, indicating a shift away from the previous card-based template workflow UI. [[1]](diffhunk://#diff-49569af0404058e8257f3cc0716b066517ce7397dd58744b02aa0d0c61f2a815L1-L139) [[2]](diffhunk://#diff-9fa6fc1470371f0b520d4deda4129fb313b1bea69888a376556f4bd824f9d751L1-L263) [[3]](diffhunk://#diff-bc35b6f77d1cee6e86b05d0da80b7bd40013c7a6a97a89706d3bc52573e1c574L1-L30) [[4]](diffhunk://#diff-48171f792b22022526fca411d3c3a366d48b675dab77943a20846ae079cbaf3bL1-L68) * Removed the `TemplateSearchBar.vue` component, suggesting a redesign or replacement of the search/filter UI for templates. **UI and Interaction Improvements** * Improved the `CardBottom.vue` component by making its height configurable via a `fullHeight` prop, enhancing layout flexibility. * Updated the `CardContainer.vue` component to add hover effects (background, border, shadow, and padding) and support a new `none` aspect ratio for more flexible card layouts. **Image and Input Enhancements** * Enhanced the `LazyImage.vue` component to display a default placeholder image when an image fails to load, improving error handling and user experience. * Improved the `SearchBox.vue` component by making the input focusable when clicking anywhere on the wrapper, and added a template ref for better accessibility and usability. [[1]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bL2-R5) [[2]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bL16-R17) [[3]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bR33-R39) **Minor UI Tweaks** * Adjusted label styling in `SingleSelect.vue` to remove unnecessary overflow handling, simplifying the visual layout. --------- Co-authored-by: Benjamin Lu <benceruleanlu@proton.me> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: snomiao <snomiao@gmail.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com> Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com> Co-authored-by: Jin Yi <jin12cc@gmail.com>
This commit is contained in:
committed by
GitHub
parent
49f373c46f
commit
d954336973
@@ -60,7 +60,7 @@ export function useTemplateWorkflows() {
|
||||
const getTemplateThumbnailUrl = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
index = '1'
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
@@ -85,13 +85,12 @@ export function useTemplateWorkflows() {
|
||||
/**
|
||||
* Gets formatted template description
|
||||
*/
|
||||
const getTemplateDescription = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
const getTemplateDescription = (template: TemplateInfo) => {
|
||||
return (
|
||||
(template.localizedDescription || template.description)
|
||||
?.replace(/[-_]/g, ' ')
|
||||
.trim() ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { groupBy } from 'es-toolkit/compat'
|
||||
import Fuse from 'fuse.js'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { i18n, st } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { getCategoryIcon } from '@/utils/categoryIcons'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { api } from '@/scripts/api'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
} from '../types/template'
|
||||
|
||||
const SHOULD_SORT_CATEGORIES = new Set([
|
||||
// API Node templates should be strictly sorted by name to avoid any
|
||||
// favoritism or bias towards a particular API. Other categories can
|
||||
// have their ordering specified in index.json freely.
|
||||
'Image API',
|
||||
'Video API'
|
||||
])
|
||||
// Enhanced template interface for easier filtering
|
||||
interface EnhancedTemplate extends TemplateInfo {
|
||||
sourceModule: string
|
||||
category?: string
|
||||
categoryType?: string
|
||||
categoryGroup?: string // 'GENERATION TYPE' or 'CLOSED SOURCE MODELS'
|
||||
isEssential?: boolean
|
||||
searchableText?: string
|
||||
}
|
||||
|
||||
export const useWorkflowTemplatesStore = defineStore(
|
||||
'workflowTemplates',
|
||||
@@ -26,36 +31,13 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
|
||||
const isLoaded = ref(false)
|
||||
|
||||
/**
|
||||
* Sort a list of templates in alphabetical order by localized display name.
|
||||
*/
|
||||
const sortTemplateList = (templates: TemplateInfo[]) =>
|
||||
templates.sort((a, b) => {
|
||||
const aName = st(
|
||||
`templateWorkflows.name.${normalizeI18nKey(a.name)}`,
|
||||
a.title ?? a.name
|
||||
)
|
||||
const bName = st(
|
||||
`templateWorkflows.name.${normalizeI18nKey(b.name)}`,
|
||||
b.name
|
||||
)
|
||||
return aName.localeCompare(bName)
|
||||
})
|
||||
// Store filter mappings for dynamic categories
|
||||
type FilterData = {
|
||||
category?: string
|
||||
categoryGroup?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort any template categories (grouped templates) that should be sorted.
|
||||
* Leave other categories' templates in their original order specified in index.json
|
||||
*/
|
||||
const sortCategoryTemplates = (categories: WorkflowTemplates[]) =>
|
||||
categories.map((category) => {
|
||||
if (SHOULD_SORT_CATEGORIES.has(category.title)) {
|
||||
return {
|
||||
...category,
|
||||
templates: sortTemplateList(category.templates)
|
||||
}
|
||||
}
|
||||
return category
|
||||
})
|
||||
const categoryFilters = ref(new Map<string, FilterData>())
|
||||
|
||||
/**
|
||||
* Add localization fields to a template.
|
||||
@@ -144,12 +126,13 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Original grouped templates for backward compatibility
|
||||
*/
|
||||
const groupedTemplates = computed<TemplateGroup[]>(() => {
|
||||
// Get regular categories
|
||||
const allTemplates = [
|
||||
...sortCategoryTemplates(coreTemplates.value).map(
|
||||
localizeTemplateCategory
|
||||
),
|
||||
...coreTemplates.value.map(localizeTemplateCategory),
|
||||
...Object.entries(customTemplates.value).map(
|
||||
([moduleName, templates]) => ({
|
||||
moduleName,
|
||||
@@ -169,38 +152,286 @@ 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 }))
|
||||
const groupedByCategory = [
|
||||
{
|
||||
label: st(
|
||||
'templateWorkflows.category.ComfyUI Examples',
|
||||
'ComfyUI Examples'
|
||||
),
|
||||
modules: [
|
||||
createAllCategory(),
|
||||
...allTemplates.filter((t) => t.moduleName === 'default')
|
||||
]
|
||||
},
|
||||
...(Object.keys(customTemplates.value).length > 0
|
||||
? [
|
||||
{
|
||||
label: st(
|
||||
'templateWorkflows.category.Custom Nodes',
|
||||
'Custom Nodes'
|
||||
),
|
||||
modules: allTemplates.filter((t) => t.moduleName !== 'default')
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
// 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')
|
||||
return groupedByCategory
|
||||
})
|
||||
|
||||
/**
|
||||
* Enhanced templates with proper categorization for filtering
|
||||
*/
|
||||
const enhancedTemplates = computed<EnhancedTemplate[]>(() => {
|
||||
const allTemplates: EnhancedTemplate[] = []
|
||||
|
||||
// Process core templates
|
||||
coreTemplates.value.forEach((category) => {
|
||||
category.templates.forEach((template) => {
|
||||
const enhancedTemplate: EnhancedTemplate = {
|
||||
...template,
|
||||
sourceModule: category.moduleName,
|
||||
category: category.title,
|
||||
categoryType: category.type,
|
||||
categoryGroup: category.category,
|
||||
isEssential: category.isEssential,
|
||||
searchableText: [
|
||||
template.title || template.name,
|
||||
template.description || '',
|
||||
category.title,
|
||||
...(template.tags || []),
|
||||
...(template.models || [])
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
allTemplates.push(enhancedTemplate)
|
||||
})
|
||||
})
|
||||
|
||||
// Process custom templates
|
||||
Object.entries(customTemplates.value).forEach(
|
||||
([moduleName, templates]) => {
|
||||
templates.forEach((name) => {
|
||||
const enhancedTemplate: EnhancedTemplate = {
|
||||
name,
|
||||
title: name,
|
||||
description: name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
sourceModule: moduleName,
|
||||
category: 'Extensions',
|
||||
categoryType: 'extension',
|
||||
searchableText: `${name} ${moduleName} extension`
|
||||
}
|
||||
allTemplates.push(enhancedTemplate)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (comfyExamplesGroupIndex !== -1) {
|
||||
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
|
||||
createAllCategory()
|
||||
return allTemplates
|
||||
})
|
||||
|
||||
/**
|
||||
* Fuse.js instance for advanced template searching and filtering
|
||||
*/
|
||||
const templateFuse = computed(() => {
|
||||
const fuseOptions = {
|
||||
keys: [
|
||||
{ name: 'searchableText', weight: 0.4 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'name', weight: 0.2 },
|
||||
{ name: 'tags', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3,
|
||||
includeScore: true
|
||||
}
|
||||
|
||||
return new Fuse(enhancedTemplates.value, fuseOptions)
|
||||
})
|
||||
|
||||
/**
|
||||
* Filter templates by category ID using stored filter mappings
|
||||
*/
|
||||
const filterTemplatesByCategory = (categoryId: string) => {
|
||||
if (categoryId === 'all') {
|
||||
return enhancedTemplates.value
|
||||
}
|
||||
|
||||
if (categoryId === 'basics') {
|
||||
// Filter for templates from categories marked as essential
|
||||
return enhancedTemplates.value.filter((t) => t.isEssential)
|
||||
}
|
||||
|
||||
// Handle extension-specific filters
|
||||
if (categoryId.startsWith('extension-')) {
|
||||
const moduleName = categoryId.replace('extension-', '')
|
||||
return enhancedTemplates.value.filter(
|
||||
(t) => t.sourceModule === moduleName
|
||||
)
|
||||
}
|
||||
|
||||
return groupedByCategory
|
||||
// Look up the filter from our stored mappings
|
||||
const filter = categoryFilters.value.get(categoryId)
|
||||
if (!filter) {
|
||||
return enhancedTemplates.value
|
||||
}
|
||||
|
||||
// Apply the filter
|
||||
return enhancedTemplates.value.filter((template) => {
|
||||
if (filter.category && template.category !== filter.category) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filter.categoryGroup &&
|
||||
template.categoryGroup !== filter.categoryGroup
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* New navigation structure dynamically built from JSON categories
|
||||
*/
|
||||
const navGroupedTemplates = computed<(NavItemData | NavGroupData)[]>(() => {
|
||||
if (!isLoaded.value) return []
|
||||
|
||||
const items: (NavItemData | NavGroupData)[] = []
|
||||
|
||||
// Clear and rebuild filter mappings
|
||||
categoryFilters.value.clear()
|
||||
|
||||
// 1. All Templates - always first
|
||||
items.push({
|
||||
id: 'all',
|
||||
label: st('templateWorkflows.category.All', 'All Templates'),
|
||||
icon: getCategoryIcon('all')
|
||||
})
|
||||
|
||||
// 2. Basics (isEssential categories) - always second if it exists
|
||||
let gettingStartedText = 'Getting Started'
|
||||
const essentialCat = coreTemplates.value.find(
|
||||
(cat) => cat.isEssential && cat.templates.length > 0
|
||||
)
|
||||
const hasEssentialCategories = Boolean(essentialCat)
|
||||
|
||||
if (essentialCat) {
|
||||
gettingStartedText = essentialCat.title
|
||||
}
|
||||
if (hasEssentialCategories) {
|
||||
items.push({
|
||||
id: 'basics',
|
||||
label: gettingStartedText,
|
||||
icon: 'icon-[lucide--graduation-cap]'
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Group categories from JSON dynamically
|
||||
const categoryGroups = new Map<
|
||||
string,
|
||||
{ title: string; items: NavItemData[] }
|
||||
>()
|
||||
|
||||
// Process all categories from JSON
|
||||
coreTemplates.value.forEach((category) => {
|
||||
// Skip essential categories as they're handled as Basics
|
||||
if (category.isEssential) return
|
||||
|
||||
const categoryGroup = category.category
|
||||
const categoryIcon = category.icon
|
||||
|
||||
if (categoryGroup) {
|
||||
if (!categoryGroups.has(categoryGroup)) {
|
||||
categoryGroups.set(categoryGroup, {
|
||||
title: categoryGroup,
|
||||
items: []
|
||||
})
|
||||
}
|
||||
|
||||
const group = categoryGroups.get(categoryGroup)!
|
||||
|
||||
// Generate unique ID for this category
|
||||
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
|
||||
|
||||
// Store the filter mapping
|
||||
categoryFilters.value.set(categoryId, {
|
||||
category: category.title,
|
||||
categoryGroup: categoryGroup
|
||||
})
|
||||
|
||||
group.items.push({
|
||||
id: categoryId,
|
||||
label: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(category.title)}`,
|
||||
category.title
|
||||
),
|
||||
icon: categoryIcon || getCategoryIcon(category.type || 'default')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Add grouped categories
|
||||
categoryGroups.forEach((group, groupName) => {
|
||||
if (group.items.length > 0) {
|
||||
items.push({
|
||||
title: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(groupName)}`,
|
||||
groupName
|
||||
.split(' ')
|
||||
.map(
|
||||
(word) =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
)
|
||||
.join(' ')
|
||||
),
|
||||
items: group.items
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Extensions - always last
|
||||
const extensionCounts = enhancedTemplates.value.filter(
|
||||
(t) => t.sourceModule !== 'default'
|
||||
).length
|
||||
|
||||
if (extensionCounts > 0) {
|
||||
// Get unique extension modules
|
||||
const extensionModules = Array.from(
|
||||
new Set(
|
||||
enhancedTemplates.value
|
||||
.filter((t) => t.sourceModule !== 'default')
|
||||
.map((t) => t.sourceModule)
|
||||
)
|
||||
).sort()
|
||||
|
||||
const extensionItems: NavItemData[] = extensionModules.map(
|
||||
(moduleName) => ({
|
||||
id: `extension-${moduleName}`,
|
||||
label: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
|
||||
moduleName
|
||||
),
|
||||
icon: getCategoryIcon('extensions')
|
||||
})
|
||||
)
|
||||
|
||||
items.push({
|
||||
title: st('templateWorkflows.category.Extensions', 'Extensions'),
|
||||
items: extensionItems,
|
||||
collapsible: true
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
async function loadWorkflowTemplates() {
|
||||
try {
|
||||
if (!isLoaded.value) {
|
||||
customTemplates.value = await api.getWorkflowTemplates()
|
||||
coreTemplates.value = await api.getCoreWorkflowTemplates()
|
||||
const locale = i18n.global.locale.value
|
||||
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
|
||||
isLoaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -210,6 +441,10 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
|
||||
return {
|
||||
groupedTemplates,
|
||||
navGroupedTemplates,
|
||||
enhancedTemplates,
|
||||
templateFuse,
|
||||
filterTemplatesByCategory,
|
||||
isLoaded,
|
||||
loadWorkflowTemplates
|
||||
}
|
||||
|
||||
@@ -11,13 +11,25 @@ export interface TemplateInfo {
|
||||
description: string
|
||||
localizedTitle?: string
|
||||
localizedDescription?: string
|
||||
isEssential?: boolean
|
||||
sourceModule?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
date?: string
|
||||
useCase?: string
|
||||
license?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface WorkflowTemplates {
|
||||
moduleName: string
|
||||
templates: TemplateInfo[]
|
||||
title: string
|
||||
localizedTitle?: string
|
||||
category?: string
|
||||
type?: string
|
||||
icon?: string
|
||||
isEssential?: boolean
|
||||
}
|
||||
|
||||
export interface TemplateGroup {
|
||||
|
||||
Reference in New Issue
Block a user