Compare commits
18 Commits
fix/workfl
...
vue-custom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b10f40e8 | ||
|
|
8ba98bad07 | ||
|
|
80c428746b | ||
|
|
703de3e669 | ||
|
|
415ebfd67b | ||
|
|
97542efc9b | ||
|
|
13ce23399c | ||
|
|
a0c06bd723 | ||
|
|
3fc17ebdac | ||
|
|
0db2a2c03e | ||
|
|
b96bf3871c | ||
|
|
bc4549244e | ||
|
|
f3e68804e8 | ||
|
|
1749cfa678 | ||
|
|
0fea54c542 | ||
|
|
cd7666e3bc | ||
|
|
8d1261133a | ||
|
|
0919856a05 |
2
.gitignore
vendored
@@ -22,6 +22,8 @@ dist-ssr
|
||||
*.local
|
||||
# Claude configuration
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -56,14 +56,6 @@ TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
|
||||
### Common Setup Issues
|
||||
|
||||
**Most tests require the new menu system** - Add to your test:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
```
|
||||
|
||||
### Release API Mocking
|
||||
|
||||
By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions.
|
||||
|
||||
@@ -1643,7 +1643,7 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
// Hide canvas menu/info/selection toolbox by default.
|
||||
'Comfy.Graph.CanvasInfo': false,
|
||||
'Comfy.Graph.CanvasMenu': false,
|
||||
|
||||
@@ -22,6 +22,13 @@ export class VueNodeHelpers {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for a Vue node by the node's title (displayed name in the header)
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of Vue nodes in the DOM
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Background Image Upload', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Reset the background image setting before each test
|
||||
|
||||
@@ -15,6 +15,10 @@ async function afterChange(comfyPage: ComfyPage) {
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Change Tracker', () => {
|
||||
test.describe('Undo/Redo', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
interface ChatHistoryEntry {
|
||||
prompt: string
|
||||
response: string
|
||||
|
||||
@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
|
||||
import type { Palette } from '../../src/schemas/colorPaletteSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
const customColorPalettes: Record<string, Palette> = {
|
||||
obsidian: {
|
||||
version: 102,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Should execute command', async ({ comfyPage }) => {
|
||||
await comfyPage.registerCommand('TestCommand', () => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Copy Paste', () => {
|
||||
test('Can copy and paste node', async ({ comfyPage }) => {
|
||||
await comfyPage.clickEmptyLatentNode()
|
||||
|
||||
@@ -4,6 +4,10 @@ import { expect } from '@playwright/test'
|
||||
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load workflow warning', () => {
|
||||
test('Should display a warning when loading a workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('DOM Widget', () => {
|
||||
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/collapsed_multiline')
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Execution', () => {
|
||||
test('Report error on unconnected slot', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Feature Flags', () => {
|
||||
test('Client and server exchange feature flags on connection', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Graph', () => {
|
||||
// Should be able to fix link input slot index after swap the input order
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Graph Canvas Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Set link render mode to spline to make sure it's not affected by other tests'
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
@@ -4,6 +4,10 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Group Node', () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('groups/mixed_graph_items')
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
function listenForEvent(): Promise<Event> {
|
||||
return new Promise<Event>((resolve) => {
|
||||
document.addEventListener('litegraph:canvas', (e) => resolve(e), {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load Workflow in Media', () => {
|
||||
const fileNames = [
|
||||
'workflow.webp',
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('LOD Threshold', () => {
|
||||
test('Should switch to low quality mode at correct zoom threshold', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { ComfyApp } from '../../src/scripts/app'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node Badge', () => {
|
||||
test('Can add badge', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
// If an input is optional by node definition, it should be shown as
|
||||
// a hollow circle no matter what shape it was defined in the workflow JSON.
|
||||
test.describe('Optional input', () => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node search box', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box')
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Note Node', () => {
|
||||
test('Can load node nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Primitive Node', () => {
|
||||
test('Can load with correct size', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('primitive/primitive_node')
|
||||
|
||||
@@ -40,6 +40,7 @@ test.describe('Reroute Node', () => {
|
||||
|
||||
test.describe('LiteGraph Native Reroute Node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Canvas Right Click Menu', () => {
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickCanvas()
|
||||
|
||||
@@ -4,6 +4,9 @@ import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox - More Options Submenus', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
@@ -80,12 +80,6 @@ test.describe('Templates', () => {
|
||||
// Load a template
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.locator(
|
||||
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
|
||||
)
|
||||
.click()
|
||||
await comfyPage.templates.loadTemplate('default')
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
|
||||
@@ -108,72 +102,48 @@ test.describe('Templates', () => {
|
||||
expect(await comfyPage.templates.content.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
// Set locale to French before opening templates
|
||||
await comfyPage.setSetting('Comfy.Locale', 'fr')
|
||||
|
||||
// Load the templates dialog and wait for the French index file request
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.fr.json'
|
||||
)
|
||||
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
|
||||
const request = await requestPromise
|
||||
|
||||
// Verify French index was requested
|
||||
expect(request.url()).toContain('templates/index.fr.json')
|
||||
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('Falls back to English templates when locale file not found', async ({
|
||||
test('Uses title field as fallback when the key is not found in locales', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Set locale to a language that doesn't have a template file
|
||||
await comfyPage.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
|
||||
|
||||
// Wait for the German request (expected to 404)
|
||||
const germanRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.de.json'
|
||||
)
|
||||
|
||||
// Wait for the fallback English request
|
||||
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.json'
|
||||
)
|
||||
|
||||
// Intercept the German file to simulate a 404
|
||||
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
|
||||
// Capture request for the index.json
|
||||
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
|
||||
// Add a new template that won't have a translation pre-generated
|
||||
const response = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'FALLBACK CATEGORY',
|
||||
type: 'image',
|
||||
templates: [
|
||||
{
|
||||
name: 'unknown_key_has_no_translation_available',
|
||||
title: 'FALLBACK TEMPLATE NAME',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'No translations found'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Allow the English index to load normally
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
// Load the templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Verify German was requested first, then English as fallback
|
||||
const germanRequest = await germanRequestPromise
|
||||
const englishRequest = await englishRequestPromise
|
||||
|
||||
expect(germanRequest.url()).toContain('templates/index.de.json')
|
||||
expect(englishRequest.url()).toContain('templates/index.json')
|
||||
|
||||
// Verify English titles are shown as fallback
|
||||
// Expect the title to be used as fallback for template cards
|
||||
await expect(
|
||||
comfyPage.templates.content.getByRole('heading', {
|
||||
name: 'Image Generation'
|
||||
})
|
||||
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
|
||||
).toBeVisible()
|
||||
|
||||
// Expect the title to be used as fallback for the template categories
|
||||
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
|
||||
})
|
||||
|
||||
test('template cards are dynamically sized and responsive', async ({
|
||||
@@ -183,43 +153,25 @@ test.describe('Templates', () => {
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
const firstCard = comfyPage.page
|
||||
.locator('[data-testid^="template-workflow-"]')
|
||||
.first()
|
||||
await expect(firstCard).toBeVisible({ timeout: 5000 })
|
||||
// Wait for at least one template card to appear
|
||||
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Get the template grid
|
||||
const templateGrid = comfyPage.page.locator(
|
||||
'[data-testid="template-workflows-content"]'
|
||||
)
|
||||
// Take snapshot of the template grid
|
||||
const templateGrid = comfyPage.templates.content.locator('.grid').first()
|
||||
await expect(templateGrid).toBeVisible()
|
||||
|
||||
// Check grid layout at desktop size (default)
|
||||
const desktopGridClass = await templateGrid.getAttribute('class')
|
||||
expect(desktopGridClass).toContain('grid')
|
||||
expect(desktopGridClass).toContain(
|
||||
'grid-cols-[repeat(auto-fill,minmax(16rem,1fr))]'
|
||||
)
|
||||
|
||||
// Count visible cards at desktop size
|
||||
const desktopCardCount = await comfyPage.page
|
||||
.locator('[data-testid^="template-workflow-"]')
|
||||
.count()
|
||||
expect(desktopCardCount).toBeGreaterThan(0)
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
|
||||
|
||||
// Check cards at mobile viewport size
|
||||
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
|
||||
await expect(templateGrid).toBeVisible()
|
||||
// Grid should still be responsive at mobile size
|
||||
const mobileGridClass = await templateGrid.getAttribute('class')
|
||||
expect(mobileGridClass).toContain('grid')
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
|
||||
|
||||
// Check cards at tablet size
|
||||
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
|
||||
await expect(templateGrid).toBeVisible()
|
||||
// Grid should still be responsive at tablet size
|
||||
const tabletGridClass = await templateGrid.getAttribute('class')
|
||||
expect(tabletGridClass).toContain('grid')
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
|
||||
})
|
||||
|
||||
test('hover effects work on template cards', async ({ comfyPage }) => {
|
||||
@@ -227,13 +179,10 @@ test.describe('Templates', () => {
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Get a template card using data-testid
|
||||
const firstCard = comfyPage.page
|
||||
.locator('[data-testid^="template-workflow-"]')
|
||||
.first()
|
||||
// Get a template card
|
||||
const firstCard = comfyPage.page.locator('.template-card').first()
|
||||
await expect(firstCard).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Check initial state - card should have transition classes
|
||||
// Take snapshot before hover
|
||||
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
|
||||
|
||||
@@ -308,42 +257,21 @@ test.describe('Templates', () => {
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Wait for cards to load
|
||||
// Verify cards are visible with varying content lengths
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-short-description"]'
|
||||
)
|
||||
comfyPage.page.getByText('This is a short description.')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
comfyPage.page.getByText('This is a medium length description')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
comfyPage.page.getByText('This is a much longer description')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify all three cards with different descriptions are visible
|
||||
const shortDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-short-description"]'
|
||||
)
|
||||
const mediumDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-medium-description"]'
|
||||
)
|
||||
const longDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-long-description"]'
|
||||
)
|
||||
|
||||
await expect(shortDescCard).toBeVisible()
|
||||
await expect(mediumDescCard).toBeVisible()
|
||||
await expect(longDescCard).toBeVisible()
|
||||
|
||||
// Verify descriptions are visible and have line-clamp class
|
||||
// The description is in a p tag with text-muted class
|
||||
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
|
||||
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
|
||||
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
|
||||
|
||||
await expect(shortDesc).toContainText('short description')
|
||||
await expect(mediumDesc).toContainText('medium length description')
|
||||
await expect(longDesc).toContainText('much longer description')
|
||||
|
||||
// Verify grid layout maintains consistency
|
||||
const templateGrid = comfyPage.page.locator(
|
||||
'[data-testid="template-workflows-content"]'
|
||||
)
|
||||
// Take snapshot of a grid with specific cards
|
||||
const templateGrid = comfyPage.templates.content
|
||||
.locator('.grid:has-text("Short Description")')
|
||||
.first()
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot(
|
||||
'template-grid-varying-content.png'
|
||||
|
||||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 68 KiB |
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Settings Search functionality', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Register test settings to verify hidden/deprecated filtering
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - Delete Key Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Enable Vue nodes rendering
|
||||
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Node Selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
@@ -15,11 +15,10 @@ test.describe('Vue Node Bypass', () => {
|
||||
test('should allow toggling bypass on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
await checkpointNode.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
@@ -29,15 +28,12 @@ test.describe('Vue Node Bypass', () => {
|
||||
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
const ksamplerNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'KSampler'
|
||||
})
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await checkpointNode.getByText('Load Checkpoint').click()
|
||||
await ksamplerNode.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
32
browser_tests/tests/vueNodes/nodeStates/error.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const ERROR_CLASS = /border-error/
|
||||
|
||||
test.describe('Vue Node Error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Close missing nodes warning dialog
|
||||
await comfyPage.page.getByRole('button', { name: 'Close' }).click()
|
||||
await comfyPage.page.waitForSelector('.comfy-missing-nodes', {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Expect error state on missing unknown node
|
||||
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'UNKNOWN NODE'
|
||||
})
|
||||
await expect(unknownNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
45
browser_tests/tests/vueNodes/nodeStates/mute.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const MUTE_HOTKEY = 'Control+m'
|
||||
const MUTE_CLASS = /opacity-50/
|
||||
|
||||
test.describe('Vue Node Mute', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling mute on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
})
|
||||
|
||||
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(MUTE_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Combo text widget', () => {
|
||||
test('Truncates text when resized', async ({ comfyPage }) => {
|
||||
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
|
||||
@@ -318,6 +322,9 @@ test.describe('Animated image widget', () => {
|
||||
test.describe('Load audio widget', () => {
|
||||
test('Can load audio', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_audio_widget')
|
||||
// Wait for the audio widget to be rendered in the DOM
|
||||
await comfyPage.page.waitForSelector('.comfy-audio', { state: 'attached' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.28.1",
|
||||
"version": "1.28.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -18,10 +18,11 @@
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"test:all": "nx run test",
|
||||
"test:browser": "npx nx e2e",
|
||||
"test:unit": "nx run test tests-ui/tests",
|
||||
"test:component": "nx run test src/components/",
|
||||
"test:litegraph": "vitest run --config vitest.litegraph.config.ts",
|
||||
"test:unit": "nx run test tests-ui/tests",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
@@ -123,7 +124,6 @@
|
||||
"@tiptap/extension-table-row": "^2.10.4",
|
||||
"@tiptap/starter-kit": "^2.10.4",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"@vueuse/integrations": "^13.9.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
||||
87
pnpm-lock.yaml
generated
@@ -68,9 +68,6 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(vue@3.5.13(typescript@5.9.2))
|
||||
'@vueuse/integrations':
|
||||
specifier: ^13.9.0
|
||||
version: 13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
@@ -2898,73 +2895,18 @@ packages:
|
||||
'@vueuse/core@12.8.2':
|
||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||
|
||||
'@vueuse/core@13.9.0':
|
||||
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/integrations@13.9.0':
|
||||
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
|
||||
peerDependencies:
|
||||
async-validator: ^4
|
||||
axios: ^1
|
||||
change-case: ^5
|
||||
drauu: ^0.4
|
||||
focus-trap: ^7
|
||||
fuse.js: ^7
|
||||
idb-keyval: ^6
|
||||
jwt-decode: ^4
|
||||
nprogress: ^0.2
|
||||
qrcode: ^1.5
|
||||
sortablejs: ^1
|
||||
universal-cookie: ^7 || ^8
|
||||
vue: ^3.5.0
|
||||
peerDependenciesMeta:
|
||||
async-validator:
|
||||
optional: true
|
||||
axios:
|
||||
optional: true
|
||||
change-case:
|
||||
optional: true
|
||||
drauu:
|
||||
optional: true
|
||||
focus-trap:
|
||||
optional: true
|
||||
fuse.js:
|
||||
optional: true
|
||||
idb-keyval:
|
||||
optional: true
|
||||
jwt-decode:
|
||||
optional: true
|
||||
nprogress:
|
||||
optional: true
|
||||
qrcode:
|
||||
optional: true
|
||||
sortablejs:
|
||||
optional: true
|
||||
universal-cookie:
|
||||
optional: true
|
||||
|
||||
'@vueuse/metadata@11.0.0':
|
||||
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
|
||||
|
||||
'@vueuse/metadata@12.8.2':
|
||||
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
||||
|
||||
'@vueuse/metadata@13.9.0':
|
||||
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
|
||||
|
||||
'@vueuse/shared@11.0.0':
|
||||
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
|
||||
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
'@vueuse/shared@13.9.0':
|
||||
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@webgpu/types@0.1.51':
|
||||
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
|
||||
|
||||
@@ -6381,9 +6323,6 @@ packages:
|
||||
vue-component-type-helpers@3.0.7:
|
||||
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
|
||||
|
||||
vue-component-type-helpers@3.0.8:
|
||||
resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -8940,7 +8879,7 @@ snapshots:
|
||||
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.8
|
||||
vue-component-type-helpers: 3.0.7
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -9723,28 +9662,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/core@13.9.0(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 13.9.0
|
||||
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
|
||||
'@vueuse/integrations@13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@vueuse/core': 13.9.0(vue@3.5.13(typescript@5.9.2))
|
||||
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
optionalDependencies:
|
||||
axios: 1.11.0
|
||||
fuse.js: 7.0.0
|
||||
|
||||
'@vueuse/metadata@11.0.0': {}
|
||||
|
||||
'@vueuse/metadata@12.8.2': {}
|
||||
|
||||
'@vueuse/metadata@13.9.0': {}
|
||||
|
||||
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
|
||||
@@ -9758,10 +9679,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/shared@13.9.0(vue@3.5.13(typescript@5.9.2))':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
|
||||
'@webgpu/types@0.1.51': {}
|
||||
|
||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||
@@ -13628,8 +13545,6 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.0.7: {}
|
||||
|
||||
vue-component-type-helpers@3.0.8: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB |
@@ -17,8 +17,8 @@ import { computed, onMounted } from 'vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { useConflictDetection } from './composables/useConflictDetection'
|
||||
import { electronAPI, isElectron } from './utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
@@ -970,10 +970,9 @@ audio.comfy-audio.empty-audio-widget {
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
|
||||
}
|
||||
|
||||
.isLOD .lg-node > * {
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g clip-path="url(#clip0_1416_62)">
|
||||
<path d="M7.99998 10.6667V8.00004M7.99998 5.33337H8.00665M14.6666 8.00004C14.6666 11.6819 11.6819 14.6667 7.99998 14.6667C4.31808 14.6667 1.33331 11.6819 1.33331 8.00004C1.33331 4.31814 4.31808 1.33337 7.99998 1.33337C11.6819 1.33337 14.6666 4.31814 14.6666 8.00004Z" stroke="#171718" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1416_62">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 623 B |
@@ -1,19 +1,7 @@
|
||||
<template>
|
||||
<div :class="containerClasses">
|
||||
<div class="flex-1 w-full h-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { fullHeight = true } = defineProps<{
|
||||
fullHeight?: boolean
|
||||
}>()
|
||||
|
||||
const containerClasses = computed(() =>
|
||||
cn('flex-1 w-full', fullHeight && 'h-full')
|
||||
)
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
@@ -8,21 +8,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { ratio = 'square', type } = defineProps<{
|
||||
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
|
||||
type?: string
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
ratio?: 'square' | 'portrait' | 'tallPortrait'
|
||||
}>()
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const baseClasses =
|
||||
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
|
||||
if (type === 'workflow-template-card') {
|
||||
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
|
||||
}
|
||||
'flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
|
||||
const ratioClasses = {
|
||||
smallSquare: 'aspect-240/311',
|
||||
square: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tallPortrait: 'aspect-256/353'
|
||||
|
||||
@@ -2,40 +2,26 @@
|
||||
<div :class="topStyle">
|
||||
<slot class="absolute top-0 left-0 w-full h-full"></slot>
|
||||
|
||||
<div
|
||||
v-if="slots['top-left']"
|
||||
class="absolute top-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||
>
|
||||
<div class="absolute top-2 left-2 flex gap-2">
|
||||
<slot name="top-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['top-right']"
|
||||
class="absolute top-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||
>
|
||||
<div class="absolute top-2 right-2 flex gap-2">
|
||||
<slot name="top-right"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-left']"
|
||||
class="absolute bottom-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||
>
|
||||
<div class="absolute bottom-2 left-2 flex gap-2">
|
||||
<slot name="bottom-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-right']"
|
||||
class="absolute bottom-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||
>
|
||||
<div class="absolute bottom-2 right-2 flex gap-2">
|
||||
<slot name="bottom-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
const slots = useSlots()
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
ratio?: 'square' | 'landscape'
|
||||
|
||||
@@ -24,13 +24,7 @@
|
||||
v-if="hasError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/default-template.png"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="imageClass"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
<i class="pi pi-image text-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,750 +0,0 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
class="workflow-template-selector-dialog"
|
||||
>
|
||||
<template #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[comfy--template]" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">{{
|
||||
$t('sideToolbar.templates', 'Templates')
|
||||
}}</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton
|
||||
v-if="filteredCount !== totalCount"
|
||||
type="secondary"
|
||||
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:filter-x />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
|
||||
<!-- Model Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModelObjects"
|
||||
v-model:search-query="modelSearchText"
|
||||
class="w-[250px]"
|
||||
:label="modelFilterLabel"
|
||||
:options="modelOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:cpu />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- Use Case Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedUseCaseObjects"
|
||||
:label="useCaseFilterLabel"
|
||||
:options="useCaseOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:target />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- License Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedLicenseObjects"
|
||||
:label="licenseFilterLabel"
|
||||
:options="licenseOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:file-text />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<div class="absolute right-5">
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
class="min-w-[270px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:arrow-up-down />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isLoading"
|
||||
class="px-6 pt-4 pb-2 text-2xl font-semibold text-neutral"
|
||||
>
|
||||
<span>
|
||||
{{ pageTitle }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<!-- No Results State (only show when loaded and no results) -->
|
||||
<div
|
||||
v-if="!isLoading && filteredTemplates.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-neutral-500"
|
||||
>
|
||||
<i-lucide:search class="w-12 h-12 mb-4 opacity-50" />
|
||||
<p class="text-lg mb-2">
|
||||
{{ $t('templateWorkflows.noResults', 'No templates found') }}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
{{
|
||||
$t(
|
||||
'templateWorkflows.noResultsHint',
|
||||
'Try adjusting your search or filters'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Title -->
|
||||
<span
|
||||
v-if="isLoading"
|
||||
class="inline-block h-8 w-48 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||
></span>
|
||||
|
||||
<!-- Template Cards Grid -->
|
||||
<div
|
||||
:key="templateListKey"
|
||||
:style="gridStyle"
|
||||
data-testid="template-workflows-content"
|
||||
>
|
||||
<!-- Loading Skeletons (show while loading initial data) -->
|
||||
<CardContainer
|
||||
v-for="n in isLoading ? 12 : 0"
|
||||
:key="`initial-skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
|
||||
></div>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<div class="px-4 py-3">
|
||||
<div
|
||||
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
|
||||
></div>
|
||||
<div
|
||||
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||
></div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
|
||||
<!-- Actual Template Cards -->
|
||||
<CardContainer
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
v-memo="[template.name, hoveredTemplate === template.name]"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
@mouseenter="hoveredTemplate = template.name"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="onLoadWorkflow(template)"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<!-- Template Thumbnail -->
|
||||
<div
|
||||
class="w-full h-full relative rounded-lg overflow-hidden"
|
||||
>
|
||||
<template v-if="template.mediaType === 'audio'">
|
||||
<AudioThumbnail :src="getBaseThumbnailSrc(template)" />
|
||||
</template>
|
||||
<template
|
||||
v-else-if="template.thumbnailVariant === 'compareSlider'"
|
||||
>
|
||||
<CompareSliderThumbnail
|
||||
:base-image-src="getBaseThumbnailSrc(template)"
|
||||
:overlay-image-src="getOverlayThumbnailSrc(template)"
|
||||
:alt="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
"
|
||||
:is-hovered="hoveredTemplate === template.name"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="template.thumbnailVariant === 'hoverDissolve'"
|
||||
>
|
||||
<HoverDissolveThumbnail
|
||||
:base-image-src="getBaseThumbnailSrc(template)"
|
||||
:overlay-image-src="getOverlayThumbnailSrc(template)"
|
||||
:alt="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
"
|
||||
:is-hovered="hoveredTemplate === template.name"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DefaultThumbnail
|
||||
:src="getBaseThumbnailSrc(template)"
|
||||
:alt="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
"
|
||||
:is-hovered="hoveredTemplate === template.name"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
:hover-zoom="
|
||||
template.thumbnailVariant === 'zoomHover' ? 16 : 5
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<ProgressSpinner
|
||||
v-if="loadingTemplate === template.name"
|
||||
class="absolute inset-0 z-10 w-12 h-12 m-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<template v-if="template.tags && template.tags.length > 0">
|
||||
<SquareChip
|
||||
v-for="tag in template.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<div class="flex flex-col gap-2 pt-3">
|
||||
<h3
|
||||
class="line-clamp-1 text-sm m-0"
|
||||
:title="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
}}
|
||||
</h3>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p
|
||||
class="line-clamp-2 text-sm text-muted m-0"
|
||||
:title="getTemplateDescription(template)"
|
||||
>
|
||||
{{ getTemplateDescription(template) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="template.tutorialUrl"
|
||||
class="flex flex-col-reverse justify-center"
|
||||
>
|
||||
<IconButton
|
||||
v-if="hoveredTemplate === template.name"
|
||||
v-tooltip.bottom="$t('g.seeTutorial')"
|
||||
v-bind="$attrs"
|
||||
type="primary"
|
||||
size="sm"
|
||||
@click.stop="openTutorial(template)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
|
||||
<!-- Loading More Skeletons -->
|
||||
<CardContainer
|
||||
v-for="n in isLoadingMore ? 6 : 0"
|
||||
:key="`skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
|
||||
></div>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<div class="px-4 py-3">
|
||||
<div
|
||||
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
|
||||
></div>
|
||||
<div
|
||||
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||
></div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More Trigger -->
|
||||
<div
|
||||
v-if="!isLoading && hasMoreTemplates"
|
||||
ref="loadTrigger"
|
||||
class="w-full h-4 flex justify-center items-center mt-4"
|
||||
>
|
||||
<div v-if="isLoadingMore" class="text-sm text-muted">
|
||||
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div
|
||||
v-if="!isLoading"
|
||||
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
|
||||
>
|
||||
{{
|
||||
$t('templateWorkflows.resultsCount', {
|
||||
count: filteredCount,
|
||||
total: totalCount
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
// Workflow templates store and composable
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const {
|
||||
loadTemplates,
|
||||
loadWorkflowTemplate,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
}
|
||||
|
||||
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
|
||||
}
|
||||
|
||||
// Open tutorial in new tab
|
||||
const openTutorial = (template: TemplateInfo) => {
|
||||
if (template.tutorialUrl) {
|
||||
window.open(template.tutorialUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Get navigation items from the store, with skeleton items while loading
|
||||
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
|
||||
// Show skeleton navigation items while loading
|
||||
if (isLoading.value) {
|
||||
return [
|
||||
{
|
||||
id: 'skeleton-all',
|
||||
label: 'All Templates',
|
||||
icon: 'icon-[lucide--layout-grid]'
|
||||
},
|
||||
{
|
||||
id: 'skeleton-basics',
|
||||
label: 'Basics',
|
||||
icon: 'icon-[lucide--graduation-cap]'
|
||||
},
|
||||
{
|
||||
title: 'Generation Type',
|
||||
items: [
|
||||
{ id: 'skeleton-1', label: '...', icon: 'icon-[lucide--loader-2]' },
|
||||
{ id: 'skeleton-2', label: '...', icon: 'icon-[lucide--loader-2]' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Closed Source Models',
|
||||
items: [
|
||||
{ id: 'skeleton-3', label: '...', icon: 'icon-[lucide--loader-2]' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return workflowTemplatesStore.navGroupedTemplates
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => createGridStyle())
|
||||
|
||||
// Get enhanced templates for better filtering
|
||||
const allTemplates = computed(() => {
|
||||
return workflowTemplatesStore.enhancedTemplates
|
||||
})
|
||||
|
||||
// Filter templates based on selected navigation item
|
||||
const navigationFilteredTemplates = computed(() => {
|
||||
if (!selectedNavItem.value) {
|
||||
return allTemplates.value
|
||||
}
|
||||
|
||||
return workflowTemplatesStore.filterTemplatesByCategory(selectedNavItem.value)
|
||||
})
|
||||
|
||||
// Template filtering
|
||||
const {
|
||||
searchQuery,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses,
|
||||
sortBy,
|
||||
filteredTemplates,
|
||||
availableModels,
|
||||
availableUseCases,
|
||||
availableLicenses,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
const selectedModelObjects = computed({
|
||||
get() {
|
||||
return selectedModels.value.map((model) => ({ name: model, value: model }))
|
||||
},
|
||||
set(value: { name: string; value: string }[]) {
|
||||
selectedModels.value = value.map((item) => item.value)
|
||||
}
|
||||
})
|
||||
|
||||
const selectedUseCaseObjects = computed({
|
||||
get() {
|
||||
return selectedUseCases.value.map((useCase) => ({
|
||||
name: useCase,
|
||||
value: useCase
|
||||
}))
|
||||
},
|
||||
set(value: { name: string; value: string }[]) {
|
||||
selectedUseCases.value = value.map((item) => item.value)
|
||||
}
|
||||
})
|
||||
|
||||
const selectedLicenseObjects = computed({
|
||||
get() {
|
||||
return selectedLicenses.value.map((license) => ({
|
||||
name: license,
|
||||
value: license
|
||||
}))
|
||||
},
|
||||
set(value: { name: string; value: string }[]) {
|
||||
selectedLicenses.value = value.map((item) => item.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Loading states
|
||||
const loadingTemplate = ref<string | null>(null)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
|
||||
// Search text for model filter
|
||||
const modelSearchText = ref<string>('')
|
||||
|
||||
// Filter options
|
||||
const modelOptions = computed(() =>
|
||||
availableModels.value.map((model) => ({
|
||||
name: model,
|
||||
value: model
|
||||
}))
|
||||
)
|
||||
|
||||
const useCaseOptions = computed(() =>
|
||||
availableUseCases.value.map((useCase) => ({
|
||||
name: useCase,
|
||||
value: useCase
|
||||
}))
|
||||
)
|
||||
|
||||
const licenseOptions = computed(() =>
|
||||
availableLicenses.value.map((license) => ({
|
||||
name: license,
|
||||
value: license
|
||||
}))
|
||||
)
|
||||
|
||||
// Filter labels
|
||||
const modelFilterLabel = computed(() => {
|
||||
if (selectedModelObjects.value.length === 0) {
|
||||
return t('templateWorkflows.modelFilter', 'Model Filter')
|
||||
} else if (selectedModelObjects.value.length === 1) {
|
||||
return selectedModelObjects.value[0].name
|
||||
} else {
|
||||
return t('templateWorkflows.modelsSelected', {
|
||||
count: selectedModelObjects.value.length
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const useCaseFilterLabel = computed(() => {
|
||||
if (selectedUseCaseObjects.value.length === 0) {
|
||||
return t('templateWorkflows.useCaseFilter', 'Use Case')
|
||||
} else if (selectedUseCaseObjects.value.length === 1) {
|
||||
return selectedUseCaseObjects.value[0].name
|
||||
} else {
|
||||
return t('templateWorkflows.useCasesSelected', {
|
||||
count: selectedUseCaseObjects.value.length
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const licenseFilterLabel = computed(() => {
|
||||
if (selectedLicenseObjects.value.length === 0) {
|
||||
return t('templateWorkflows.licenseFilter', 'License')
|
||||
} else if (selectedLicenseObjects.value.length === 1) {
|
||||
return selectedLicenseObjects.value[0].name
|
||||
} else {
|
||||
return t('templateWorkflows.licensesSelected', {
|
||||
count: selectedLicenseObjects.value.length
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sort options
|
||||
const sortOptions = computed(() => [
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.default', 'Default'),
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
'templateWorkflows.sort.modelSizeLowToHigh',
|
||||
'Model Size (Low to High)'
|
||||
),
|
||||
value: 'model-size-low-to-high'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.alphabetical', 'Alphabetical (A-Z)'),
|
||||
value: 'alphabetical'
|
||||
}
|
||||
])
|
||||
|
||||
// Lazy pagination setup
|
||||
const loadTrigger = ref<HTMLElement | null>(null)
|
||||
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
||||
|
||||
const {
|
||||
paginatedItems: paginatedTemplates,
|
||||
isLoading: isLoadingMore,
|
||||
hasMoreItems: hasMoreTemplates,
|
||||
loadNextPage,
|
||||
reset: resetPagination
|
||||
} = useLazyPagination(filteredTemplates, { itemsPerPage: 24 }) // Load 24 items per page
|
||||
|
||||
// Display templates (all when searching, paginated when not)
|
||||
const displayTemplates = computed(() => {
|
||||
return shouldUsePagination.value
|
||||
? paginatedTemplates.value
|
||||
: filteredTemplates.value
|
||||
})
|
||||
|
||||
// Set up intersection observer for lazy loading
|
||||
useIntersectionObserver(loadTrigger, () => {
|
||||
if (
|
||||
shouldUsePagination.value &&
|
||||
hasMoreTemplates.value &&
|
||||
!isLoadingMore.value
|
||||
) {
|
||||
void loadNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
// Reset pagination when filters change
|
||||
watch(
|
||||
[
|
||||
searchQuery,
|
||||
selectedNavItem,
|
||||
sortBy,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses
|
||||
],
|
||||
() => {
|
||||
resetPagination()
|
||||
// Clear loading state and force re-render of template list
|
||||
loadingTemplate.value = null
|
||||
templateListKey.value++
|
||||
}
|
||||
)
|
||||
|
||||
// Methods
|
||||
const onLoadWorkflow = async (template: any) => {
|
||||
loadingTemplate.value = template.name
|
||||
try {
|
||||
await loadWorkflowTemplate(
|
||||
template.name,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
onClose()
|
||||
} finally {
|
||||
loadingTemplate.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const navItem = navItems.value.find((item) =>
|
||||
'id' in item
|
||||
? item.id === selectedNavItem.value
|
||||
: item.items?.some((sub) => sub.id === selectedNavItem.value)
|
||||
)
|
||||
|
||||
if (!navItem) {
|
||||
return t('templateWorkflows.allTemplates', 'All Templates')
|
||||
}
|
||||
|
||||
return 'id' in navItem
|
||||
? navItem.label
|
||||
: navItem.items?.find((i) => i.id === selectedNavItem.value)?.label ||
|
||||
t('templateWorkflows.allTemplates', 'All Templates')
|
||||
})
|
||||
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run both operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
])
|
||||
return true
|
||||
},
|
||||
false, // initial state
|
||||
{
|
||||
immediate: true // Start loading immediately
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Ensure the workflow template selector dialog fits within provided dialog */
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
width: 100% !important;
|
||||
max-width: 1400px;
|
||||
height: 100% !important;
|
||||
aspect-ratio: auto !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
max-width: 1600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -58,11 +58,11 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
@@ -165,7 +165,7 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
// Vue node system
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
const viewportCulling = useViewportCulling()
|
||||
const { handleTransformUpdate } = useViewportCulling()
|
||||
|
||||
const handleVueNodeLifecycleReset = async () => {
|
||||
if (shouldRenderVueNodes.value) {
|
||||
@@ -187,8 +187,9 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const allNodes = viewportCulling.allNodes
|
||||
const handleTransformUpdate = viewportCulling.handleTransformUpdate
|
||||
const allNodes = computed(() =>
|
||||
Array.from(vueNodeLifecycle.vueNodeData.value.values())
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
@@ -307,13 +308,27 @@ watch(
|
||||
removeSlotError(node)
|
||||
const nodeErrors = lastNodeErrors?.[node.id]
|
||||
if (!nodeErrors) continue
|
||||
for (const error of nodeErrors.errors) {
|
||||
if (error.extra_info && error.extra_info.input_name) {
|
||||
const inputIndex = node.findInputSlot(error.extra_info.input_name)
|
||||
|
||||
const validErrors = nodeErrors.errors.filter(
|
||||
(error) => error.extra_info?.input_name !== undefined
|
||||
)
|
||||
const slotErrorsChanged =
|
||||
validErrors.length > 0 &&
|
||||
validErrors.some((error) => {
|
||||
const inputName = error.extra_info!.input_name!
|
||||
const inputIndex = node.findInputSlot(inputName)
|
||||
if (inputIndex !== -1) {
|
||||
node.inputs[inputIndex].hasErrors = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Trigger Vue node data update if slot errors changed
|
||||
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
|
||||
comfyApp.graph.onTrigger('node:slot-errors:changed', {
|
||||
nodeId: node.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,13 +141,13 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
@@ -168,7 +168,8 @@ const EXTERNAL_LINKS = {
|
||||
DOCS: 'https://docs.comfy.org/',
|
||||
DISCORD: 'https://www.comfy.org/discord',
|
||||
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
DESKTOP_GUIDE: 'https://comfyorg.notion.site/',
|
||||
DESKTOP_GUIDE_WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
|
||||
DESKTOP_GUIDE_MACOS: 'https://docs.comfy.org/installation/desktop/macos',
|
||||
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
|
||||
} as const
|
||||
|
||||
@@ -222,7 +223,11 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
|
||||
const docsUrl =
|
||||
electronAPI().getPlatform() === 'darwin'
|
||||
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
||||
: EXTERNAL_LINKS.DESKTOP_GUIDE_WINDOWS
|
||||
openExternalLink(docsUrl)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
-->
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
v-bind="{ ...$attrs, options: filteredOptions }"
|
||||
v-bind="$attrs"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:max-selected-labels="0"
|
||||
@@ -105,11 +105,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type UseFuseOptions, useFuse } from '@vueuse/integrations/useFuse'
|
||||
import Button from 'primevue/button'
|
||||
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
@@ -159,7 +158,7 @@ const {
|
||||
const selectedItems = defineModel<Option[]>({
|
||||
required: true
|
||||
})
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
|
||||
const { t } = useI18n()
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
@@ -168,40 +167,6 @@ const popoverStyle = usePopoverSizing({
|
||||
minWidth: popoverMinWidth,
|
||||
maxWidth: popoverMaxWidth
|
||||
})
|
||||
const attrs = useAttrs()
|
||||
const originalOptions = computed(() => (attrs.options as Option[]) || [])
|
||||
|
||||
// Use VueUse's useFuse for better reactivity and performance
|
||||
const fuseOptions: UseFuseOptions<Option> = {
|
||||
fuseOptions: {
|
||||
keys: ['name', 'value'],
|
||||
threshold: 0.3,
|
||||
includeScore: false
|
||||
},
|
||||
matchAllWhenSearchEmpty: true
|
||||
}
|
||||
|
||||
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
|
||||
|
||||
// Filter options based on search, but always include selected items
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchQuery.value || searchQuery.value.trim() === '') {
|
||||
return originalOptions.value
|
||||
}
|
||||
|
||||
// results.value already contains the search results from useFuse
|
||||
const searchResults = results.value.map(
|
||||
(result: { item: Option }) => result.item
|
||||
)
|
||||
|
||||
// Include selected items that aren't in search results
|
||||
const selectedButNotInResults = selectedItems.value.filter(
|
||||
(item) =>
|
||||
!searchResults.some((result: Option) => result.value === item.value)
|
||||
)
|
||||
|
||||
return [...selectedButNotInResults, ...searchResults]
|
||||
})
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<div :class="wrapperStyle" @click="focusInput">
|
||||
<div :class="wrapperStyle">
|
||||
<i-lucide:search :class="iconColorStyle" />
|
||||
<InputText
|
||||
ref="input"
|
||||
v-model="searchQuery"
|
||||
:aria-label="
|
||||
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
|
||||
"
|
||||
:placeholder="
|
||||
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
|
||||
"
|
||||
:placeholder="placeHolder || 'Search...'"
|
||||
type="text"
|
||||
unstyled
|
||||
:class="inputStyle"
|
||||
@@ -19,9 +13,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
@@ -36,13 +29,6 @@ const {
|
||||
// defineModel without arguments uses 'modelValue' as the prop name
|
||||
const searchQuery = defineModel<string>()
|
||||
|
||||
const input = ref<{ $el: HTMLElement } | null>()
|
||||
const focusInput = () => {
|
||||
if (input.value && input.value.$el) {
|
||||
input.value.$el.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
const baseClasses = [
|
||||
'relative flex w-full items-center gap-2',
|
||||
|
||||
@@ -143,7 +143,7 @@ const pt = computed(() => ({
|
||||
label: {
|
||||
class:
|
||||
// Align with MultiSelect labelContainer spacing
|
||||
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- Reference:
|
||||
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="_sb_node_preview">
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header text-ellipsis mr-4"
|
||||
@@ -85,6 +85,8 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
@@ -94,6 +96,8 @@ const { nodeDef } = defineProps<{
|
||||
nodeDef: ComfyNodeDefV2
|
||||
}>()
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const litegraphColors = computed(
|
||||
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
|
||||
@@ -62,14 +62,14 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
|
||||
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
|
||||
64
src/components/templates/TemplateSearchBar.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<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">
|
||||
<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-2xl'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<small
|
||||
v-if="searchQuery && filteredCount !== null"
|
||||
class="text-color-secondary"
|
||||
>
|
||||
{{ $t('g.resultsCount', { count: filteredCount }) }}
|
||||
</small>
|
||||
<Button
|
||||
v-if="searchQuery"
|
||||
text
|
||||
size="small"
|
||||
icon="pi pi-times"
|
||||
:label="$t('g.clearFilters')"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const { filteredCount } = defineProps<{
|
||||
filteredCount?: number | null
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
clearFilters: []
|
||||
}>()
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = ''
|
||||
emit('clearFilters')
|
||||
}
|
||||
</script>
|
||||
273
src/components/templates/TemplateWorkflowCard.spec.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/AudioThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'AudioThumbnail',
|
||||
template: '<div class="mock-audio-thumbnail" :data-src="src"></div>',
|
||||
props: ['src']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/CompareSliderThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'CompareSliderThumbnail',
|
||||
template:
|
||||
'<div class="mock-compare-slider" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
|
||||
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/DefaultThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'DefaultThumbnail',
|
||||
template: '<div class="mock-default-thumbnail" :data-src="src"></div>',
|
||||
props: ['src', 'alt', 'isHovered', 'isVideo', 'hoverZoom']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/HoverDissolveThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'HoverDissolveThumbnail',
|
||||
template:
|
||||
'<div class="mock-hover-dissolve" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
|
||||
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useElementHover: () => ref(false)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: (path: string) => `/fileURL${path}`,
|
||||
apiURL: (path: string) => `/apiURL${path}`,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
isLoaded: true,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
groupedTemplates: []
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, fallback: string) => fallback || key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateWorkflows',
|
||||
() => ({
|
||||
useTemplateWorkflows: () => ({
|
||||
getTemplateThumbnailUrl: (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? `/fileURL/templates/${template.name}`
|
||||
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
|
||||
const indexSuffix =
|
||||
sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
},
|
||||
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
},
|
||||
getTemplateDescription: (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
},
|
||||
loadWorkflowTemplate: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('TemplateWorkflowCard', () => {
|
||||
const createTemplate = (overrides = {}): TemplateInfo => ({
|
||||
name: 'test-template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
thumbnailVariant: 'default',
|
||||
description: 'Test description',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountCard = (props = {}) => {
|
||||
return mount(TemplateWorkflowCard, {
|
||||
props: {
|
||||
sourceModule: 'default',
|
||||
categoryTitle: 'Test Category',
|
||||
loading: false,
|
||||
template: createTemplate(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Card: {
|
||||
template:
|
||||
'<div class="card" @click="$emit(\'click\')"><slot name="header" /><slot name="content" /></div>',
|
||||
props: ['dataTestid', 'pt']
|
||||
},
|
||||
ProgressSpinner: {
|
||||
template: '<div class="progress-spinner"></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('emits loadWorkflow event when clicked', async () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ name: 'test-workflow' })
|
||||
})
|
||||
await wrapper.find('.card').trigger('click')
|
||||
expect(wrapper.emitted('loadWorkflow')).toBeTruthy()
|
||||
expect(wrapper.emitted('loadWorkflow')?.[0]).toEqual(['test-workflow'])
|
||||
})
|
||||
|
||||
it('shows loading spinner when loading is true', () => {
|
||||
const wrapper = mountCard({ loading: true })
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders audio thumbnail for audio media type', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ mediaType: 'audio' })
|
||||
})
|
||||
expect(wrapper.find('.mock-audio-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders compare slider thumbnail for compareSlider variant', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'compareSlider' })
|
||||
})
|
||||
expect(wrapper.find('.mock-compare-slider').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders hover dissolve thumbnail for hoverDissolve variant', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'hoverDissolve' })
|
||||
})
|
||||
expect(wrapper.find('.mock-hover-dissolve').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders default thumbnail by default', () => {
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('passes correct props to default thumbnail for video', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ mediaType: 'video' })
|
||||
})
|
||||
const thumbnail = wrapper.find('.mock-default-thumbnail')
|
||||
expect(thumbnail.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses zoomHover scale when variant is zoomHover', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'zoomHover' })
|
||||
})
|
||||
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays localized title for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({ localizedTitle: 'My Localized Title' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Localized Title')
|
||||
})
|
||||
|
||||
it('displays template name as title for non-default source modules', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom',
|
||||
template: createTemplate({ name: 'custom-template' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('custom-template')
|
||||
})
|
||||
|
||||
it('displays localized description for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({
|
||||
localizedDescription: 'My Localized Description'
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Localized Description')
|
||||
})
|
||||
|
||||
it('processes description for non-default source modules', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom',
|
||||
template: createTemplate({ description: 'custom_module-description' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('custom module description')
|
||||
})
|
||||
|
||||
it('generates correct thumbnail URLs for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({
|
||||
name: 'my-template',
|
||||
mediaSubtype: 'jpg'
|
||||
})
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
expect(vm.baseThumbnailSrc).toBe('/fileURL/templates/my-template-1.jpg')
|
||||
expect(vm.overlayThumbnailSrc).toBe('/fileURL/templates/my-template-2.jpg')
|
||||
})
|
||||
|
||||
it('generates correct thumbnail URLs for custom source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom-module',
|
||||
template: createTemplate({
|
||||
name: 'my-template',
|
||||
mediaSubtype: 'png'
|
||||
})
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
expect(vm.baseThumbnailSrc).toBe(
|
||||
'/apiURL/workflow_templates/custom-module/my-template.png'
|
||||
)
|
||||
expect(vm.overlayThumbnailSrc).toBe(
|
||||
'/apiURL/workflow_templates/custom-module/my-template.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
139
src/components/templates/TemplateWorkflowCard.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<Card
|
||||
ref="cardRef"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
|
||||
:pt="{
|
||||
body: { class: 'p-0 h-full flex flex-col' }
|
||||
}"
|
||||
@click="$emit('loadWorkflow', template.name)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative overflow-hidden rounded-t-lg">
|
||||
<template v-if="template.mediaType === 'audio'">
|
||||
<AudioThumbnail :src="baseThumbnailSrc" />
|
||||
</template>
|
||||
<template v-else-if="template.thumbnailVariant === 'compareSlider'">
|
||||
<CompareSliderThumbnail
|
||||
:base-image-src="baseThumbnailSrc"
|
||||
:overlay-image-src="overlayThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="template.thumbnailVariant === 'hoverDissolve'">
|
||||
<HoverDissolveThumbnail
|
||||
:base-image-src="baseThumbnailSrc"
|
||||
:overlay-image-src="overlayThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DefaultThumbnail
|
||||
:src="baseThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
:hover-zoom="
|
||||
template.thumbnailVariant === 'zoomHover'
|
||||
? UPSCALE_ZOOM_SCALE
|
||||
: DEFAULT_ZOOM_SCALE
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<ProgressSpinner
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-1 w-3/12 h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
|
||||
const DEFAULT_ZOOM_SCALE = 5
|
||||
|
||||
const { sourceModule, loading, template } = defineProps<{
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: boolean
|
||||
template: TemplateInfo
|
||||
}>()
|
||||
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = useElementHover(cardRef)
|
||||
|
||||
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
|
||||
useTemplateWorkflows()
|
||||
|
||||
// Determine the effective source module to use (from template or prop)
|
||||
const effectiveSourceModule = computed(
|
||||
() => template.sourceModule || sourceModule
|
||||
)
|
||||
|
||||
const baseThumbnailSrc = computed(() =>
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '1' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const overlayThumbnailSrc = computed(() =>
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '2' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const description = computed(() =>
|
||||
getTemplateDescription(template, effectiveSourceModule.value)
|
||||
)
|
||||
const title = computed(() =>
|
||||
getTemplateTitle(template, effectiveSourceModule.value)
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
</script>
|
||||
30
src/components/templates/TemplateWorkflowCardSkeleton.vue
Normal 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>
|
||||
68
src/components/templates/TemplateWorkflowList.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<DataTable
|
||||
v-model:selection="selectedTemplate"
|
||||
:value="enrichedTemplates"
|
||||
striped-rows
|
||||
selection-mode="single"
|
||||
>
|
||||
<Column field="title" :header="$t('g.title')">
|
||||
<template #body="slotProps">
|
||||
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" :header="$t('g.description')">
|
||||
<template #body="slotProps">
|
||||
<span :title="slotProps.data.description">
|
||||
{{ slotProps.data.description }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="actions" header="" class="w-12">
|
||||
<template #body="slotProps">
|
||||
<Button
|
||||
icon="pi pi-arrow-right"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
:loading="loading === slotProps.data.name"
|
||||
@click="emit('loadWorkflow', slotProps.data.name)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const { sourceModule, loading, templates } = defineProps<{
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: string | null
|
||||
templates: TemplateInfo[]
|
||||
}>()
|
||||
|
||||
const selectedTemplate = ref(null)
|
||||
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
|
||||
|
||||
const enrichedTemplates = computed(() => {
|
||||
return templates.map((template) => {
|
||||
const actualSourceModule = template.sourceModule || sourceModule
|
||||
return {
|
||||
...template,
|
||||
title: getTemplateTitle(template, actualSourceModule),
|
||||
description: getTemplateDescription(template, actualSourceModule)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
</script>
|
||||
185
src/components/templates/TemplateWorkflowView.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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 type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
vi.mock('primevue/dataview', () => ({
|
||||
default: {
|
||||
name: 'DataView',
|
||||
template: `
|
||||
<div class="p-dataview">
|
||||
<div class="dataview-header"><slot name="header"></slot></div>
|
||||
<div class="dataview-content">
|
||||
<slot name="grid" :items="value"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
props: ['value', 'layout', 'lazy', 'pt']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
name: 'SelectButton',
|
||||
template:
|
||||
'<div class="p-selectbutton"><slot name="option" :option="modelValue"></slot></div>',
|
||||
props: ['modelValue', 'options', 'allowEmpty']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
|
||||
default: {
|
||||
template: `
|
||||
<div
|
||||
class="mock-template-card"
|
||||
:data-name="template.name"
|
||||
:data-source-module="sourceModule"
|
||||
:data-category-title="categoryTitle"
|
||||
:data-loading="loading"
|
||||
@click="$emit('loadWorkflow', template.name)"
|
||||
></div>
|
||||
`,
|
||||
props: ['sourceModule', 'categoryTitle', 'loading', 'template'],
|
||||
emits: ['loadWorkflow']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-template-list"></div>',
|
||||
props: ['sourceModule', 'categoryTitle', 'loading', 'templates'],
|
||||
emits: ['loadWorkflow']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-search-bar"></div>',
|
||||
props: ['searchQuery', 'filteredCount'],
|
||||
emits: ['update:searchQuery', '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: '' },
|
||||
filteredTemplates: templates,
|
||||
filteredCount: { value: templates.value?.length || 0 }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowView', () => {
|
||||
const createTemplate = (name: string): TemplateInfo => ({
|
||||
name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
thumbnailVariant: 'default',
|
||||
description: `Description for ${name}`
|
||||
})
|
||||
|
||||
const mountView = (props = {}) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWorkflows: {
|
||||
loadingMore: 'Loading more...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(TemplateWorkflowView, {
|
||||
props: {
|
||||
title: 'Test Templates',
|
||||
sourceModule: 'default',
|
||||
categoryTitle: 'Test Category',
|
||||
templates: [
|
||||
createTemplate('template-1'),
|
||||
createTemplate('template-2'),
|
||||
createTemplate('template-3')
|
||||
],
|
||||
loading: null,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders template cards for each template', () => {
|
||||
const wrapper = mountView()
|
||||
const cards = wrapper.findAll('.mock-template-card')
|
||||
|
||||
expect(cards.length).toBe(3)
|
||||
expect(cards[0].attributes('data-name')).toBe('template-1')
|
||||
expect(cards[1].attributes('data-name')).toBe('template-2')
|
||||
expect(cards[2].attributes('data-name')).toBe('template-3')
|
||||
})
|
||||
|
||||
it('emits loadWorkflow event when clicked', async () => {
|
||||
const wrapper = mountView()
|
||||
const card = wrapper.find('.mock-template-card')
|
||||
|
||||
await card.trigger('click')
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('loadWorkflow')
|
||||
// Check that the emitted event contains the template name
|
||||
const emitted = wrapper.emitted('loadWorkflow')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted?.[0][0]).toBe('template-1')
|
||||
})
|
||||
|
||||
it('passes correct props to template cards', () => {
|
||||
const wrapper = mountView({
|
||||
sourceModule: 'custom',
|
||||
categoryTitle: 'Custom Category'
|
||||
})
|
||||
|
||||
const card = wrapper.find('.mock-template-card')
|
||||
expect(card.exists()).toBe(true)
|
||||
expect(card.attributes('data-source-module')).toBe('custom')
|
||||
expect(card.attributes('data-category-title')).toBe('Custom Category')
|
||||
})
|
||||
|
||||
it('applies loading state correctly to cards', () => {
|
||||
const wrapper = mountView({
|
||||
loading: 'template-2'
|
||||
})
|
||||
|
||||
const cards = wrapper.findAll('.mock-template-card')
|
||||
|
||||
// Only the second card should have loading=true since loading="template-2"
|
||||
expect(cards[0].attributes('data-loading')).toBe('false')
|
||||
expect(cards[1].attributes('data-loading')).toBe('true')
|
||||
expect(cards[2].attributes('data-loading')).toBe('false')
|
||||
})
|
||||
})
|
||||
168
src/components/templates/TemplateWorkflowView.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<DataView
|
||||
:value="displayTemplates"
|
||||
:layout="layout"
|
||||
data-key="name"
|
||||
:lazy="true"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
|
||||
pt:content="p-2 overflow-auto"
|
||||
>
|
||||
<template #header>
|
||||
<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>
|
||||
<TemplateSearchBar
|
||||
v-model:search-query="searchQuery"
|
||||
:filtered-count="filteredCount"
|
||||
@clear-filters="() => reset()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #list="{ items }">
|
||||
<TemplateWorkflowList
|
||||
:source-module="sourceModule"
|
||||
:templates="items"
|
||||
:loading="loading"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #grid="{ items }">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import DataView from 'primevue/dataview'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
|
||||
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 { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
|
||||
title: string
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: string | null
|
||||
templates: TemplateInfo[]
|
||||
}>()
|
||||
|
||||
const layout = useLocalStorage<'grid' | 'list'>(
|
||||
'Comfy.TemplateWorkflow.Layout',
|
||||
'grid'
|
||||
)
|
||||
|
||||
const skeletonCount = 6
|
||||
const loadTrigger = ref<HTMLElement | null>(null)
|
||||
|
||||
const templatesRef = computed(() => templates || [])
|
||||
|
||||
const { searchQuery, filteredTemplates, filteredCount } =
|
||||
useTemplateFiltering(templatesRef)
|
||||
|
||||
// When searching, show all results immediately without pagination
|
||||
// When not searching, use lazy pagination
|
||||
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
||||
|
||||
// Lazy pagination setup using filtered templates
|
||||
const {
|
||||
paginatedItems: paginatedTemplates,
|
||||
isLoading: isLoadingMore,
|
||||
hasMoreItems: hasMoreTemplates,
|
||||
loadNextPage,
|
||||
reset
|
||||
} = useLazyPagination(filteredTemplates, {
|
||||
itemsPerPage: 12
|
||||
})
|
||||
|
||||
// Final templates to display
|
||||
const displayTemplates = computed(() => {
|
||||
return shouldUsePagination.value
|
||||
? paginatedTemplates.value
|
||||
: filteredTemplates.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, searchQuery], () => {
|
||||
reset()
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
const onLoadWorkflow = (name: string) => {
|
||||
emit('loadWorkflow', name)
|
||||
}
|
||||
</script>
|
||||
112
src/components/templates/TemplateWorkflowsContent.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
|
||||
data-testid="template-workflows-content"
|
||||
>
|
||||
<Button
|
||||
v-if="isSmallScreen"
|
||||
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
|
||||
text
|
||||
class="absolute top-1/2 -translate-y-1/2 z-10"
|
||||
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
|
||||
@click="toggleSideNav"
|
||||
/>
|
||||
<Divider
|
||||
class="m-0 [&::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"
|
||||
>
|
||||
<ProgressSpinner
|
||||
v-if="!isTemplatesLoaded || !isReady"
|
||||
class="absolute w-8 h-full inset-0"
|
||||
/>
|
||||
<TemplateWorkflowsSideNav
|
||||
:tabs="allTemplateGroups"
|
||||
:selected-tab="selectedTemplate"
|
||||
@update:selected-tab="handleTabSelection"
|
||||
/>
|
||||
</aside>
|
||||
<div
|
||||
class="flex-1 transition-all duration-300"
|
||||
:class="{
|
||||
'pl-80': 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const {
|
||||
isSmallScreen,
|
||||
isOpen: isSideNavOpen,
|
||||
toggle: toggleSideNav
|
||||
} = useResponsiveCollapse()
|
||||
|
||||
const {
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
loadWorkflowTemplate
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const { isReady } = useAsyncState(loadTemplates, null)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
() => {
|
||||
if (isReady.value) {
|
||||
selectFirstTemplateCategory()
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
if (selection !== null) {
|
||||
selectTemplateCategory(selection)
|
||||
|
||||
// On small screens, close the sidebar when a category is selected
|
||||
if (isSmallScreen.value) {
|
||||
isSideNavOpen.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadWorkflow = async (id: string) => {
|
||||
if (!isReady.value || !selectedTemplate.value) return false
|
||||
|
||||
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="px-4">
|
||||
<span>{{ $t('templateWorkflows.title') }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
50
src/components/templates/TemplateWorkflowsSideNav.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<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"
|
||||
>
|
||||
<template #optiongroup="slotProps">
|
||||
<div class="text-left py-3 px-12">
|
||||
<h2 class="text-lg">
|
||||
{{ slotProps.option.label }}
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
|
||||
import type {
|
||||
TemplateGroup,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
|
||||
defineProps<{
|
||||
tabs: TemplateGroup[]
|
||||
selectedTab: WorkflowTemplates | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedTab', tab: WorkflowTemplates): void
|
||||
}>()
|
||||
|
||||
const handleTabSelection = (tab: WorkflowTemplates) => {
|
||||
emit('update:selectedTab', tab)
|
||||
}
|
||||
</script>
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<BaseThumbnail>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center p-4"
|
||||
:style="{
|
||||
backgroundImage: 'url(/assets/images/default-template.png)',
|
||||
backgroundRepeat: 'round'
|
||||
}"
|
||||
>
|
||||
<div class="w-full h-full flex items-center justify-center p-4">
|
||||
<audio controls class="w-full relative" :src="src" @click.stop />
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
|
||||
@@ -51,9 +51,8 @@ describe('BaseThumbnail', () => {
|
||||
vm.error = true
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
|
||||
).toBe(true)
|
||||
expect(wrapper.find('.pi-file').exists()).toBe(true)
|
||||
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies transition classes to content', () => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative w-full aspect-square rounded-t-lg overflow-hidden select-none"
|
||||
>
|
||||
<div class="relative w-64 h-64 rounded-t-lg overflow-hidden select-none">
|
||||
<div
|
||||
v-if="!error"
|
||||
ref="contentRef"
|
||||
@@ -13,11 +11,7 @@
|
||||
<slot />
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src="/assets/images/default-template.png"
|
||||
draggable="false"
|
||||
class="transform-gpu transition-transform duration-300 ease-out w-full h-full object-cover"
|
||||
/>
|
||||
<i class="pi pi-file text-4xl" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -127,7 +127,7 @@ const toggleRightPanel = () => {
|
||||
const layoutClasses = cn(
|
||||
'base-widget-layout',
|
||||
'rounded-2xl overflow-hidden relative',
|
||||
'bg-zinc-50 dark-theme:bg-zinc-800'
|
||||
'bg-gray-50 dark-theme:bg-gray-800'
|
||||
)
|
||||
|
||||
const rightPanelButtonClasses = computed(() => {
|
||||
@@ -144,7 +144,7 @@ const closeButtonClasses = cn(
|
||||
|
||||
const mainContainerClasses = cn(
|
||||
'flex-1 flex',
|
||||
'bg-zinc-100 dark-theme:bg-neutral-900'
|
||||
'bg-gray-100 dark-theme:bg-neutral-900'
|
||||
)
|
||||
|
||||
const headerClasses = cn(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<i :class="icon" class="text-sm text-neutral" />
|
||||
<i :class="icon" class="text-xs text-neutral" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
class="flex items-center gap-2 px-4 py-3 text-sm rounded-md transition-colors cursor-pointer"
|
||||
:class="
|
||||
active
|
||||
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
|
||||
: 'text-neutral hover:bg-zinc-100 dark-theme:hover:bg-zinc-700/50'
|
||||
? 'bg-white dark-theme:bg-charcoal-600 text-neutral'
|
||||
: 'text-neutral hover:bg-gray-100 dark-theme:hover:bg-charcoal-300'
|
||||
"
|
||||
role="button"
|
||||
@click="onClick"
|
||||
|
||||
@@ -1,55 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between m-0 px-3 py-0 pt-5',
|
||||
collapsible && 'cursor-pointer select-none'
|
||||
)
|
||||
"
|
||||
@click="collapsible && toggleCollapse()"
|
||||
<h3
|
||||
class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<i
|
||||
v-if="collapsible"
|
||||
:class="
|
||||
cn(
|
||||
'pi transition-transform duration-200 text-xs text-neutral-400 dark-theme:text-neutral-400',
|
||||
isCollapsed ? 'pi-chevron-right' : 'pi-chevron-down'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
{{ title }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
title,
|
||||
modelValue = false,
|
||||
collapsible = false
|
||||
} = defineProps<{
|
||||
const { title } = defineProps<{
|
||||
title: string
|
||||
modelValue?: boolean
|
||||
collapsible?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const isCollapsed = computed({
|
||||
get: () => modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-zinc-800">
|
||||
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-charcoal-600">
|
||||
<PanelHeader>
|
||||
<template #icon>
|
||||
<slot name="header-icon"></slot>
|
||||
@@ -7,27 +7,19 @@
|
||||
<slot name="header-title"></slot>
|
||||
</PanelHeader>
|
||||
|
||||
<nav
|
||||
class="flex-1 px-3 py-4 flex flex-col gap-1 overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
<nav class="flex-1 px-3 py-4 flex flex-col gap-1">
|
||||
<template v-for="(item, index) in navItems" :key="index">
|
||||
<div v-if="'items' in item" class="flex flex-col gap-2">
|
||||
<NavTitle
|
||||
v-model="collapsedGroups[item.title]"
|
||||
:title="item.title"
|
||||
:collapsible="item.collapsible"
|
||||
/>
|
||||
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
|
||||
<NavItem
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.id"
|
||||
:icon="subItem.icon"
|
||||
:active="activeItem === subItem.id"
|
||||
@click="activeItem = subItem.id"
|
||||
>
|
||||
{{ subItem.label }}
|
||||
</NavItem>
|
||||
</template>
|
||||
<NavTitle :title="item.title" />
|
||||
<NavItem
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.id"
|
||||
:icon="subItem.icon"
|
||||
:active="activeItem === subItem.id"
|
||||
@click="activeItem = subItem.id"
|
||||
>
|
||||
{{ subItem.label }}
|
||||
</NavItem>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<NavItem
|
||||
@@ -44,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
@@ -61,9 +53,6 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
}>()
|
||||
|
||||
// Track collapsed state for each group
|
||||
const collapsedGroups = ref<Record<string, boolean>>({})
|
||||
|
||||
const getFirstItemId = () => {
|
||||
if (!navItems || navItems.length === 0) {
|
||||
return null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-zinc-800">
|
||||
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-charcoal-600">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -103,14 +103,22 @@ Utility composables for common patterns:
|
||||
| `useChainCallback` | Chains multiple callbacks together |
|
||||
|
||||
### Manager
|
||||
Composables for ComfyUI Manager integration:
|
||||
Composables for ComfyUI Manager integration (located in
|
||||
`@/workbench/extensions/manager/composables`):
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useManagerStatePersistence` | Persists manager UI state |
|
||||
| `useManagerState` | Determines availability of manager UI modes |
|
||||
| `useManagerQueue` | Handles manager task queue state |
|
||||
| `useConflictAcknowledgment` | Tracks conflict dismissal state |
|
||||
| `useConflictDetection` | Orchestrates conflict detection workflow |
|
||||
| `useImportFailedDetection` | Handles import-failed conflict dialogs |
|
||||
| `useRegistrySearch` | Manages registry search UI state |
|
||||
|
||||
### Node Pack
|
||||
Composables for node package management:
|
||||
#### Node Pack
|
||||
Node package helpers live under
|
||||
`@/workbench/extensions/manager/composables/nodePack`:
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
@@ -118,6 +126,9 @@ Composables for node package management:
|
||||
| `useMissingNodes` | Detects and handles missing nodes |
|
||||
| `useNodePacks` | Core node package functionality |
|
||||
| `usePackUpdateStatus` | Tracks package update availability |
|
||||
| `usePacksSelection` | Provides selection helpers for pack lists |
|
||||
| `usePacksStatus` | Aggregates status across multiple packs |
|
||||
| `useUpdateAvailableNodes` | Detects available updates for nodes |
|
||||
| `useWorkflowPacks` | Manages packages used in workflows |
|
||||
|
||||
### Node
|
||||
@@ -408,4 +419,4 @@ export function useFetchData(url) {
|
||||
}
|
||||
```
|
||||
|
||||
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).
|
||||
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
@@ -28,8 +30,8 @@ export interface VueNodeData {
|
||||
executing: boolean
|
||||
subgraphId?: string | null
|
||||
widgets?: SafeWidgetData[]
|
||||
inputs?: unknown[]
|
||||
outputs?: unknown[]
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
hasErrors?: boolean
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
@@ -433,6 +435,28 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
action === 'node:slot-errors:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as { nodeId: string | number }
|
||||
const nodeId = String(event.nodeId)
|
||||
const litegraphNode = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (litegraphNode && currentData) {
|
||||
// Re-extract slot data with updated hasErrors properties
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
inputs: litegraphNode.inputs
|
||||
? [...litegraphNode.inputs]
|
||||
: undefined,
|
||||
outputs: litegraphNode.outputs
|
||||
? [...litegraphNode.outputs]
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Call original trigger handler if it exists
|
||||
|
||||
@@ -6,78 +6,98 @@
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { createSharedComposable, useThrottleFn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export function useViewportCulling() {
|
||||
type Bounds = [left: number, right: number, top: number, bottom: number]
|
||||
|
||||
function getNodeBounds(node: LGraphNode): Bounds {
|
||||
const [nodeLeft, nodeTop] = node.pos
|
||||
const nodeRight = nodeLeft + node.size[0]
|
||||
const nodeBottom = nodeTop + node.size[1]
|
||||
return [nodeLeft, nodeRight, nodeTop, nodeBottom]
|
||||
}
|
||||
|
||||
function viewportEdges(
|
||||
canvas: ReturnType<typeof useCanvasStore>['canvas']
|
||||
): Bounds | undefined {
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
const ds = canvas.ds
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const margin = 500 * ds.scale
|
||||
|
||||
const [xOffset, yOffset] = ds.offset
|
||||
|
||||
const leftEdge = -margin / ds.scale - xOffset
|
||||
const rightEdge = (viewport_width + margin) / ds.scale - xOffset
|
||||
const topEdge = -margin / ds.scale - yOffset
|
||||
const bottomEdge = (viewport_height + margin) / ds.scale - yOffset
|
||||
return [leftEdge, rightEdge, topEdge, bottomEdge]
|
||||
}
|
||||
|
||||
function boundsIntersect(boxA: Bounds, boxB: Bounds): boolean {
|
||||
const [aLeft, aRight, aTop, aBottom] = boxA
|
||||
const [bLeft, bRight, bTop, bBottom] = boxB
|
||||
|
||||
const leftOf = aRight < bLeft
|
||||
const rightOf = aLeft > bRight
|
||||
const above = aBottom < bTop
|
||||
const below = aTop > bBottom
|
||||
return !(leftOf || rightOf || above || below)
|
||||
}
|
||||
|
||||
function useViewportCullingIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { vueNodeData, nodeManager } = useVueNodeLifecycle()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const allNodes = computed(() => {
|
||||
return Array.from(vueNodeData.value.values())
|
||||
})
|
||||
const viewport = computed(() => viewportEdges(canvasStore.canvas))
|
||||
|
||||
function inViewport(node: LGraphNode | undefined): boolean {
|
||||
if (!viewport.value || !node) {
|
||||
return true
|
||||
}
|
||||
const nodeBounds = getNodeBounds(node)
|
||||
return boundsIntersect(nodeBounds, viewport.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
*/
|
||||
function updateVisibility() {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
|
||||
if (!nodeManager.value || !app.canvas) return // load bearing app.canvas check for workflows being loaded.
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
const manager = nodeManager.value
|
||||
const ds = canvas.ds
|
||||
|
||||
// Viewport bounds
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const margin = 500 * ds.scale
|
||||
|
||||
// Get all node elements at once
|
||||
const nodeElements = document.querySelectorAll('[data-node-id]')
|
||||
|
||||
// Update each element's visibility
|
||||
for (const element of nodeElements) {
|
||||
const nodeId = element.getAttribute('data-node-id')
|
||||
if (!nodeId) continue
|
||||
|
||||
const node = manager.getNode(nodeId)
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
// Calculate if node is outside viewport
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
const isNodeOutsideViewport =
|
||||
screen_x + screen_width < -margin ||
|
||||
screen_x > viewport_width + margin ||
|
||||
screen_y + screen_height < -margin ||
|
||||
screen_y > viewport_height + margin
|
||||
|
||||
// Setting display none directly avoid potential cascade resolution
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.display = isNodeOutsideViewport ? 'none' : ''
|
||||
const displayValue = inViewport(node) ? '' : 'none'
|
||||
if (
|
||||
element instanceof HTMLElement &&
|
||||
element.style.display !== displayValue
|
||||
) {
|
||||
element.style.display = displayValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateVisibilityDebounced = useThrottleFn(updateVisibility, 20)
|
||||
const handleTransformUpdate = useThrottleFn(() => updateVisibility, 100, true)
|
||||
|
||||
// RAF throttling for smooth updates during continuous panning
|
||||
function handleTransformUpdate() {
|
||||
requestAnimationFrame(async () => {
|
||||
await updateVisibilityDebounced()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
allNodes,
|
||||
handleTransformUpdate
|
||||
}
|
||||
return { handleTransformUpdate }
|
||||
}
|
||||
|
||||
export const useViewportCulling = createSharedComposable(
|
||||
useViewportCullingIndividual
|
||||
)
|
||||
|
||||
@@ -51,8 +51,6 @@ import {
|
||||
} from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
@@ -266,7 +264,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Browse Templates',
|
||||
function: () => {
|
||||
useWorkflowTemplateSelectorDialog().show()
|
||||
dialogService.showTemplateWorkflowsDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,205 +1,57 @@
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
// @ts-expect-error unused (To be used later?)
|
||||
interface TemplateFilterOptions {
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
const searchQuery = ref('')
|
||||
const selectedModels = ref<string[]>([])
|
||||
const selectedUseCases = ref<string[]>([])
|
||||
const selectedLicenses = ref<string[]>([])
|
||||
const sortBy = ref<
|
||||
'default' | 'alphabetical' | 'newest' | 'model-size-low-to-high'
|
||||
>('newest')
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
return Array.isArray(templateData) ? templateData : []
|
||||
})
|
||||
|
||||
// Fuse.js configuration for fuzzy search
|
||||
const fuseOptions = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.3 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'description', weight: 0.2 },
|
||||
{ name: 'tags', weight: 0.1 },
|
||||
{ name: 'models', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
}
|
||||
|
||||
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions))
|
||||
|
||||
const availableModels = computed(() => {
|
||||
const modelSet = new Set<string>()
|
||||
templatesArray.value.forEach((template) => {
|
||||
if (Array.isArray(template.models)) {
|
||||
template.models.forEach((model) => modelSet.add(model))
|
||||
}
|
||||
})
|
||||
return Array.from(modelSet).sort()
|
||||
})
|
||||
|
||||
const availableUseCases = computed(() => {
|
||||
const tagSet = new Set<string>()
|
||||
templatesArray.value.forEach((template) => {
|
||||
if (template.tags && Array.isArray(template.tags)) {
|
||||
template.tags.forEach((tag) => tagSet.add(tag))
|
||||
}
|
||||
})
|
||||
return Array.from(tagSet).sort()
|
||||
})
|
||||
|
||||
const availableLicenses = computed(() => {
|
||||
return ['Open Source', 'Closed Source (API Nodes)']
|
||||
})
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
return templatesArray.value
|
||||
const filteredTemplates = computed(() => {
|
||||
const templateData = templatesArray.value
|
||||
if (templateData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||
return results.map((result) => result.item)
|
||||
})
|
||||
|
||||
const filteredByModels = computed(() => {
|
||||
if (selectedModels.value.length === 0) {
|
||||
return filteredBySearch.value
|
||||
if (!searchQuery.value.trim()) {
|
||||
return templateData
|
||||
}
|
||||
|
||||
return filteredBySearch.value.filter((template) => {
|
||||
if (!template.models || !Array.isArray(template.models)) {
|
||||
return false
|
||||
}
|
||||
return selectedModels.value.some((selectedModel) =>
|
||||
template.models?.includes(selectedModel)
|
||||
)
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return templateData.filter((template) => {
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.sourceModule
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const filteredByUseCases = computed(() => {
|
||||
if (selectedUseCases.value.length === 0) {
|
||||
return filteredByModels.value
|
||||
}
|
||||
|
||||
return filteredByModels.value.filter((template) => {
|
||||
if (!template.tags || !Array.isArray(template.tags)) {
|
||||
return false
|
||||
}
|
||||
return selectedUseCases.value.some((selectedTag) =>
|
||||
template.tags?.includes(selectedTag)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const filteredByLicenses = computed(() => {
|
||||
if (selectedLicenses.value.length === 0) {
|
||||
return filteredByUseCases.value
|
||||
}
|
||||
|
||||
return filteredByUseCases.value.filter((template) => {
|
||||
// Check if template has API in its tags or name (indicating it's a closed source API node)
|
||||
const isApiTemplate =
|
||||
template.tags?.includes('API') ||
|
||||
template.name?.toLowerCase().includes('api_')
|
||||
|
||||
return selectedLicenses.value.some((selectedLicense) => {
|
||||
if (selectedLicense === 'Closed Source (API Nodes)') {
|
||||
return isApiTemplate
|
||||
} else if (selectedLicense === 'Open Source') {
|
||||
return !isApiTemplate
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const sortedTemplates = computed(() => {
|
||||
const templates = [...filteredByLicenses.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'alphabetical':
|
||||
return templates.sort((a, b) => {
|
||||
const nameA = a.title || a.name || ''
|
||||
const nameB = b.title || b.name || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
case 'newest':
|
||||
return templates.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()
|
||||
})
|
||||
case 'model-size-low-to-high':
|
||||
return templates.sort((a: any, b: any) => {
|
||||
const sizeA =
|
||||
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
|
||||
const sizeB =
|
||||
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
|
||||
if (sizeA === sizeB) return 0
|
||||
return sizeA - sizeB
|
||||
})
|
||||
case 'default':
|
||||
default:
|
||||
// Keep original order (default order)
|
||||
return templates
|
||||
}
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => sortedTemplates.value)
|
||||
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
selectedModels.value = []
|
||||
selectedUseCases.value = []
|
||||
selectedLicenses.value = []
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
|
||||
const removeModelFilter = (model: string) => {
|
||||
selectedModels.value = selectedModels.value.filter((m) => m !== model)
|
||||
}
|
||||
|
||||
const removeUseCaseFilter = (tag: string) => {
|
||||
selectedUseCases.value = selectedUseCases.value.filter((t) => t !== tag)
|
||||
}
|
||||
|
||||
const removeLicenseFilter = (license: string) => {
|
||||
selectedLicenses.value = selectedLicenses.value.filter((l) => l !== license)
|
||||
}
|
||||
|
||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||
const totalCount = computed(() => templatesArray.value.length)
|
||||
|
||||
return {
|
||||
// State
|
||||
searchQuery,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses,
|
||||
sortBy,
|
||||
|
||||
// Computed
|
||||
filteredTemplates,
|
||||
availableModels,
|
||||
availableUseCases,
|
||||
availableLicenses,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
|
||||
// Methods
|
||||
resetFilters,
|
||||
removeModelFilter,
|
||||
removeUseCaseFilter,
|
||||
removeLicenseFilter
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const DIALOG_KEY = 'global-workflow-template-selector'
|
||||
|
||||
export const useWorkflowTemplateSelectorDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: WorkflowTemplateSelectorDialog,
|
||||
props: {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
content: { class: '!px-0 overflow-hidden h-full !py-0' },
|
||||
root: {
|
||||
style:
|
||||
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { checkMirrorReachable } from '@/utils/networkUtil'
|
||||
|
||||
// Desktop documentation URLs
|
||||
const DESKTOP_DOCS = {
|
||||
WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
|
||||
MACOS: 'https://docs.comfy.org/installation/desktop/macos'
|
||||
} as const
|
||||
|
||||
;(async () => {
|
||||
if (!isElectron()) return
|
||||
|
||||
@@ -159,7 +165,11 @@ import { checkMirrorReachable } from '@/utils/networkUtil'
|
||||
label: 'Desktop User Guide',
|
||||
icon: 'pi pi-book',
|
||||
function() {
|
||||
window.open('https://comfyorg.notion.site/', '_blank')
|
||||
const docsUrl =
|
||||
electronAPI.getPlatform() === 'darwin'
|
||||
? DESKTOP_DOCS.MACOS
|
||||
: DESKTOP_DOCS.WINDOWS
|
||||
window.open(docsUrl, '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
44
src/extensions/core/factories/AudioWidgetFactory.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Factory interface for creating audio widgets
|
||||
* Provides consistent API for both Vue and LiteGraph implementations
|
||||
*/
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export interface WidgetCreationResult {
|
||||
widget: IBaseWidget
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
}
|
||||
|
||||
export interface AudioWidgetFactory {
|
||||
/**
|
||||
* Creates an AUDIO_UI widget for audio playback/preview
|
||||
*/
|
||||
createAudioUI(node: LGraphNode, inputName: string): WidgetCreationResult
|
||||
|
||||
/**
|
||||
* Creates an AUDIOUPLOAD widget for file uploads
|
||||
*/
|
||||
createAudioUpload(node: LGraphNode, inputName: string): WidgetCreationResult
|
||||
|
||||
/**
|
||||
* Creates an AUDIO_RECORD widget for audio recording
|
||||
*/
|
||||
createAudioRecord(node: LGraphNode, inputName: string): WidgetCreationResult
|
||||
|
||||
/**
|
||||
* Hook called before registering node definitions
|
||||
*/
|
||||
beforeRegisterNodeDef?(nodeType: any, nodeData: any): void
|
||||
|
||||
/**
|
||||
* Hook called when node outputs are updated
|
||||
*/
|
||||
onNodeOutputsUpdated?(nodeOutputs: Record<string, any>): void
|
||||
|
||||
/**
|
||||
* Hook called when a node is created (for RecordAudio setup)
|
||||
*/
|
||||
onNodeCreated?(node: LGraphNode): Promise<void>
|
||||
}
|
||||
446
src/extensions/core/factories/LiteGraphAudioWidgetFactory.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* LiteGraph Audio Widget Factory
|
||||
* Creates full DOM-based audio widgets with rich functionality
|
||||
*/
|
||||
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import { t } from '@/i18n'
|
||||
import { type LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IStringWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import { vueWidgetSerializationStore } from '@/stores/vueWidgetSerializationStore'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { api } from '../../../scripts/api'
|
||||
import { app } from '../../../scripts/app'
|
||||
import type {
|
||||
AudioWidgetFactory,
|
||||
WidgetCreationResult
|
||||
} from './AudioWidgetFactory'
|
||||
|
||||
export class LiteGraphAudioWidgetFactory implements AudioWidgetFactory {
|
||||
beforeRegisterNodeDef(nodeType: any, nodeData: any): void {
|
||||
// Add audioUI widget requirement for audio nodes
|
||||
if (
|
||||
[
|
||||
'LoadAudio',
|
||||
'SaveAudio',
|
||||
'PreviewAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus',
|
||||
'RecordAudio'
|
||||
].includes(nodeType.prototype?.comfyClass)
|
||||
) {
|
||||
nodeData.input.required.audioUI = ['AUDIO_UI', {}]
|
||||
}
|
||||
|
||||
// Add upload widget requirement for nodes with audio_upload flag
|
||||
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
|
||||
nodeData.input.required.upload = ['AUDIOUPLOAD', {}]
|
||||
}
|
||||
}
|
||||
|
||||
createAudioUI(node: LGraphNode, inputName: string): WidgetCreationResult {
|
||||
// Create DOM audio element
|
||||
const audio = document.createElement('audio')
|
||||
audio.controls = true
|
||||
audio.classList.add('comfy-audio')
|
||||
audio.setAttribute('name', 'media')
|
||||
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
const isOutputNode = nodeData.output_node
|
||||
if (isOutputNode) {
|
||||
// Hide the audio widget when there is no audio initially
|
||||
audioUIWidget.element.classList.add('empty-audio-widget')
|
||||
|
||||
// Populate the audio widget UI on node execution
|
||||
const onExecuted = node.onExecuted
|
||||
const factory = this // Capture factory reference for callback
|
||||
node.onExecuted = function (message: any) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onExecuted?.apply(this, arguments)
|
||||
const audios = message.audio
|
||||
if (!audios) return
|
||||
const audio = audios[0]
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
factory.getResourceURL(audio.subfolder, audio.filename, audio.type)
|
||||
)
|
||||
audioUIWidget.element.classList.remove('empty-audio-widget')
|
||||
}
|
||||
}
|
||||
|
||||
audioUIWidget.onRemove = useChainCallback(audioUIWidget.onRemove, () => {
|
||||
if (!audioUIWidget.element) return
|
||||
audioUIWidget.element.pause()
|
||||
audioUIWidget.element.src = ''
|
||||
audioUIWidget.element.remove()
|
||||
})
|
||||
|
||||
// Add serialization support for RecordAudio nodes
|
||||
if ((node.constructor as any).comfyClass === 'RecordAudio') {
|
||||
const nodeId = node.id
|
||||
audioUIWidget.serializeValue = async () => {
|
||||
let serializationFn = vueWidgetSerializationStore.get(
|
||||
`${nodeId}-audioUI`
|
||||
)
|
||||
|
||||
// Fallback: try with current node.id in case it changed
|
||||
if (!serializationFn && node.id !== nodeId) {
|
||||
serializationFn = vueWidgetSerializationStore.get(
|
||||
`${node.id}-audioUI`
|
||||
)
|
||||
}
|
||||
|
||||
if (serializationFn) {
|
||||
const result = await serializationFn()
|
||||
|
||||
// Update both LiteGraph widgets for consistency
|
||||
const audioWidget = node.widgets?.find((w) => w.name === 'audio')
|
||||
const audioUIWidget = node.widgets?.find((w) => w.name === 'audioUI')
|
||||
|
||||
if (audioWidget && result) {
|
||||
audioWidget.value = result
|
||||
}
|
||||
if (audioUIWidget && result) {
|
||||
audioUIWidget.value = result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return { widget: audioUIWidget }
|
||||
}
|
||||
|
||||
createAudioUpload(node: LGraphNode, inputName: string): WidgetCreationResult {
|
||||
// Find the related audio widgets
|
||||
const audioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'audio'
|
||||
) as IStringWidget
|
||||
const audioUIWidget = node.widgets?.find(
|
||||
(w) => w.name === 'audioUI'
|
||||
) as unknown as DOMWidget<HTMLAudioElement, string>
|
||||
|
||||
const factory = this // Capture factory reference
|
||||
const onAudioWidgetUpdate = () => {
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
factory.getResourceURL(
|
||||
...factory.splitFilePath(audioWidget.value as string)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Initially load default audio file to audioUIWidget
|
||||
if (audioWidget.value) {
|
||||
onAudioWidgetUpdate()
|
||||
}
|
||||
audioWidget.callback = onAudioWidgetUpdate
|
||||
|
||||
// Load saved audio file widget values if restoring from workflow
|
||||
const onGraphConfigured = node.onGraphConfigured
|
||||
node.onGraphConfigured = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onGraphConfigured?.apply(this, arguments)
|
||||
if (audioWidget.value) {
|
||||
onAudioWidgetUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (files: File[]) => {
|
||||
if (files?.length) {
|
||||
await this.uploadFile(audioWidget, audioUIWidget, files[0], true)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const isAudioFile = (file: File) => file.type.startsWith('audio/')
|
||||
|
||||
const { openFileSelection } = useNodeFileInput(node, {
|
||||
accept: 'audio/*',
|
||||
onSelect: handleUpload
|
||||
})
|
||||
|
||||
// The widget to pop up the upload dialog
|
||||
const uploadWidget = node.addWidget(
|
||||
'button',
|
||||
inputName,
|
||||
'',
|
||||
openFileSelection,
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
uploadWidget.label = t('g.choose_file_to_upload')
|
||||
|
||||
useNodeDragAndDrop(node, {
|
||||
fileFilter: isAudioFile,
|
||||
onDrop: handleUpload
|
||||
})
|
||||
|
||||
useNodePaste(node, {
|
||||
fileFilter: isAudioFile,
|
||||
onPaste: handleUpload
|
||||
})
|
||||
|
||||
node.previewMediaType = 'audio'
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
|
||||
createAudioRecord(node: LGraphNode, inputName: string): WidgetCreationResult {
|
||||
const audio = document.createElement('audio')
|
||||
audio.controls = true
|
||||
audio.classList.add('comfy-audio')
|
||||
audio.setAttribute('name', 'media')
|
||||
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, 'audioUI', audio)
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let isRecording = false
|
||||
let audioChunks: Blob[] = []
|
||||
let currentStream: MediaStream | null = null
|
||||
let recordWidget: IBaseWidget | null = null
|
||||
|
||||
let stopPromise: Promise<void> | null = null
|
||||
let stopResolve: (() => void) | null = null
|
||||
|
||||
audioUIWidget.serializeValue = async () => {
|
||||
if (isRecording && mediaRecorder) {
|
||||
stopPromise = new Promise((resolve) => {
|
||||
stopResolve = resolve
|
||||
})
|
||||
|
||||
mediaRecorder.stop()
|
||||
await stopPromise
|
||||
}
|
||||
|
||||
const audioSrc = audioUIWidget.element.src
|
||||
|
||||
if (!audioSrc) {
|
||||
useToastStore().addAlert(t('g.noAudioRecorded'))
|
||||
return ''
|
||||
}
|
||||
|
||||
const blob = await fetch(audioSrc).then((r) => r.blob())
|
||||
return await useAudioService().convertBlobToFileAndSubmit(blob)
|
||||
}
|
||||
|
||||
recordWidget = node.addWidget(
|
||||
'button',
|
||||
inputName,
|
||||
'',
|
||||
async () => {
|
||||
if (!isRecording) {
|
||||
try {
|
||||
currentStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
})
|
||||
|
||||
mediaRecorder = new ExtendableMediaRecorder(currentStream, {
|
||||
mimeType: 'audio/wav'
|
||||
}) as unknown as MediaRecorder
|
||||
|
||||
audioChunks = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
audioChunks.push(event.data)
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
|
||||
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
|
||||
if (
|
||||
audioUIWidget.element.src &&
|
||||
audioUIWidget.element.src.startsWith('blob:')
|
||||
) {
|
||||
URL.revokeObjectURL(audioUIWidget.element.src)
|
||||
}
|
||||
|
||||
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
|
||||
isRecording = false
|
||||
|
||||
if (recordWidget) {
|
||||
recordWidget.label = t('g.startRecording')
|
||||
}
|
||||
|
||||
if (stopResolve) {
|
||||
stopResolve()
|
||||
stopResolve = null
|
||||
stopPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onerror = (event) => {
|
||||
console.error('MediaRecorder error:', event)
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
isRecording = false
|
||||
|
||||
if (recordWidget) {
|
||||
recordWidget.label = t('g.startRecording')
|
||||
}
|
||||
|
||||
if (stopResolve) {
|
||||
stopResolve()
|
||||
stopResolve = null
|
||||
stopPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
isRecording = true
|
||||
if (recordWidget) {
|
||||
recordWidget.label = t('g.stopRecording')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error accessing microphone:', err)
|
||||
useToastStore().addAlert(t('g.micPermissionDenied'))
|
||||
|
||||
if (mediaRecorder) {
|
||||
try {
|
||||
mediaRecorder.stop()
|
||||
} catch {}
|
||||
}
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
currentStream = null
|
||||
isRecording = false
|
||||
if (recordWidget) {
|
||||
recordWidget.label = t('g.startRecording')
|
||||
}
|
||||
}
|
||||
} else if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
},
|
||||
{ serialize: false }
|
||||
)
|
||||
|
||||
recordWidget.label = t('g.startRecording')
|
||||
|
||||
const originalOnRemoved = node.onRemoved
|
||||
node.onRemoved = function () {
|
||||
if (isRecording && mediaRecorder) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
if (audioUIWidget.element.src?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(audioUIWidget.element.src)
|
||||
}
|
||||
originalOnRemoved?.call(this)
|
||||
}
|
||||
|
||||
return { widget: recordWidget }
|
||||
}
|
||||
|
||||
onNodeOutputsUpdated(nodeOutputs: Record<string, any>): void {
|
||||
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
||||
if ('audio' in output) {
|
||||
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
|
||||
if (!node) continue
|
||||
|
||||
const audioUIWidget = node.widgets?.find(
|
||||
(w) => w.name === 'audioUI'
|
||||
) as unknown as DOMWidget<HTMLAudioElement, string>
|
||||
const audio = output.audio[0]
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
this.getResourceURL(audio.subfolder, audio.filename, audio.type)
|
||||
)
|
||||
audioUIWidget.element.classList.remove('empty-audio-widget')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onNodeCreated(node: LGraphNode): Promise<void> {
|
||||
if ((node.constructor as any).comfyClass === 'RecordAudio') {
|
||||
await useAudioService().registerWavEncoder()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private splitFilePath(path: string): [string, string] {
|
||||
const folder_separator = path.lastIndexOf('/')
|
||||
if (folder_separator === -1) {
|
||||
return ['', path]
|
||||
}
|
||||
return [
|
||||
path.substring(0, folder_separator),
|
||||
path.substring(folder_separator + 1)
|
||||
]
|
||||
}
|
||||
|
||||
private getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
].join('&')
|
||||
return `/view?${params}`
|
||||
}
|
||||
|
||||
private async uploadFile(
|
||||
audioWidget: IStringWidget,
|
||||
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
||||
file: File,
|
||||
updateNode: boolean,
|
||||
pasted: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (pasted) body.append('subfolder', 'pasted')
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status === 200) {
|
||||
const data = await resp.json()
|
||||
let path = data.name
|
||||
if (data.subfolder) path = data.subfolder + '/' + path
|
||||
|
||||
if (!audioWidget.options?.values?.includes(path)) {
|
||||
audioWidget.options = audioWidget.options || {}
|
||||
audioWidget.options.values = audioWidget.options.values || []
|
||||
audioWidget.options.values.push(path)
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
this.getResourceURL(...this.splitFilePath(path))
|
||||
)
|
||||
audioWidget.value = path
|
||||
}
|
||||
} else {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
145
src/extensions/core/factories/VueAudioWidgetFactory.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Vue Audio Widget Factory
|
||||
* Creates minimal placeholder widgets that Vue components can recognize and render
|
||||
*/
|
||||
import { t } from '@/i18n'
|
||||
import { type LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import { vueWidgetSerializationStore } from '@/stores/vueWidgetSerializationStore'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { api } from '../../../scripts/api'
|
||||
import { app } from '../../../scripts/app'
|
||||
import type {
|
||||
AudioWidgetFactory,
|
||||
WidgetCreationResult
|
||||
} from './AudioWidgetFactory'
|
||||
|
||||
export class VueAudioWidgetFactory implements AudioWidgetFactory {
|
||||
beforeRegisterNodeDef(nodeType: any, nodeData: any): void {
|
||||
// Add audioUI widget requirement for audio nodes
|
||||
if (
|
||||
[
|
||||
'LoadAudio',
|
||||
'SaveAudio',
|
||||
'PreviewAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus',
|
||||
'RecordAudio'
|
||||
].includes(nodeType.prototype?.comfyClass)
|
||||
) {
|
||||
nodeData.input.required.audioUI = ['AUDIO_UI', {}]
|
||||
}
|
||||
|
||||
// Add upload widget requirement for nodes with audio_upload flag
|
||||
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
|
||||
nodeData.input.required.upload = ['AUDIOUPLOAD', {}]
|
||||
}
|
||||
}
|
||||
|
||||
createAudioUI(node: LGraphNode, inputName: string): WidgetCreationResult {
|
||||
// Create simple placeholder widget for Vue
|
||||
const audioUIWidget = node.addWidget('custom', inputName, '', () => {}, {
|
||||
serialize: false
|
||||
}) as IBaseWidget
|
||||
|
||||
// Set the type that Vue components expect
|
||||
audioUIWidget.type = 'AUDIO_UI'
|
||||
|
||||
// Add serialization support for RecordAudio nodes
|
||||
if ((node.constructor as any).comfyClass === 'RecordAudio') {
|
||||
audioUIWidget.serializeValue = async () => {
|
||||
const serializationFn = vueWidgetSerializationStore.get(
|
||||
`${node.id}-audioUI`
|
||||
)
|
||||
if (serializationFn) {
|
||||
const result = await serializationFn()
|
||||
audioUIWidget.value = result
|
||||
return result
|
||||
}
|
||||
return audioUIWidget.value || ''
|
||||
}
|
||||
}
|
||||
|
||||
return { widget: audioUIWidget }
|
||||
}
|
||||
|
||||
createAudioUpload(node: LGraphNode, inputName: string): WidgetCreationResult {
|
||||
// Create simple placeholder widget for Vue
|
||||
const uploadWidget = node.addWidget('button', inputName, '', () => {}, {
|
||||
serialize: false
|
||||
}) as IBaseWidget
|
||||
|
||||
// Set the type that Vue components expect
|
||||
uploadWidget.type = 'AUDIOUPLOAD'
|
||||
uploadWidget.label = t('g.choose_file_to_upload')
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
|
||||
createAudioRecord(node: LGraphNode, inputName: string): WidgetCreationResult {
|
||||
// Create simple placeholder widget for Vue
|
||||
const recordWidget = node.addWidget('custom', inputName, '', () => {}, {
|
||||
serialize: true
|
||||
}) as IBaseWidget
|
||||
|
||||
// Set the type that Vue components expect
|
||||
recordWidget.type = 'AUDIO_RECORD'
|
||||
|
||||
// Set up serialization bridge for RecordAudio
|
||||
recordWidget.serializeValue = async () => {
|
||||
const serializationFn = vueWidgetSerializationStore.get(
|
||||
`${node.id}-audioUI`
|
||||
)
|
||||
if (serializationFn) {
|
||||
const result = await serializationFn()
|
||||
recordWidget.value = result
|
||||
return result
|
||||
}
|
||||
return recordWidget.value || ''
|
||||
}
|
||||
|
||||
return { widget: recordWidget }
|
||||
}
|
||||
|
||||
onNodeOutputsUpdated(nodeOutputs: Record<string, any>): void {
|
||||
// Helper function to get resource URL
|
||||
function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
].join('&')
|
||||
return `/view?${params}`
|
||||
}
|
||||
|
||||
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
||||
if ('audio' in output) {
|
||||
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
|
||||
if (!node) continue
|
||||
|
||||
const audioUIWidget = node.widgets?.find(
|
||||
(w) => w.name === 'audioUI'
|
||||
) as IBaseWidget
|
||||
if (audioUIWidget) {
|
||||
const audio = output.audio[0]
|
||||
audioUIWidget.value = api.apiURL(
|
||||
getResourceURL(audio.subfolder, audio.filename, audio.type)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onNodeCreated(node: LGraphNode): Promise<void> {
|
||||
if ((node.constructor as any).comfyClass === 'RecordAudio') {
|
||||
await useAudioService().registerWavEncoder()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,428 +1,55 @@
|
||||
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
|
||||
/**
|
||||
* Audio Widget Extension - Factory Pattern Implementation
|
||||
* Dynamically selects between Vue and LiteGraph implementations
|
||||
*/
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IStringWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import { type NodeLocatorId } from '@/types'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { api } from '../../scripts/api'
|
||||
import { app } from '../../scripts/app'
|
||||
import type { AudioWidgetFactory } from './factories/AudioWidgetFactory'
|
||||
import { LiteGraphAudioWidgetFactory } from './factories/LiteGraphAudioWidgetFactory'
|
||||
import { VueAudioWidgetFactory } from './factories/VueAudioWidgetFactory'
|
||||
|
||||
function splitFilePath(path: string): [string, string] {
|
||||
const folder_separator = path.lastIndexOf('/')
|
||||
if (folder_separator === -1) {
|
||||
return ['', path]
|
||||
}
|
||||
return [
|
||||
path.substring(0, folder_separator),
|
||||
path.substring(folder_separator + 1)
|
||||
]
|
||||
}
|
||||
// Create the appropriate factory based on the current mode
|
||||
const audioFactory: AudioWidgetFactory = LiteGraph.vueNodesMode
|
||||
? new VueAudioWidgetFactory()
|
||||
: new LiteGraphAudioWidgetFactory()
|
||||
|
||||
function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: ResultItemType = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
audioWidget: IStringWidget,
|
||||
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
||||
file: File,
|
||||
updateNode: boolean,
|
||||
pasted: boolean = false
|
||||
) {
|
||||
try {
|
||||
// Wrap file in formdata so it includes filename
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (pasted) body.append('subfolder', 'pasted')
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status === 200) {
|
||||
const data = await resp.json()
|
||||
// Add the file to the dropdown list and update the widget value
|
||||
let path = data.name
|
||||
if (data.subfolder) path = data.subfolder + '/' + path
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
if (!audioWidget.options.values.includes(path)) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
audioWidget.options.values.push(path)
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
getResourceURL(...splitFilePath(path))
|
||||
)
|
||||
audioWidget.value = path
|
||||
}
|
||||
} else {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
useToastStore().addAlert(error)
|
||||
}
|
||||
}
|
||||
|
||||
// AudioWidget MUST be registered first, as AUDIOUPLOAD depends on AUDIO_UI to be
|
||||
// present.
|
||||
// AudioWidget MUST be registered first, as AUDIOUPLOAD depends on AUDIO_UI to be present
|
||||
app.registerExtension({
|
||||
name: 'Comfy.AudioWidget',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (
|
||||
[
|
||||
'LoadAudio',
|
||||
'SaveAudio',
|
||||
'PreviewAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus'
|
||||
].includes(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
nodeType.prototype.comfyClass
|
||||
)
|
||||
) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
nodeData.input.required.audioUI = ['AUDIO_UI', {}]
|
||||
}
|
||||
audioFactory.beforeRegisterNodeDef?.(nodeType, nodeData)
|
||||
},
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
AUDIO_UI(node: LGraphNode, inputName: string) {
|
||||
const audio = document.createElement('audio')
|
||||
audio.controls = true
|
||||
audio.classList.add('comfy-audio')
|
||||
audio.setAttribute('name', 'media')
|
||||
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
const isOutputNode = nodeData.output_node
|
||||
if (isOutputNode) {
|
||||
// Hide the audio widget when there is no audio initially.
|
||||
audioUIWidget.element.classList.add('empty-audio-widget')
|
||||
// Populate the audio widget UI on node execution.
|
||||
const onExecuted = node.onExecuted
|
||||
node.onExecuted = function (message: any) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onExecuted?.apply(this, arguments)
|
||||
const audios = message.audio
|
||||
if (!audios) return
|
||||
const audio = audios[0]
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
getResourceURL(audio.subfolder, audio.filename, audio.type)
|
||||
)
|
||||
audioUIWidget.element.classList.remove('empty-audio-widget')
|
||||
}
|
||||
}
|
||||
|
||||
audioUIWidget.onRemove = useChainCallback(
|
||||
audioUIWidget.onRemove,
|
||||
() => {
|
||||
if (!audioUIWidget.element) return
|
||||
audioUIWidget.element.pause()
|
||||
audioUIWidget.element.src = ''
|
||||
audioUIWidget.element.remove()
|
||||
}
|
||||
)
|
||||
|
||||
return { widget: audioUIWidget }
|
||||
}
|
||||
AUDIO_UI: audioFactory.createAudioUI.bind(audioFactory)
|
||||
}
|
||||
},
|
||||
onNodeOutputsUpdated(nodeOutputs: Record<NodeLocatorId, any>) {
|
||||
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
||||
if ('audio' in output) {
|
||||
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
|
||||
if (!node) continue
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const audioUIWidget = node.widgets.find(
|
||||
(w) => w.name === 'audioUI'
|
||||
) as unknown as DOMWidget<HTMLAudioElement, string>
|
||||
const audio = output.audio[0]
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
getResourceURL(audio.subfolder, audio.filename, audio.type)
|
||||
)
|
||||
audioUIWidget.element.classList.remove('empty-audio-widget')
|
||||
}
|
||||
}
|
||||
onNodeOutputsUpdated(nodeOutputs: Record<string, any>) {
|
||||
audioFactory.onNodeOutputsUpdated?.(nodeOutputs)
|
||||
}
|
||||
})
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.UploadAudio',
|
||||
async beforeRegisterNodeDef(_nodeType, nodeData: ComfyNodeDef) {
|
||||
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
|
||||
nodeData.input.required.upload = ['AUDIOUPLOAD', {}]
|
||||
}
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
audioFactory.beforeRegisterNodeDef?.(nodeType, nodeData)
|
||||
},
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
AUDIOUPLOAD(node, inputName: string) {
|
||||
// The widget that allows user to select file.
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const audioWidget = node.widgets.find(
|
||||
(w) => w.name === 'audio'
|
||||
) as IStringWidget
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const audioUIWidget = node.widgets.find(
|
||||
(w) => w.name === 'audioUI'
|
||||
) as unknown as DOMWidget<HTMLAudioElement, string>
|
||||
|
||||
const onAudioWidgetUpdate = () => {
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
getResourceURL(...splitFilePath(audioWidget.value as string))
|
||||
)
|
||||
}
|
||||
// Initially load default audio file to audioUIWidget.
|
||||
if (audioWidget.value) {
|
||||
onAudioWidgetUpdate()
|
||||
}
|
||||
audioWidget.callback = onAudioWidgetUpdate
|
||||
|
||||
// Load saved audio file widget values if restoring from workflow
|
||||
const onGraphConfigured = node.onGraphConfigured
|
||||
node.onGraphConfigured = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onGraphConfigured?.apply(this, arguments)
|
||||
if (audioWidget.value) {
|
||||
onAudioWidgetUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (files: File[]) => {
|
||||
if (files?.length) {
|
||||
uploadFile(audioWidget, audioUIWidget, files[0], true)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const isAudioFile = (file: File) => file.type.startsWith('audio/')
|
||||
|
||||
const { openFileSelection } = useNodeFileInput(node, {
|
||||
accept: 'audio/*',
|
||||
onSelect: handleUpload
|
||||
})
|
||||
|
||||
// The widget to pop up the upload dialog.
|
||||
const uploadWidget = node.addWidget(
|
||||
'button',
|
||||
inputName,
|
||||
'',
|
||||
openFileSelection,
|
||||
{ serialize: false }
|
||||
)
|
||||
uploadWidget.label = t('g.choose_file_to_upload')
|
||||
|
||||
useNodeDragAndDrop(node, {
|
||||
fileFilter: isAudioFile,
|
||||
onDrop: handleUpload
|
||||
})
|
||||
|
||||
useNodePaste(node, {
|
||||
fileFilter: isAudioFile,
|
||||
onPaste: handleUpload
|
||||
})
|
||||
|
||||
node.previewMediaType = 'audio'
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
AUDIOUPLOAD: audioFactory.createAudioUpload.bind(audioFactory)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.RecordAudio',
|
||||
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
AUDIO_RECORD(node, inputName: string) {
|
||||
const audio = document.createElement('audio')
|
||||
audio.controls = true
|
||||
audio.classList.add('comfy-audio')
|
||||
audio.setAttribute('name', 'media')
|
||||
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let isRecording = false
|
||||
let audioChunks: Blob[] = []
|
||||
let currentStream: MediaStream | null = null
|
||||
let recordWidget: IBaseWidget | null = null
|
||||
|
||||
let stopPromise: Promise<void> | null = null
|
||||
let stopResolve: (() => void) | null = null
|
||||
|
||||
audioUIWidget.serializeValue = async () => {
|
||||
if (isRecording && mediaRecorder) {
|
||||
stopPromise = new Promise((resolve) => {
|
||||
stopResolve = resolve
|
||||
})
|
||||
|
||||
mediaRecorder.stop()
|
||||
|
||||
await stopPromise
|
||||
}
|
||||
|
||||
const audioSrc = audioUIWidget.element.src
|
||||
|
||||
if (!audioSrc) {
|
||||
useToastStore().addAlert(t('g.noAudioRecorded'))
|
||||
return ''
|
||||
}
|
||||
|
||||
const blob = await fetch(audioSrc).then((r) => r.blob())
|
||||
|
||||
return await useAudioService().convertBlobToFileAndSubmit(blob)
|
||||
}
|
||||
|
||||
recordWidget = node.addWidget(
|
||||
'button',
|
||||
inputName,
|
||||
'',
|
||||
async () => {
|
||||
if (!isRecording) {
|
||||
try {
|
||||
currentStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
})
|
||||
|
||||
mediaRecorder = new ExtendableMediaRecorder(currentStream, {
|
||||
mimeType: 'audio/wav'
|
||||
}) as unknown as MediaRecorder
|
||||
|
||||
audioChunks = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
audioChunks.push(event.data)
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
|
||||
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
|
||||
if (
|
||||
audioUIWidget.element.src &&
|
||||
audioUIWidget.element.src.startsWith('blob:')
|
||||
) {
|
||||
URL.revokeObjectURL(audioUIWidget.element.src)
|
||||
}
|
||||
|
||||
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
|
||||
|
||||
isRecording = false
|
||||
|
||||
if (recordWidget) {
|
||||
recordWidget.label = t('g.startRecording')
|
||||
}
|
||||
|
||||
if (stopResolve) {
|
||||
stopResolve()
|
||||
stopResolve = null
|
||||
stopPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onerror = (event) => {
|
||||
console.error('MediaRecorder error:', event)
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
isRecording = false
|
||||
|
||||
if (recordWidget) {
|
||||
recordWidget.label = t('g.startRecording')
|
||||
}
|
||||
|
||||
if (stopResolve) {
|
||||
stopResolve()
|
||||
stopResolve = null
|
||||
stopPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
isRecording = true
|
||||
if (recordWidget) {
|
||||
recordWidget.label = t('g.stopRecording')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error accessing microphone:', err)
|
||||
useToastStore().addAlert(t('g.micPermissionDenied'))
|
||||
|
||||
if (mediaRecorder) {
|
||||
try {
|
||||
mediaRecorder.stop()
|
||||
} catch {}
|
||||
}
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
currentStream = null
|
||||
isRecording = false
|
||||
if (recordWidget) {
|
||||
recordWidget.label = t('g.startRecording')
|
||||
}
|
||||
}
|
||||
} else if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
},
|
||||
{ serialize: false }
|
||||
)
|
||||
|
||||
recordWidget.label = t('g.startRecording')
|
||||
|
||||
const originalOnRemoved = node.onRemoved
|
||||
node.onRemoved = function () {
|
||||
if (isRecording && mediaRecorder) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
if (audioUIWidget.element.src?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(audioUIWidget.element.src)
|
||||
}
|
||||
originalOnRemoved?.call(this)
|
||||
}
|
||||
|
||||
return { widget: recordWidget }
|
||||
}
|
||||
AUDIO_RECORD: audioFactory.createAudioRecord.bind(audioFactory)
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'RecordAudio') return
|
||||
|
||||
await useAudioService().registerWavEncoder()
|
||||
await audioFactory.onNodeCreated?.(node)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -169,7 +169,6 @@
|
||||
"nodesRunning": "nodes running",
|
||||
"duplicate": "Duplicate",
|
||||
"moreWorkflows": "More workflows",
|
||||
"seeTutorial": "See a tutorial",
|
||||
"nodeRenderError": "Node Render Error",
|
||||
"nodeContentError": "Node Content Error",
|
||||
"nodeHeaderError": "Node Header Error",
|
||||
@@ -686,7 +685,6 @@
|
||||
"ComfyUI Examples": "ComfyUI Examples",
|
||||
"Custom Nodes": "Custom Nodes",
|
||||
"Basics": "Basics",
|
||||
"GettingStarted": "Getting Started",
|
||||
"Flux": "Flux",
|
||||
"ControlNet": "ControlNet",
|
||||
"Upscaling": "Upscaling",
|
||||
@@ -695,7 +693,6 @@
|
||||
"Area Composition": "Area Composition",
|
||||
"3D": "3D",
|
||||
"Audio": "Audio",
|
||||
"LLMs": "LLMs",
|
||||
"Image API": "Image API",
|
||||
"Video API": "Video API",
|
||||
"LLM API": "LLM API",
|
||||
@@ -1004,24 +1001,6 @@
|
||||
"audio_ace_step_1_t2a_song": "ACE Step v1 Text to Song",
|
||||
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Editing"
|
||||
}
|
||||
},
|
||||
"categories": "Categories",
|
||||
"resetFilters": "Clear Filters",
|
||||
"sorting": "Sort by",
|
||||
"activeFilters": "Filters:",
|
||||
"loading": "Loading templates...",
|
||||
"noResults": "No templates found",
|
||||
"noResultsHint": "Try adjusting your search or filters",
|
||||
"modelFilter": "Model Filter",
|
||||
"modelsSelected": "{count} Models",
|
||||
"useCasesSelected": "{count} Use Cases",
|
||||
"licensesSelected": "{count} Licenses",
|
||||
"resultsCount": "Showing {count} of {total} templates",
|
||||
"sort": {
|
||||
"recommended": "Recommended",
|
||||
"alphabetical": "A → Z",
|
||||
"newest": "Newest",
|
||||
"searchPlaceholder": "Search..."
|
||||
}
|
||||
},
|
||||
"graphCanvasMenu": {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const Default: Story = {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
showLeftPanel: true
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #contentFilter>
|
||||
<AssetFilterBar :assets="assets" @filter-change="updateFilters" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<AssetGrid
|
||||
:assets="filteredAssets"
|
||||
@@ -42,6 +46,7 @@ import { computed, provide } from 'vue'
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
@@ -70,19 +75,20 @@ const {
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
selectAssetWithCallback
|
||||
selectAssetWithCallback,
|
||||
updateFilters
|
||||
} = useAssetBrowser(props.assets)
|
||||
|
||||
const shouldShowLeftPanel = computed(() => {
|
||||
return props.showLeftPanel ?? true
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
function handleClose() {
|
||||
props.onClose?.()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleAssetSelectAndEmit = async (asset: AssetDisplayItem) => {
|
||||
async function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
||||
emit('asset-select', asset)
|
||||
await selectAssetWithCallback(asset.id, props.onSelect)
|
||||
}
|
||||
|
||||
@@ -8,19 +8,17 @@
|
||||
cn(
|
||||
// Base layout and container styles (always applied)
|
||||
'rounded-xl overflow-hidden transition-all duration-200',
|
||||
interactive && 'group',
|
||||
// Button-specific styles
|
||||
interactive && [
|
||||
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
|
||||
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
|
||||
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
|
||||
'focus:outline-none focus:transform focus:-translate-y-0.5 focus:shadow-lg focus:shadow-black/10 dark-theme:focus:shadow-black/30'
|
||||
'bg-gray-100 dark-theme:bg-charcoal-800',
|
||||
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
|
||||
'border-none',
|
||||
'focus:outline-solid outline-blue-100 outline-4'
|
||||
],
|
||||
// Div-specific styles
|
||||
!interactive && [
|
||||
'bg-ivory-100 border border-gray-300',
|
||||
'dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600'
|
||||
]
|
||||
!interactive && 'bg-gray-100 dark-theme:bg-charcoal-800'
|
||||
)
|
||||
"
|
||||
@click="interactive && $emit('select', asset)"
|
||||
@@ -32,7 +30,7 @@
|
||||
></div>
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
</div>
|
||||
<div class="p-4 h-32 flex flex-col justify-between">
|
||||
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
|
||||
<div>
|
||||
<h3
|
||||
:class="
|
||||
@@ -49,8 +47,8 @@
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||
'text-stone-300',
|
||||
'dark-theme:text-stone-200'
|
||||
'text-stone-100',
|
||||
'dark-theme:text-slate-100'
|
||||
)
|
||||
"
|
||||
:title="asset.description"
|
||||
|
||||
135
src/platform/assets/components/AssetFilterBar.stories.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import {
|
||||
createAssetWithSpecificBaseModel,
|
||||
createAssetWithSpecificExtension,
|
||||
createAssetWithoutBaseModel,
|
||||
createAssetWithoutExtension
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
import AssetFilterBar from './AssetFilterBar.vue'
|
||||
|
||||
const meta: Meta<typeof AssetFilterBar> = {
|
||||
title: 'Platform/Assets/AssetFilterBar',
|
||||
component: AssetFilterBar,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Filter bar for asset browser that dynamically shows/hides filters based on available options.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div class="min-h-screen bg-white dark-theme:bg-charcoal-900">
|
||||
<div class="bg-gray-50 dark-theme:bg-charcoal-800 border-b border-gray-200 dark-theme:border-charcoal-600">
|
||||
<story />
|
||||
</div>
|
||||
<div class="p-6 text-sm text-gray-600 dark-theme:text-gray-400">
|
||||
<p>Filter bar with proper chrome styling showing contextual background and borders.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
assets: {
|
||||
description: 'Array of assets to generate filter options from'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const BothFiltersVisible: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificExtension('ckpt'),
|
||||
createAssetWithSpecificBaseModel('sd15'),
|
||||
createAssetWithSpecificBaseModel('sdxl')
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows both file format and base model filters when assets contain both types of options.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OnlyFileFormatFilter: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
// Assets with extensions but explicitly NO base models
|
||||
{
|
||||
...createAssetWithSpecificExtension('safetensors'),
|
||||
user_metadata: undefined
|
||||
},
|
||||
{ ...createAssetWithSpecificExtension('ckpt'), user_metadata: undefined }
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows only file format filter when assets have file extensions but no base model metadata.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OnlyBaseModelFilter: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
// Assets with base models but no recognizable extensions
|
||||
{
|
||||
...createAssetWithSpecificBaseModel('sd15'),
|
||||
name: 'model_without_extension'
|
||||
},
|
||||
{ ...createAssetWithSpecificBaseModel('sdxl'), name: 'another_model' }
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows only base model filter when assets have base model metadata but no recognizable file extensions.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NoFiltersVisible: Story = {
|
||||
args: {
|
||||
assets: []
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows no filters when no assets are provided or assets contain no filterable options.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NoFiltersFromAssetsWithoutOptions: Story = {
|
||||
args: {
|
||||
assets: [createAssetWithoutExtension(), createAssetWithoutBaseModel()]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows no filters when assets are provided but contain no filterable options (no extensions or base models).'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,20 @@
|
||||
<div :class="containerClasses" data-component-id="asset-filter-bar">
|
||||
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
||||
<MultiSelect
|
||||
v-if="availableFileFormats.length > 0"
|
||||
v-model="fileFormats"
|
||||
:label="$t('assetBrowser.fileFormats')"
|
||||
:options="fileFormatOptions"
|
||||
:options="availableFileFormats"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-file-formats"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-if="availableBaseModels.length > 0"
|
||||
v-model="baseModels"
|
||||
:label="$t('assetBrowser.baseModels')"
|
||||
:options="baseModelOptions"
|
||||
:options="availableBaseModels"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-base-models"
|
||||
@update:model-value="handleFilterChange"
|
||||
@@ -44,6 +46,8 @@ import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface FilterState {
|
||||
@@ -52,25 +56,16 @@ export interface FilterState {
|
||||
sortBy: string
|
||||
}
|
||||
|
||||
const { assets = [] } = defineProps<{
|
||||
assets?: AssetItem[]
|
||||
}>()
|
||||
|
||||
const fileFormats = ref<SelectOption[]>([])
|
||||
const baseModels = ref<SelectOption[]>([])
|
||||
const sortBy = ref('name-asc')
|
||||
|
||||
// TODO: Make fileFormatOptions configurable via props or assetService
|
||||
// Should support dynamic file formats based on available assets or server capabilities
|
||||
const fileFormatOptions = [
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.safetensors', value: 'safetensors' },
|
||||
{ name: '.pt', value: 'pt' }
|
||||
]
|
||||
|
||||
// TODO: Make baseModelOptions configurable via props or assetService
|
||||
// Should support dynamic base models based on available assets or server detection
|
||||
const baseModelOptions = [
|
||||
{ name: 'SD 1.5', value: 'sd15' },
|
||||
{ name: 'SD XL', value: 'sdxl' },
|
||||
{ name: 'SD 3.5', value: 'sd35' }
|
||||
]
|
||||
const { availableFileFormats, availableBaseModels } =
|
||||
useAssetFilterOptions(assets)
|
||||
|
||||
// TODO: Make sortOptions configurable via props
|
||||
// Different asset types might need different sorting options
|
||||
|
||||