Compare commits
17 Commits
austin/wid
...
schema-as-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f94873ae3 | ||
|
|
b717a21a6a | ||
|
|
d9abeec10f | ||
|
|
240255d9e9 | ||
|
|
c5b5fb4021 | ||
|
|
0cebc52fd2 | ||
|
|
ca243be027 | ||
|
|
1d908e18fa | ||
|
|
edcbcdfa84 | ||
|
|
1f4a52ca3e | ||
|
|
f2e925de62 | ||
|
|
5f7c7ca949 | ||
|
|
62441fa0f9 | ||
|
|
c96f719f91 | ||
|
|
9f19d8fb4b | ||
|
|
d954336973 | ||
|
|
49f373c46f |
6
.github/workflows/i18n-custom-nodes.yaml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
working-directory: ComfyUI
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
run: pnpm exec playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
run: pnpm dev:electron &
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Capture base i18n
|
||||
run: npx tsx scripts/diff-i18n capture
|
||||
run: pnpm exec tsx scripts/diff-i18n capture
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Diff base vs updated i18n
|
||||
run: npx tsx scripts/diff-i18n diff
|
||||
run: pnpm exec tsx scripts/diff-i18n diff
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update i18n in custom node repository
|
||||
run: |
|
||||
|
||||
2
.github/workflows/i18n-node-defs.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
run: pnpm exec playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
|
||||
2
.github/workflows/i18n.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
run: pnpm exec playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
|
||||
4
.github/workflows/test-browser-exp.yaml
vendored
@@ -19,11 +19,11 @@ jobs:
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
run: pnpm exec playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Run Playwright tests and update snapshots
|
||||
id: playwright-tests
|
||||
run: npx playwright test --update-snapshots
|
||||
run: pnpm exec playwright test --update-snapshots
|
||||
continue-on-error: true
|
||||
working-directory: ComfyUI_frontend
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
8
.github/workflows/test-ui.yaml
vendored
@@ -148,7 +148,7 @@ jobs:
|
||||
|
||||
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
id: playwright
|
||||
run: npx playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
working-directory: ComfyUI_frontend
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ../blob-report
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
run: |
|
||||
# Run tests with both HTML and JSON reporters
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
npx playwright test --project=${{ matrix.browser }} \
|
||||
pnpm exec playwright test --project=${{ matrix.browser }} \
|
||||
--reporter=list \
|
||||
--reporter=html \
|
||||
--reporter=json
|
||||
@@ -281,10 +281,10 @@ jobs:
|
||||
- name: Merge into HTML Report
|
||||
run: |
|
||||
# Generate HTML report
|
||||
npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
|
||||
# Generate JSON report separately with explicit output path
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
npx playwright merge-reports --reporter=json ./all-blob-reports
|
||||
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Upload HTML report
|
||||
|
||||
2
.github/workflows/update-manager-types.yaml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
- name: Generate Manager API types
|
||||
run: |
|
||||
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
|
||||
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
|
||||
pnpm dlx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
|
||||
|
||||
- name: Validate generated types
|
||||
run: |
|
||||
|
||||
2
.github/workflows/update-registry-types.yaml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
- name: Generate API types
|
||||
run: |
|
||||
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
|
||||
npx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
|
||||
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
|
||||
|
||||
- name: Validate generated types
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
pnpm exec lint-staged
|
||||
pnpm exec tsx scripts/check-unused-i18n-keys.ts
|
||||
pnpm exec tsx scripts/check-unused-i18n-keys.ts
|
||||
@@ -45,7 +45,7 @@ const config: StorybookConfig = {
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
comfy: FileSystemIconLoader(
|
||||
process.cwd() + '/src/assets/icons/custom'
|
||||
process.cwd() + '/packages/design-system/src/icons'
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -265,9 +265,9 @@ The project supports three types of icons, all with automatic imports (no manual
|
||||
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i-lucide:settings />`, `<i-mdi:folder />`
|
||||
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
|
||||
|
||||
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `src/assets/icons/custom/` and processed by `build/customIconCollection.ts` with automatic validation.
|
||||
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.ts` with automatic validation.
|
||||
|
||||
For detailed instructions and code examples, see [src/assets/icons/README.md](src/assets/icons/README.md).
|
||||
For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md).
|
||||
|
||||
## Working with litegraph.js
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium --with-deps
|
||||
pnpm exec playwright install chromium --with-deps
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
@@ -73,7 +73,7 @@ For tests that specifically need to test release functionality, see the example
|
||||
**Always use UI mode for development:**
|
||||
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
pnpm exec playwright test --ui
|
||||
```
|
||||
|
||||
UI mode features:
|
||||
@@ -89,8 +89,8 @@ UI mode features:
|
||||
For CI or headless testing:
|
||||
|
||||
```bash
|
||||
npx playwright test # Run all tests
|
||||
npx playwright test widget.spec.ts # Run specific test file
|
||||
pnpm exec playwright test # Run all tests
|
||||
pnpm exec playwright test widget.spec.ts # Run specific test file
|
||||
```
|
||||
|
||||
### Local Development Config
|
||||
@@ -386,7 +386,7 @@ export default defineConfig({
|
||||
Option 2 - Generate local baselines for comparison:
|
||||
|
||||
```bash
|
||||
npx playwright test --update-snapshots
|
||||
pnpm exec playwright test --update-snapshots
|
||||
```
|
||||
|
||||
### Creating New Screenshot Baselines
|
||||
|
||||
@@ -80,6 +80,12 @@ 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()
|
||||
|
||||
@@ -102,48 +108,72 @@ test.describe('Templates', () => {
|
||||
expect(await comfyPage.templates.content.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
test('Uses title field as fallback when the key is not found in locales', async ({
|
||||
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 ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
// 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) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
||||
// Expect the title to be used as fallback for template cards
|
||||
// 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
|
||||
await expect(
|
||||
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
|
||||
comfyPage.templates.content.getByRole('heading', {
|
||||
name: 'Image Generation'
|
||||
})
|
||||
).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 ({
|
||||
@@ -151,27 +181,33 @@ test.describe('Templates', () => {
|
||||
}) => {
|
||||
// Open templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
await comfyPage.templates.content.waitFor({ state: 'visible' })
|
||||
|
||||
// Wait for at least one template card to appear
|
||||
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
const templateGrid = comfyPage.page.locator(
|
||||
'[data-testid="template-workflows-content"]'
|
||||
)
|
||||
const nav = comfyPage.page
|
||||
.locator('header')
|
||||
.filter({ hasText: 'Templates' })
|
||||
|
||||
// Take snapshot of the template grid
|
||||
const templateGrid = comfyPage.templates.content.locator('.grid').first()
|
||||
const cardCount = await comfyPage.page
|
||||
.locator('[data-testid^="template-workflow-"]')
|
||||
.count()
|
||||
expect(cardCount).toBeGreaterThan(0)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
|
||||
await expect(nav).toBeVisible() // Nav should be visible at desktop size
|
||||
|
||||
// Check cards at mobile viewport size
|
||||
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
|
||||
const mobileSize = { width: 640, height: 800 }
|
||||
await comfyPage.page.setViewportSize(mobileSize)
|
||||
expect(cardCount).toBeGreaterThan(0)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
|
||||
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
|
||||
|
||||
// Check cards at tablet size
|
||||
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
|
||||
const tabletSize = { width: 1024, height: 800 }
|
||||
await comfyPage.page.setViewportSize(tabletSize)
|
||||
expect(cardCount).toBeGreaterThan(0)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
|
||||
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
||||
})
|
||||
|
||||
test('hover effects work on template cards', async ({ comfyPage }) => {
|
||||
@@ -179,10 +215,13 @@ test.describe('Templates', () => {
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Get a template card
|
||||
const firstCard = comfyPage.page.locator('.template-card').first()
|
||||
// Get a template card using data-testid
|
||||
const firstCard = comfyPage.page
|
||||
.locator('[data-testid^="template-workflow-"]')
|
||||
.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')
|
||||
|
||||
@@ -257,21 +296,42 @@ test.describe('Templates', () => {
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Verify cards are visible with varying content lengths
|
||||
// Wait for cards to load
|
||||
await expect(
|
||||
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')
|
||||
comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-short-description"]'
|
||||
)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Take snapshot of a grid with specific cards
|
||||
const templateGrid = comfyPage.templates.content
|
||||
.locator('.grid:has-text("Short Description")')
|
||||
.first()
|
||||
// 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"]'
|
||||
)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot(
|
||||
'template-grid-varying-content.png'
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
@@ -110,7 +110,7 @@ pnpm build
|
||||
|
||||
For faster iteration during development, use watch mode:
|
||||
```bash
|
||||
npx vite build --watch
|
||||
pnpm exec vite build --watch
|
||||
```
|
||||
|
||||
Note: Watch mode provides faster rebuilds than full builds, but still no hot reload
|
||||
|
||||
13
package.json
@@ -19,11 +19,11 @@
|
||||
"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:browser": "pnpm exec nx e2e",
|
||||
"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",
|
||||
"preinstall": "pnpm dlx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"lint": "eslint src --cache",
|
||||
@@ -35,15 +35,13 @@
|
||||
"knip": "knip --cache",
|
||||
"knip:no-cache": "knip",
|
||||
"locale": "lobe-i18n locale",
|
||||
"collect-i18n": "npx playwright test --config=playwright.i18n.config.ts",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"storybook": "nx storybook -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@iconify-json/lucide": "^1.2.66",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@nx/eslint": "21.4.1",
|
||||
@@ -107,6 +105,8 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/schemas": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@iconify/json": "^2.2.380",
|
||||
"@primeuix/forms": "0.0.2",
|
||||
@@ -125,6 +125,7 @@
|
||||
"@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",
|
||||
@@ -158,4 +159,4 @@
|
||||
"zod": "^3.23.8",
|
||||
"zod-validation-error": "^3.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/design-system/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@comfyorg/design-system",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared design system for ComfyUI Frontend",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./tailwind-config": {
|
||||
"import": "./tailwind.config.ts",
|
||||
"types": "./tailwind.config.ts"
|
||||
},
|
||||
"./css/*": "./src/css/*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:design"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.1.178",
|
||||
"@iconify/tailwind": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1"
|
||||
}
|
||||
1003
packages/design-system/src/css/style.css
Normal file
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'
|
||||
|
||||
const fileName = fileURLToPath(import.meta.url)
|
||||
const dirName = dirname(fileName)
|
||||
const customIconsPath = join(dirName, '..', 'src', 'assets', 'icons', 'custom')
|
||||
const customIconsPath = join(dirName, 'icons')
|
||||
|
||||
// Iconify collection structure
|
||||
interface IconifyIcon {
|
||||
@@ -51,7 +51,7 @@ ComfyUI supports three types of icons that can be used throughout the interface.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Your custom SVG icons from src/assets/icons/custom/ -->
|
||||
<!-- Your custom SVG icons from packages/design-system/src/icons/ -->
|
||||
<i-comfy:workflow />
|
||||
<i-comfy:node-tree />
|
||||
<i-comfy:my-custom-icon class="text-xl" />
|
||||
@@ -128,10 +128,10 @@ ComfyUI supports three types of icons that can be used throughout the interface.
|
||||
|
||||
### 1. Add Your SVG
|
||||
|
||||
Place your SVG file in `src/assets/icons/custom/`:
|
||||
Place your SVG file in `packages/design-system/src/icons/`:
|
||||
|
||||
```
|
||||
src/assets/icons/custom/
|
||||
packages/design-system/src/icons/
|
||||
├── workflow-duplicate.svg
|
||||
├── node-preview.svg
|
||||
└── your-icon.svg
|
||||
@@ -249,8 +249,8 @@ Icons are automatically imported using `unplugin-icons` - no manual imports need
|
||||
|
||||
The icon system has two layers:
|
||||
|
||||
1. **Build-time Processing** (`build/customIconCollection.ts`):
|
||||
- Scans `src/assets/icons/custom/` for SVG files
|
||||
1. **Build-time Processing** (`packages/design-system/src/iconCollection.ts`):
|
||||
- Scans `packages/design-system/src/icons/` for SVG files
|
||||
- Validates SVG content and structure
|
||||
- Creates Iconify collection for Tailwind CSS
|
||||
- Provides error handling for malformed files
|
||||
@@ -273,7 +273,7 @@ export const iconCollection: IconifyCollection = {
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
comfy: FileSystemIconLoader('src/assets/icons/custom')
|
||||
comfy: FileSystemIconLoader('packages/design-system/src/icons')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 938 B |
|
Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 411 B |
|
Before Width: | Height: | Size: 830 B After Width: | Height: | Size: 830 B |
|
Before Width: | Height: | Size: 857 B After Width: | Height: | Size: 857 B |
26
packages/design-system/tailwind.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import lucide from '@iconify-json/lucide/icons.json' with { type: 'json' }
|
||||
import { addDynamicIconSelectors } from '@iconify/tailwind'
|
||||
|
||||
import { iconCollection } from './src/iconCollection'
|
||||
|
||||
export default {
|
||||
content: [],
|
||||
safelist: [
|
||||
'icon-[lucide--folder]',
|
||||
'icon-[lucide--package]',
|
||||
'icon-[lucide--image]',
|
||||
'icon-[lucide--video]',
|
||||
'icon-[lucide--box]',
|
||||
'icon-[lucide--audio-waveform]',
|
||||
'icon-[lucide--message-circle]'
|
||||
],
|
||||
plugins: [
|
||||
addDynamicIconSelectors({
|
||||
iconSets: {
|
||||
comfy: iconCollection,
|
||||
lucide
|
||||
},
|
||||
prefix: 'icon'
|
||||
})
|
||||
]
|
||||
}
|
||||
8
packages/design-system/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
33
packages/schemas/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@comfyorg/schemas",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Shared Zod schemas for ComfyUI Frontend",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
"./apiSchema": "./src/apiSchema.ts",
|
||||
"./colorPaletteSchema": "./src/colorPaletteSchema.ts",
|
||||
"./keyBindingSchema": "./src/keyBindingSchema.ts",
|
||||
"./nodeDefSchema": "./src/nodeDefSchema.ts",
|
||||
"./nodeDef/nodeDefSchemaV2": "./src/nodeDef/nodeDefSchemaV2.ts",
|
||||
"./nodeDef/migration": "./src/nodeDef/migration.ts",
|
||||
"./signInSchema": "./src/signInSchema.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:schema"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8",
|
||||
"zod-validation-error": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
526
packages/schemas/src/apiSchema.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
zComfyWorkflow,
|
||||
zNodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
|
||||
import { zKeybinding } from '@/schemas/keyBindingSchema'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
|
||||
const zNodeType = z.string()
|
||||
const zQueueIndex = z.number()
|
||||
const zPromptId = z.string()
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
|
||||
const zResultItem = z.object({
|
||||
filename: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: resultItemType.optional()
|
||||
})
|
||||
export type ResultItem = z.infer<typeof zResultItem>
|
||||
const zOutputs = z
|
||||
.object({
|
||||
audio: z.array(zResultItem).optional(),
|
||||
images: z.array(zResultItem).optional(),
|
||||
video: z.array(zResultItem).optional(),
|
||||
animated: z.array(z.boolean()).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// WS messages
|
||||
const zStatusWsMessageStatus = z.object({
|
||||
exec_info: z.object({
|
||||
queue_remaining: z.number().int()
|
||||
})
|
||||
})
|
||||
|
||||
const zStatusWsMessage = z.object({
|
||||
status: zStatusWsMessageStatus.nullish(),
|
||||
sid: z.string().nullish()
|
||||
})
|
||||
|
||||
const zProgressWsMessage = z.object({
|
||||
value: z.number().int(),
|
||||
max: z.number().int(),
|
||||
prompt_id: zPromptId,
|
||||
node: zNodeId
|
||||
})
|
||||
|
||||
const zNodeProgressState = z.object({
|
||||
value: z.number(),
|
||||
max: z.number(),
|
||||
state: z.enum(['pending', 'running', 'finished', 'error']),
|
||||
node_id: zNodeId,
|
||||
prompt_id: zPromptId,
|
||||
display_node_id: zNodeId.optional(),
|
||||
parent_node_id: zNodeId.optional(),
|
||||
real_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zProgressStateWsMessage = z.object({
|
||||
prompt_id: zPromptId,
|
||||
nodes: z.record(zNodeId, zNodeProgressState)
|
||||
})
|
||||
|
||||
const zExecutingWsMessage = z.object({
|
||||
node: zNodeId,
|
||||
display_node: zNodeId,
|
||||
prompt_id: zPromptId
|
||||
})
|
||||
|
||||
const zExecutedWsMessage = zExecutingWsMessage.extend({
|
||||
output: zOutputs,
|
||||
merge: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zExecutionWsMessageBase = z.object({
|
||||
prompt_id: zPromptId,
|
||||
timestamp: z.number().int()
|
||||
})
|
||||
|
||||
const zExecutionStartWsMessage = zExecutionWsMessageBase
|
||||
const zExecutionSuccessWsMessage = zExecutionWsMessageBase
|
||||
const zExecutionCachedWsMessage = zExecutionWsMessageBase.extend({
|
||||
nodes: z.array(zNodeId)
|
||||
})
|
||||
const zExecutionInterruptedWsMessage = zExecutionWsMessageBase.extend({
|
||||
node_id: zNodeId,
|
||||
node_type: zNodeType,
|
||||
executed: z.array(zNodeId)
|
||||
})
|
||||
const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
|
||||
node_id: zNodeId,
|
||||
node_type: zNodeType,
|
||||
executed: z.array(zNodeId),
|
||||
exception_message: z.string(),
|
||||
exception_type: z.string(),
|
||||
traceback: z.array(z.string()),
|
||||
current_inputs: z.any(),
|
||||
current_outputs: z.any()
|
||||
})
|
||||
|
||||
const zProgressTextWsMessage = z.object({
|
||||
nodeId: zNodeId,
|
||||
text: z.string()
|
||||
})
|
||||
|
||||
const zDisplayComponentWsMessage = z.object({
|
||||
node_id: zNodeId,
|
||||
component: z.enum(['ChatHistoryWidget']),
|
||||
props: z.record(z.string(), z.any()).optional()
|
||||
})
|
||||
|
||||
const zTerminalSize = z.object({
|
||||
cols: z.number(),
|
||||
row: z.number()
|
||||
})
|
||||
const zLogEntry = z.object({
|
||||
t: z.string(),
|
||||
m: z.string()
|
||||
})
|
||||
const zLogsWsMessage = z.object({
|
||||
size: zTerminalSize.optional(),
|
||||
entries: z.array(zLogEntry)
|
||||
})
|
||||
const zLogRawResponse = z.object({
|
||||
size: zTerminalSize,
|
||||
entries: z.array(zLogEntry)
|
||||
})
|
||||
|
||||
const zFeatureFlagsWsMessage = z.record(z.string(), z.any())
|
||||
|
||||
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
|
||||
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
|
||||
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
|
||||
export type ExecutingWsMessage = z.infer<typeof zExecutingWsMessage>
|
||||
export type ExecutedWsMessage = z.infer<typeof zExecutedWsMessage>
|
||||
export type ExecutionStartWsMessage = z.infer<typeof zExecutionStartWsMessage>
|
||||
export type ExecutionSuccessWsMessage = z.infer<
|
||||
typeof zExecutionSuccessWsMessage
|
||||
>
|
||||
export type ExecutionCachedWsMessage = z.infer<typeof zExecutionCachedWsMessage>
|
||||
export type ExecutionInterruptedWsMessage = z.infer<
|
||||
typeof zExecutionInterruptedWsMessage
|
||||
>
|
||||
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
|
||||
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
|
||||
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||
export type DisplayComponentWsMessage = z.infer<
|
||||
typeof zDisplayComponentWsMessage
|
||||
>
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
|
||||
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
|
||||
// End of ws messages
|
||||
|
||||
const zPromptInputItem = z.object({
|
||||
inputs: z.record(z.string(), z.any()),
|
||||
class_type: zNodeType
|
||||
})
|
||||
|
||||
const zPromptInputs = z.record(zPromptInputItem)
|
||||
|
||||
const zExtraPngInfo = z
|
||||
.object({
|
||||
workflow: zComfyWorkflow
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zExtraData = z.object({
|
||||
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
||||
extra_pnginfo: zExtraPngInfo.optional(),
|
||||
client_id: z.string()
|
||||
})
|
||||
const zOutputsToExecute = z.array(zNodeId)
|
||||
|
||||
const zExecutionStartMessage = z.tuple([
|
||||
z.literal('execution_start'),
|
||||
zExecutionStartWsMessage
|
||||
])
|
||||
|
||||
const zExecutionSuccessMessage = z.tuple([
|
||||
z.literal('execution_success'),
|
||||
zExecutionSuccessWsMessage
|
||||
])
|
||||
|
||||
const zExecutionCachedMessage = z.tuple([
|
||||
z.literal('execution_cached'),
|
||||
zExecutionCachedWsMessage
|
||||
])
|
||||
|
||||
const zExecutionInterruptedMessage = z.tuple([
|
||||
z.literal('execution_interrupted'),
|
||||
zExecutionInterruptedWsMessage
|
||||
])
|
||||
|
||||
const zExecutionErrorMessage = z.tuple([
|
||||
z.literal('execution_error'),
|
||||
zExecutionErrorWsMessage
|
||||
])
|
||||
|
||||
const zStatusMessage = z.union([
|
||||
zExecutionStartMessage,
|
||||
zExecutionSuccessMessage,
|
||||
zExecutionCachedMessage,
|
||||
zExecutionInterruptedMessage,
|
||||
zExecutionErrorMessage
|
||||
])
|
||||
|
||||
const zStatus = z.object({
|
||||
status_str: z.enum(['success', 'error']),
|
||||
completed: z.boolean(),
|
||||
messages: z.array(zStatusMessage)
|
||||
})
|
||||
|
||||
const zTaskPrompt = z.tuple([
|
||||
zQueueIndex,
|
||||
zPromptId,
|
||||
zPromptInputs,
|
||||
zExtraData,
|
||||
zOutputsToExecute
|
||||
])
|
||||
|
||||
const zRunningTaskItem = z.object({
|
||||
taskType: z.literal('Running'),
|
||||
prompt: zTaskPrompt,
|
||||
// @Deprecated
|
||||
remove: z.object({
|
||||
name: z.literal('Cancel'),
|
||||
cb: z.function()
|
||||
})
|
||||
})
|
||||
|
||||
const zPendingTaskItem = z.object({
|
||||
taskType: z.literal('Pending'),
|
||||
prompt: zTaskPrompt
|
||||
})
|
||||
|
||||
const zTaskOutput = z.record(zNodeId, zOutputs)
|
||||
|
||||
const zNodeOutputsMeta = z.object({
|
||||
node_id: zNodeId,
|
||||
display_node: zNodeId,
|
||||
prompt_id: zPromptId.optional(),
|
||||
read_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zTaskMeta = z.record(zNodeId, zNodeOutputsMeta)
|
||||
|
||||
const zHistoryTaskItem = z.object({
|
||||
taskType: z.literal('History'),
|
||||
prompt: zTaskPrompt,
|
||||
status: zStatus.optional(),
|
||||
outputs: zTaskOutput,
|
||||
meta: zTaskMeta.optional()
|
||||
})
|
||||
|
||||
const zTaskItem = z.union([
|
||||
zRunningTaskItem,
|
||||
zPendingTaskItem,
|
||||
zHistoryTaskItem
|
||||
])
|
||||
|
||||
const zTaskType = z.union([
|
||||
z.literal('Running'),
|
||||
z.literal('Pending'),
|
||||
z.literal('History')
|
||||
])
|
||||
|
||||
export type TaskType = z.infer<typeof zTaskType>
|
||||
export type TaskPrompt = z.infer<typeof zTaskPrompt>
|
||||
export type TaskStatus = z.infer<typeof zStatus>
|
||||
export type TaskOutput = z.infer<typeof zTaskOutput>
|
||||
|
||||
// `/queue`
|
||||
export type RunningTaskItem = z.infer<typeof zRunningTaskItem>
|
||||
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>
|
||||
// `/history`
|
||||
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>
|
||||
export type TaskItem = z.infer<typeof zTaskItem>
|
||||
|
||||
const zEmbeddingsResponse = z.array(z.string())
|
||||
const zExtensionsResponse = z.array(z.string())
|
||||
const zError = z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
details: z.string(),
|
||||
extra_info: z
|
||||
.object({
|
||||
input_name: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional()
|
||||
})
|
||||
const zNodeError = z.object({
|
||||
errors: z.array(zError),
|
||||
class_type: z.string(),
|
||||
dependent_outputs: z.array(z.any())
|
||||
})
|
||||
const zPromptResponse = z.object({
|
||||
node_errors: z.record(zNodeId, zNodeError).optional(),
|
||||
prompt_id: z.string().optional(),
|
||||
exec_info: z
|
||||
.object({
|
||||
queue_remaining: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
error: z.union([z.string(), zError])
|
||||
})
|
||||
|
||||
const zDeviceStats = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
index: z.number(),
|
||||
vram_total: z.number(),
|
||||
vram_free: z.number(),
|
||||
torch_vram_total: z.number(),
|
||||
torch_vram_free: z.number()
|
||||
})
|
||||
|
||||
const zSystemStats = z.object({
|
||||
system: z.object({
|
||||
os: z.string(),
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
pytorch_version: z.string(),
|
||||
required_frontend_version: z.string().optional(),
|
||||
argv: z.array(z.string()),
|
||||
ram_total: z.number(),
|
||||
ram_free: z.number()
|
||||
}),
|
||||
devices: z.array(zDeviceStats)
|
||||
})
|
||||
const zUser = z.object({
|
||||
storage: z.enum(['server']),
|
||||
// `migrated` is only available in single-user mode.
|
||||
migrated: z.boolean().optional(),
|
||||
// `users` is only available in multi-user server mode.
|
||||
users: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
const zUserData = z.array(z.array(z.string(), z.string()))
|
||||
const zUserDataFullInfo = z.object({
|
||||
path: z.string(),
|
||||
size: z.number(),
|
||||
modified: z.number()
|
||||
})
|
||||
const zBookmarkCustomization = z.object({
|
||||
icon: z.string().optional(),
|
||||
color: z.string().optional()
|
||||
})
|
||||
export type BookmarkCustomization = z.infer<typeof zBookmarkCustomization>
|
||||
|
||||
const zLinkReleaseTriggerAction = z.enum(
|
||||
Object.values(LinkReleaseTriggerAction) as [string, ...string[]]
|
||||
)
|
||||
|
||||
const zNodeBadgeMode = z.enum(
|
||||
Object.values(NodeBadgeMode) as [string, ...string[]]
|
||||
)
|
||||
|
||||
const zSettings = z.object({
|
||||
'Comfy.ColorPalette': z.string(),
|
||||
'Comfy.CustomColorPalettes': colorPalettesSchema,
|
||||
'Comfy.Canvas.BackgroundImage': z.string().optional(),
|
||||
'Comfy.ConfirmClear': z.boolean(),
|
||||
'Comfy.DevMode': z.boolean(),
|
||||
'Comfy.Workflow.ShowMissingNodesWarning': z.boolean(),
|
||||
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),
|
||||
'Comfy.DisableFloatRounding': z.boolean(),
|
||||
'Comfy.DisableSliders': z.boolean(),
|
||||
'Comfy.DOMClippingEnabled': z.boolean(),
|
||||
'Comfy.EditAttention.Delta': z.number(),
|
||||
'Comfy.EnableTooltips': z.boolean(),
|
||||
'Comfy.EnableWorkflowViewRestore': z.boolean(),
|
||||
'Comfy.FloatRoundingPrecision': z.number(),
|
||||
'Comfy.Graph.CanvasInfo': z.boolean(),
|
||||
'Comfy.Graph.CanvasMenu': z.boolean(),
|
||||
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
|
||||
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
|
||||
'Comfy.Graph.ZoomSpeed': z.number(),
|
||||
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
|
||||
'Comfy.GroupSelectedNodes.Padding': z.number(),
|
||||
'Comfy.Locale': z.string(),
|
||||
'Comfy.NodeLibrary.Bookmarks': z.array(z.string()),
|
||||
'Comfy.NodeLibrary.Bookmarks.V2': z.array(z.string()),
|
||||
'Comfy.NodeLibrary.BookmarksCustomization': z.record(
|
||||
z.string(),
|
||||
zBookmarkCustomization
|
||||
),
|
||||
'Comfy.LinkRelease.Action': zLinkReleaseTriggerAction,
|
||||
'Comfy.LinkRelease.ActionShift': zLinkReleaseTriggerAction,
|
||||
'Comfy.ModelLibrary.AutoLoadAll': z.boolean(),
|
||||
'Comfy.ModelLibrary.NameFormat': z.enum(['filename', 'title']),
|
||||
'Comfy.NodeSearchBoxImpl.NodePreview': z.boolean(),
|
||||
'Comfy.NodeSearchBoxImpl': z.enum(['default', 'simple']),
|
||||
'Comfy.NodeSearchBoxImpl.ShowCategory': z.boolean(),
|
||||
'Comfy.NodeSearchBoxImpl.ShowIdName': z.boolean(),
|
||||
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency': z.boolean(),
|
||||
'Comfy.NodeSuggestions.number': z.number(),
|
||||
'Comfy.Node.BypassAllLinksOnDelete': z.boolean(),
|
||||
'Comfy.Node.Opacity': z.number(),
|
||||
'Comfy.Node.MiddleClickRerouteNode': z.boolean(),
|
||||
'Comfy.Node.ShowDeprecated': z.boolean(),
|
||||
'Comfy.Node.ShowExperimental': z.boolean(),
|
||||
'Comfy.Pointer.ClickBufferTime': z.number(),
|
||||
'Comfy.Pointer.ClickDrift': z.number(),
|
||||
'Comfy.Pointer.DoubleClickTime': z.number(),
|
||||
'Comfy.PreviewFormat': z.string(),
|
||||
'Comfy.PromptFilename': z.boolean(),
|
||||
'Comfy.Sidebar.Location': z.enum(['left', 'right']),
|
||||
'Comfy.Sidebar.Size': z.enum(['small', 'normal']),
|
||||
'Comfy.Sidebar.UnifiedWidth': z.boolean(),
|
||||
'Comfy.SnapToGrid.GridSize': z.number(),
|
||||
'Comfy.TextareaWidget.FontSize': z.number(),
|
||||
'Comfy.TextareaWidget.Spellcheck': z.boolean(),
|
||||
'Comfy.UseNewMenu': z.enum(['Disabled', 'Top', 'Bottom']),
|
||||
'Comfy.TreeExplorer.ItemPadding': z.number(),
|
||||
'Comfy.Validation.Workflows': z.boolean(),
|
||||
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
|
||||
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
|
||||
'Comfy.Workflow.WorkflowTabsPosition': z.enum([
|
||||
'Sidebar',
|
||||
'Topbar',
|
||||
'Topbar (2nd-row)'
|
||||
]),
|
||||
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
|
||||
'Comfy.WidgetControlMode': z.enum(['before', 'after']),
|
||||
'Comfy.Window.UnloadConfirmation': z.boolean(),
|
||||
'Comfy.NodeBadge.NodeSourceBadgeMode': zNodeBadgeMode,
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode': zNodeBadgeMode,
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode': zNodeBadgeMode,
|
||||
'Comfy.NodeBadge.ShowApiPricing': z.boolean(),
|
||||
'Comfy.Notification.ShowVersionUpdates': z.boolean(),
|
||||
'Comfy.QueueButton.BatchCountLimit': z.number(),
|
||||
'Comfy.Queue.MaxHistoryItems': z.number(),
|
||||
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),
|
||||
'Comfy.Keybinding.NewBindings': z.array(zKeybinding),
|
||||
'Comfy.Extension.Disabled': z.array(z.string()),
|
||||
'Comfy.LinkRenderMode': z.number(),
|
||||
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),
|
||||
'Comfy.Node.SnapHighlightsNode': z.boolean(),
|
||||
'Comfy.Server.ServerConfigValues': z.record(z.string(), z.any()),
|
||||
'Comfy.Server.LaunchArgs': z.record(z.string(), z.string()),
|
||||
'LiteGraph.Canvas.MaximumFps': z.number(),
|
||||
'Comfy.Workflow.ConfirmDelete': z.boolean(),
|
||||
'Comfy.Workflow.AutoSaveDelay': z.number(),
|
||||
'Comfy.Workflow.AutoSave': z.enum(['off', 'after delay']),
|
||||
'Comfy.RerouteBeta': z.boolean(),
|
||||
'LiteGraph.Canvas.MinFontSizeForLOD': z.number(),
|
||||
'Comfy.Canvas.SelectionToolbox': z.boolean(),
|
||||
'LiteGraph.Node.TooltipDelay': z.number(),
|
||||
'LiteGraph.ContextMenu.Scaling': z.boolean(),
|
||||
'LiteGraph.Reroute.SplineOffset': z.number(),
|
||||
'LiteGraph.Canvas.LowQualityRenderingZoomThreshold': z.number(),
|
||||
'Comfy.Toast.DisableReconnectingToast': z.boolean(),
|
||||
'Comfy.Workflow.Persist': z.boolean(),
|
||||
'Comfy.TutorialCompleted': z.boolean(),
|
||||
'Comfy.InstalledVersion': z.string().nullable(),
|
||||
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
|
||||
'Comfy.Minimap.Visible': z.boolean(),
|
||||
'Comfy.Minimap.NodeColors': z.boolean(),
|
||||
'Comfy.Minimap.ShowLinks': z.boolean(),
|
||||
'Comfy.Minimap.ShowGroups': z.boolean(),
|
||||
'Comfy.Minimap.RenderBypassState': z.boolean(),
|
||||
'Comfy.Minimap.RenderErrorState': z.boolean(),
|
||||
'Comfy.Canvas.NavigationMode': z.string(),
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': z.string(),
|
||||
'Comfy.Canvas.MouseWheelScroll': z.string(),
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||
'Comfy-Desktop.SendStatistics': z.boolean(),
|
||||
'Comfy-Desktop.WindowStyle': z.string(),
|
||||
'Comfy-Desktop.UV.PythonInstallMirror': z.string(),
|
||||
'Comfy-Desktop.UV.PypiInstallMirror': z.string(),
|
||||
'Comfy-Desktop.UV.TorchInstallMirror': z.string(),
|
||||
'Comfy.MaskEditor.UseNewEditor': z.boolean(),
|
||||
'Comfy.MaskEditor.BrushAdjustmentSpeed': z.number(),
|
||||
'Comfy.MaskEditor.UseDominantAxis': z.boolean(),
|
||||
'Comfy.Load3D.ShowGrid': z.boolean(),
|
||||
'Comfy.Load3D.ShowPreview': z.boolean(),
|
||||
'Comfy.Load3D.BackgroundColor': z.string(),
|
||||
'Comfy.Load3D.LightIntensity': z.number(),
|
||||
'Comfy.Load3D.LightIntensityMaximum': z.number(),
|
||||
'Comfy.Load3D.LightIntensityMinimum': z.number(),
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': z.number(),
|
||||
'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']),
|
||||
'Comfy.Load3D.3DViewerEnable': z.boolean(),
|
||||
'Comfy.Memory.AllowManualUnload': z.boolean(),
|
||||
'pysssss.SnapToGrid': z.boolean(),
|
||||
/** VHS setting is used for queue video preview support. */
|
||||
'VHS.AdvancedPreviews': z.string(),
|
||||
/** Release data settings */
|
||||
'Comfy.Release.Version': z.string(),
|
||||
'Comfy.Release.Status': z.enum([
|
||||
'skipped',
|
||||
'changelog seen',
|
||||
"what's new seen"
|
||||
]),
|
||||
'Comfy.Release.Timestamp': z.number(),
|
||||
/** Settings used for testing */
|
||||
'test.setting': z.any(),
|
||||
'main.sub.setting.name': z.any(),
|
||||
'single.setting': z.any(),
|
||||
'LiteGraph.Node.DefaultPadding': z.boolean(),
|
||||
'LiteGraph.Pointer.TrackpadGestures': z.boolean()
|
||||
})
|
||||
|
||||
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
|
||||
export type ExtensionsResponse = z.infer<typeof zExtensionsResponse>
|
||||
export type PromptResponse = z.infer<typeof zPromptResponse>
|
||||
export type NodeError = z.infer<typeof zNodeError>
|
||||
export type Settings = z.infer<typeof zSettings>
|
||||
export type DeviceStats = z.infer<typeof zDeviceStats>
|
||||
export type SystemStats = z.infer<typeof zSystemStats>
|
||||
export type User = z.infer<typeof zUser>
|
||||
export type UserData = z.infer<typeof zUserData>
|
||||
export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo>
|
||||
export type TerminalSize = z.infer<typeof zTerminalSize>
|
||||
export type LogEntry = z.infer<typeof zLogEntry>
|
||||
export type LogsRawResponse = z.infer<typeof zLogRawResponse>
|
||||
116
packages/schemas/src/colorPaletteSchema.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const nodeSlotSchema = z.object({
|
||||
CLIP: z.string(),
|
||||
CLIP_VISION: z.string(),
|
||||
CLIP_VISION_OUTPUT: z.string(),
|
||||
CONDITIONING: z.string(),
|
||||
CONTROL_NET: z.string(),
|
||||
IMAGE: z.string(),
|
||||
LATENT: z.string(),
|
||||
MASK: z.string(),
|
||||
MODEL: z.string(),
|
||||
STYLE_MODEL: z.string(),
|
||||
VAE: z.string(),
|
||||
NOISE: z.string(),
|
||||
GUIDER: z.string(),
|
||||
SAMPLER: z.string(),
|
||||
SIGMAS: z.string(),
|
||||
TAESD: z.string()
|
||||
})
|
||||
|
||||
const litegraphBaseSchema = z.object({
|
||||
BACKGROUND_IMAGE: z.string(),
|
||||
CLEAR_BACKGROUND_COLOR: z.string(),
|
||||
NODE_TITLE_COLOR: z.string(),
|
||||
NODE_SELECTED_TITLE_COLOR: z.string(),
|
||||
NODE_TEXT_SIZE: z.number(),
|
||||
NODE_TEXT_COLOR: z.string(),
|
||||
NODE_TEXT_HIGHLIGHT_COLOR: z.string(),
|
||||
NODE_SUBTEXT_SIZE: z.number(),
|
||||
NODE_DEFAULT_COLOR: z.string(),
|
||||
NODE_DEFAULT_BGCOLOR: z.string(),
|
||||
NODE_DEFAULT_BOXCOLOR: z.string(),
|
||||
NODE_DEFAULT_SHAPE: z.union([
|
||||
z.literal(LiteGraph.BOX_SHAPE),
|
||||
z.literal(LiteGraph.ROUND_SHAPE),
|
||||
z.literal(LiteGraph.CARD_SHAPE),
|
||||
// Legacy palettes have string field for NODE_DEFAULT_SHAPE.
|
||||
z.string()
|
||||
]),
|
||||
NODE_BOX_OUTLINE_COLOR: z.string(),
|
||||
NODE_BYPASS_BGCOLOR: z.string(),
|
||||
NODE_ERROR_COLOUR: z.string(),
|
||||
DEFAULT_SHADOW_COLOR: z.string(),
|
||||
DEFAULT_GROUP_FONT: z.number(),
|
||||
WIDGET_BGCOLOR: z.string(),
|
||||
WIDGET_OUTLINE_COLOR: z.string(),
|
||||
WIDGET_TEXT_COLOR: z.string(),
|
||||
WIDGET_SECONDARY_TEXT_COLOR: z.string(),
|
||||
WIDGET_DISABLED_TEXT_COLOR: z.string(),
|
||||
LINK_COLOR: z.string(),
|
||||
EVENT_LINK_COLOR: z.string(),
|
||||
CONNECTING_LINK_COLOR: z.string(),
|
||||
BADGE_FG_COLOR: z.string(),
|
||||
BADGE_BG_COLOR: z.string()
|
||||
})
|
||||
|
||||
const comfyBaseSchema = z.object({
|
||||
['fg-color']: z.string(),
|
||||
['bg-color']: z.string(),
|
||||
['bg-img']: z.string().optional(),
|
||||
['comfy-menu-bg']: z.string(),
|
||||
['comfy-menu-secondary-bg']: z.string(),
|
||||
['comfy-input-bg']: z.string(),
|
||||
['input-text']: z.string(),
|
||||
['descrip-text']: z.string(),
|
||||
['drag-text']: z.string(),
|
||||
['error-text']: z.string(),
|
||||
['border-color']: z.string(),
|
||||
['tr-even-bg-color']: z.string(),
|
||||
['tr-odd-bg-color']: z.string(),
|
||||
['content-bg']: z.string(),
|
||||
['content-fg']: z.string(),
|
||||
['content-hover-bg']: z.string(),
|
||||
['content-hover-fg']: z.string(),
|
||||
['bar-shadow']: z.string()
|
||||
})
|
||||
|
||||
const colorsSchema = z.object({
|
||||
node_slot: nodeSlotSchema,
|
||||
litegraph_base: litegraphBaseSchema,
|
||||
comfy_base: comfyBaseSchema
|
||||
})
|
||||
|
||||
const partialColorsSchema = z.object({
|
||||
node_slot: nodeSlotSchema.partial(),
|
||||
litegraph_base: litegraphBaseSchema.partial(),
|
||||
comfy_base: comfyBaseSchema.partial()
|
||||
})
|
||||
|
||||
// Palette in the wild can have custom metadata fields such as 'version'.
|
||||
export const paletteSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
colors: partialColorsSchema,
|
||||
light_theme: z.boolean().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const completedPaletteSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
colors: colorsSchema
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const colorPalettesSchema = z.record(paletteSchema)
|
||||
|
||||
export type Colors = z.infer<typeof colorsSchema>
|
||||
export type Palette = z.infer<typeof paletteSchema>
|
||||
export type CompletedPalette = z.infer<typeof completedPaletteSchema>
|
||||
export type ColorPalettes = z.infer<typeof colorPalettesSchema>
|
||||
25
packages/schemas/src/keyBindingSchema.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// KeyCombo schema
|
||||
const zKeyCombo = z.object({
|
||||
key: z.string(),
|
||||
ctrl: z.boolean().optional(),
|
||||
alt: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
meta: z.boolean().optional()
|
||||
})
|
||||
|
||||
// Keybinding schema
|
||||
export const zKeybinding = z.object({
|
||||
commandId: z.string(),
|
||||
combo: zKeyCombo,
|
||||
// Optional target element ID to limit keybinding to.
|
||||
// Note: Currently only used to distinguish between global keybindings
|
||||
// and litegraph canvas keybindings.
|
||||
// Do NOT use this field in extensions as it has no effect.
|
||||
targetElementId: z.string().optional()
|
||||
})
|
||||
|
||||
// Infer types from schemas
|
||||
export type KeyCombo = z.infer<typeof zKeyCombo>
|
||||
export type Keybinding = z.infer<typeof zKeybinding>
|
||||
143
packages/schemas/src/nodeDef/migration.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
ComfyNodeDef as ComfyNodeDefV2,
|
||||
InputSpec as InputSpecV2,
|
||||
OutputSpec as OutputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type {
|
||||
ComfyNodeDef as ComfyNodeDefV1,
|
||||
InputSpec as InputSpecV1
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import {
|
||||
getComboSpecComboOptions,
|
||||
isComboInputSpec,
|
||||
isComboInputSpecV1
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Transforms a V1 node definition to V2 format
|
||||
* @param nodeDefV1 The V1 node definition to transform
|
||||
* @returns The transformed V2 node definition
|
||||
*/
|
||||
export function transformNodeDefV1ToV2(
|
||||
nodeDefV1: ComfyNodeDefV1
|
||||
): ComfyNodeDefV2 {
|
||||
// Transform inputs
|
||||
const inputs: Record<string, InputSpecV2> = {}
|
||||
|
||||
// Process required inputs
|
||||
if (nodeDefV1.input?.required) {
|
||||
Object.entries(nodeDefV1.input.required).forEach(([name, inputSpecV1]) => {
|
||||
inputs[name] = transformInputSpecV1ToV2(inputSpecV1, {
|
||||
name,
|
||||
isOptional: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process optional inputs
|
||||
if (nodeDefV1.input?.optional) {
|
||||
Object.entries(nodeDefV1.input.optional).forEach(([name, inputSpecV1]) => {
|
||||
inputs[name] = transformInputSpecV1ToV2(inputSpecV1, {
|
||||
name,
|
||||
isOptional: true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Transform outputs
|
||||
const outputs: OutputSpecV2[] = []
|
||||
|
||||
if (nodeDefV1.output) {
|
||||
if (Array.isArray(nodeDefV1.output)) {
|
||||
nodeDefV1.output.forEach((outputType, index) => {
|
||||
const outputSpec: OutputSpecV2 = {
|
||||
index,
|
||||
name: nodeDefV1.output_name?.[index] || `output_${index}`,
|
||||
type: Array.isArray(outputType) ? 'COMBO' : outputType,
|
||||
is_list: nodeDefV1.output_is_list?.[index] || false,
|
||||
tooltip: nodeDefV1.output_tooltips?.[index]
|
||||
}
|
||||
|
||||
// Add options for combo outputs
|
||||
if (Array.isArray(outputType)) {
|
||||
outputSpec.options = outputType
|
||||
}
|
||||
|
||||
outputs.push(outputSpec)
|
||||
})
|
||||
} else {
|
||||
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the V2 node definition
|
||||
return {
|
||||
inputs,
|
||||
outputs,
|
||||
hidden: nodeDefV1.input?.hidden,
|
||||
name: nodeDefV1.name,
|
||||
display_name: nodeDefV1.display_name,
|
||||
description: nodeDefV1.description,
|
||||
category: nodeDefV1.category,
|
||||
output_node: nodeDefV1.output_node,
|
||||
python_module: nodeDefV1.python_module,
|
||||
deprecated: nodeDefV1.deprecated,
|
||||
experimental: nodeDefV1.experimental
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a V1 input specification to V2 format
|
||||
* @param inputSpecV1 The V1 input specification to transform
|
||||
* @param name The name of the input
|
||||
* @param isOptional Whether the input is optional
|
||||
* @returns The transformed V2 input specification
|
||||
*/
|
||||
export function transformInputSpecV1ToV2(
|
||||
inputSpecV1: InputSpecV1,
|
||||
kwargs: {
|
||||
name: string
|
||||
isOptional?: boolean
|
||||
}
|
||||
): InputSpecV2 {
|
||||
const { name, isOptional = false } = kwargs
|
||||
|
||||
// Extract options from the input spec
|
||||
const options = inputSpecV1[1] || {}
|
||||
|
||||
// Base properties for all input types
|
||||
const baseProps = {
|
||||
name,
|
||||
isOptional,
|
||||
...options
|
||||
}
|
||||
|
||||
// Handle different input types
|
||||
if (isComboInputSpec(inputSpecV1)) {
|
||||
return {
|
||||
type: 'COMBO',
|
||||
...baseProps,
|
||||
options: isComboInputSpecV1(inputSpecV1)
|
||||
? inputSpecV1[0]
|
||||
: getComboSpecComboOptions(inputSpecV1)
|
||||
}
|
||||
} else if (typeof inputSpecV1[0] === 'string') {
|
||||
// Handle standard types (INT, FLOAT, BOOLEAN, STRING) and custom types
|
||||
return {
|
||||
type: inputSpecV1[0],
|
||||
...baseProps
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for any unhandled cases
|
||||
return {
|
||||
type: 'UNKNOWN',
|
||||
...baseProps
|
||||
}
|
||||
}
|
||||
|
||||
export function transformInputSpecV2ToV1(
|
||||
inputSpecV2: InputSpecV2
|
||||
): InputSpecV1 {
|
||||
return [inputSpecV2.type, inputSpecV2]
|
||||
}
|
||||
264
packages/schemas/src/nodeDef/nodeDefSchemaV2.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
zBaseInputOptions,
|
||||
zBooleanInputOptions,
|
||||
zComboInputOptions,
|
||||
zFloatInputOptions,
|
||||
zIntInputOptions,
|
||||
zStringInputOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
|
||||
const zIntInputSpec = zIntInputOptions.extend({
|
||||
type: z.literal('INT'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zFloatInputSpec = zFloatInputOptions.extend({
|
||||
type: z.literal('FLOAT'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zBooleanInputSpec = zBooleanInputOptions.extend({
|
||||
type: z.literal('BOOLEAN'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zStringInputSpec = zStringInputOptions.extend({
|
||||
type: z.literal('STRING'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zComboInputSpec = zComboInputOptions.extend({
|
||||
type: z.literal('COMBO'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zColorInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('COLOR'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
default: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zFileUploadInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('FILEUPLOAD'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
const zImageInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('IMAGE'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
const zImageCompareInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('IMAGECOMPARE'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
const zMarkdownInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('MARKDOWN'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
content: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zTreeSelectInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('TREESELECT'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
multiple: z.boolean().optional(),
|
||||
values: z.array(z.unknown()).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zMultiSelectInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('MULTISELECT'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
values: z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zChartInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('CHART'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
type: z.enum(['bar', 'line']).optional(),
|
||||
data: z.object({}).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zGalleriaInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('GALLERIA'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
images: z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zSelectButtonInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('SELECTBUTTON'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
values: z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zTextareaInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('TEXTAREA'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
rows: z.number().optional(),
|
||||
cols: z.number().optional(),
|
||||
default: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zCustomInputSpec = zBaseInputOptions.extend({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zInputSpec = z.union([
|
||||
zIntInputSpec,
|
||||
zFloatInputSpec,
|
||||
zBooleanInputSpec,
|
||||
zStringInputSpec,
|
||||
zComboInputSpec,
|
||||
zColorInputSpec,
|
||||
zFileUploadInputSpec,
|
||||
zImageInputSpec,
|
||||
zImageCompareInputSpec,
|
||||
zMarkdownInputSpec,
|
||||
zTreeSelectInputSpec,
|
||||
zMultiSelectInputSpec,
|
||||
zChartInputSpec,
|
||||
zGalleriaInputSpec,
|
||||
zSelectButtonInputSpec,
|
||||
zTextareaInputSpec,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
// Output specs
|
||||
const zOutputSpec = z.object({
|
||||
index: z.number(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
is_list: z.boolean(),
|
||||
options: z.array(z.any()).optional(),
|
||||
tooltip: z.string().optional()
|
||||
})
|
||||
|
||||
// Main node definition schema
|
||||
export const zComfyNodeDef = z.object({
|
||||
inputs: z.record(zInputSpec),
|
||||
outputs: z.array(zOutputSpec),
|
||||
hidden: z.record(z.any()).optional(),
|
||||
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string(),
|
||||
help: z.string().optional(),
|
||||
category: z.string(),
|
||||
output_node: z.boolean(),
|
||||
python_module: z.string(),
|
||||
deprecated: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
api_node: z.boolean().optional()
|
||||
})
|
||||
|
||||
// Export types
|
||||
type IntInputSpec = z.infer<typeof zIntInputSpec>
|
||||
type FloatInputSpec = z.infer<typeof zFloatInputSpec>
|
||||
type BooleanInputSpec = z.infer<typeof zBooleanInputSpec>
|
||||
type StringInputSpec = z.infer<typeof zStringInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ColorInputSpec = z.infer<typeof zColorInputSpec>
|
||||
export type FileUploadInputSpec = z.infer<typeof zFileUploadInputSpec>
|
||||
export type ImageCompareInputSpec = z.infer<typeof zImageCompareInputSpec>
|
||||
export type TreeSelectInputSpec = z.infer<typeof zTreeSelectInputSpec>
|
||||
export type MultiSelectInputSpec = z.infer<typeof zMultiSelectInputSpec>
|
||||
export type ChartInputSpec = z.infer<typeof zChartInputSpec>
|
||||
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
|
||||
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
|
||||
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
|
||||
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
|
||||
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
export type OutputSpec = z.infer<typeof zOutputSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
|
||||
export const isIntInputSpec = (
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is IntInputSpec => {
|
||||
return inputSpec.type === 'INT'
|
||||
}
|
||||
|
||||
export const isFloatInputSpec = (
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is FloatInputSpec => {
|
||||
return inputSpec.type === 'FLOAT'
|
||||
}
|
||||
|
||||
export const isBooleanInputSpec = (
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is BooleanInputSpec => {
|
||||
return inputSpec.type === 'BOOLEAN'
|
||||
}
|
||||
|
||||
export const isStringInputSpec = (
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is StringInputSpec => {
|
||||
return inputSpec.type === 'STRING'
|
||||
}
|
||||
|
||||
export const isComboInputSpec = (
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec => {
|
||||
return inputSpec.type === 'COMBO'
|
||||
}
|
||||
|
||||
export const isChartInputSpec = (
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ChartInputSpec => {
|
||||
return inputSpec.type === 'CHART'
|
||||
}
|
||||
255
packages/schemas/src/nodeDefSchema.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { resultItemType } from '@/schemas/apiSchema'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
query_params: z.record(z.string(), z.string()).optional(),
|
||||
refresh_button: z.boolean().optional(),
|
||||
control_after_refresh: z.enum(['first', 'last']).optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
const zMultiSelectOption = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
chip: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zBaseInputOptions = z
|
||||
.object({
|
||||
default: z.any().optional(),
|
||||
defaultInput: z.boolean().optional(),
|
||||
forceInput: z.boolean().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
advanced: z.boolean().optional(),
|
||||
widgetType: z.string().optional(),
|
||||
/** Backend-only properties. */
|
||||
rawLink: z.boolean().optional(),
|
||||
lazy: z.boolean().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zNumericInputOptions = zBaseInputOptions.extend({
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
step: z.number().optional(),
|
||||
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
|
||||
default: z.union([z.number(), z.array(z.number())]).optional(),
|
||||
display: z.enum(['slider', 'number', 'knob']).optional()
|
||||
})
|
||||
|
||||
export const zIntInputOptions = zNumericInputOptions.extend({
|
||||
/**
|
||||
* If true, a linked widget will be added to the node to select the mode
|
||||
* of `control_after_generate`.
|
||||
*/
|
||||
control_after_generate: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zFloatInputOptions = zNumericInputOptions.extend({
|
||||
round: z.union([z.number(), z.literal(false)]).optional()
|
||||
})
|
||||
|
||||
export const zBooleanInputOptions = zBaseInputOptions.extend({
|
||||
label_on: z.string().optional(),
|
||||
label_off: z.string().optional(),
|
||||
default: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zStringInputOptions = zBaseInputOptions.extend({
|
||||
default: z.string().optional(),
|
||||
multiline: z.boolean().optional(),
|
||||
dynamicPrompts: z.boolean().optional(),
|
||||
|
||||
// Multiline-only fields
|
||||
defaultVal: z.string().optional(),
|
||||
placeholder: z.string().optional()
|
||||
})
|
||||
|
||||
export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
control_after_generate: z.boolean().optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: resultItemType.optional(),
|
||||
allow_batch: z.boolean().optional(),
|
||||
video_upload: z.boolean().optional(),
|
||||
audio_upload: z.boolean().optional(),
|
||||
animated_image_upload: z.boolean().optional(),
|
||||
options: z.array(zComboOption).optional(),
|
||||
remote: zRemoteWidgetConfig.optional(),
|
||||
/** Whether the widget is a multi-select widget. */
|
||||
multi_select: zMultiSelectOption.optional()
|
||||
})
|
||||
|
||||
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
|
||||
const zFloatInputSpec = z.tuple([
|
||||
z.literal('FLOAT'),
|
||||
zFloatInputOptions.optional()
|
||||
])
|
||||
const zBooleanInputSpec = z.tuple([
|
||||
z.literal('BOOLEAN'),
|
||||
zBooleanInputOptions.optional()
|
||||
])
|
||||
const zStringInputSpec = z.tuple([
|
||||
z.literal('STRING'),
|
||||
zStringInputOptions.optional()
|
||||
])
|
||||
/**
|
||||
* Legacy combo syntax.
|
||||
* @deprecated Use `zComboInputSpecV2` instead.
|
||||
*/
|
||||
const zComboInputSpec = z.tuple([
|
||||
z.array(zComboOption),
|
||||
zComboInputOptions.optional()
|
||||
])
|
||||
const zComboInputSpecV2 = z.tuple([
|
||||
z.literal('COMBO'),
|
||||
zComboInputOptions.optional()
|
||||
])
|
||||
|
||||
export function isComboInputSpecV1(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec {
|
||||
return Array.isArray(inputSpec[0])
|
||||
}
|
||||
|
||||
export function isIntInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is IntInputSpec {
|
||||
return inputSpec[0] === 'INT'
|
||||
}
|
||||
|
||||
export function isFloatInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is FloatInputSpec {
|
||||
return inputSpec[0] === 'FLOAT'
|
||||
}
|
||||
|
||||
export function isComboInputSpecV2(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpecV2 {
|
||||
return inputSpec[0] === 'COMBO'
|
||||
}
|
||||
|
||||
export function isComboInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec | ComboInputSpecV2 {
|
||||
return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of an input spec.
|
||||
*
|
||||
* @param inputSpec - The input spec to get the type of.
|
||||
* @returns The type of the input spec.
|
||||
*/
|
||||
export function getInputSpecType(inputSpec: InputSpec): string {
|
||||
return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the combo options from a combo input spec.
|
||||
*
|
||||
* @param inputSpec - The input spec to get the combo options from.
|
||||
* @returns The combo options.
|
||||
*/
|
||||
export function getComboSpecComboOptions(
|
||||
inputSpec: ComboInputSpec | ComboInputSpecV2
|
||||
): (number | string)[] {
|
||||
return (
|
||||
(isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
|
||||
const zCustomInputSpec = z.tuple([
|
||||
z.string().refine((value) => !excludedLiterals.has(value)),
|
||||
zBaseInputOptions.optional()
|
||||
])
|
||||
|
||||
const zInputSpec = z.union([
|
||||
zIntInputSpec,
|
||||
zFloatInputSpec,
|
||||
zBooleanInputSpec,
|
||||
zStringInputSpec,
|
||||
zComboInputSpec,
|
||||
zComboInputSpecV2,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
const zComfyInputsSpec = z.object({
|
||||
required: z.record(zInputSpec).optional(),
|
||||
optional: z.record(zInputSpec).optional(),
|
||||
// Frontend repo is not using it, but some custom nodes are using the
|
||||
// hidden field to pass various values.
|
||||
hidden: z.record(z.any()).optional()
|
||||
})
|
||||
|
||||
const zComfyNodeDataType = z.string()
|
||||
const zComfyComboOutput = z.array(zComboOption)
|
||||
const zComfyOutputTypesSpec = z.array(
|
||||
z.union([zComfyNodeDataType, zComfyComboOutput])
|
||||
)
|
||||
|
||||
export const zComfyNodeDef = z.object({
|
||||
input: zComfyInputsSpec.optional(),
|
||||
output: zComfyOutputTypesSpec.optional(),
|
||||
output_is_list: z.array(z.boolean()).optional(),
|
||||
output_name: z.array(z.string()).optional(),
|
||||
output_tooltips: z.array(z.string()).optional(),
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string(),
|
||||
help: z.string().optional(),
|
||||
category: z.string(),
|
||||
output_node: z.boolean(),
|
||||
python_module: z.string(),
|
||||
deprecated: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
/**
|
||||
* Whether the node is an API node. Running API nodes requires login to
|
||||
* Comfy Org account.
|
||||
* https://docs.comfy.org/tutorials/api-nodes/overview
|
||||
*/
|
||||
api_node: z.boolean().optional(),
|
||||
/**
|
||||
* Specifies the order of inputs for each input category.
|
||||
* Used to ensure consistent widget ordering regardless of JSON serialization.
|
||||
* Keys are 'required', 'optional', etc., values are arrays of input names.
|
||||
*/
|
||||
input_order: z.record(z.array(z.string())).optional()
|
||||
})
|
||||
|
||||
// `/object_info`
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
|
||||
|
||||
export type IntInputSpec = z.infer<typeof zIntInputSpec>
|
||||
export type FloatInputSpec = z.infer<typeof zFloatInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ComboInputSpecV2 = z.infer<typeof zComboInputSpecV2>
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
|
||||
export function validateComfyNodeDef(
|
||||
data: any,
|
||||
onError: (error: string) => void = console.warn
|
||||
): ComfyNodeDef | null {
|
||||
const result = zComfyNodeDef.safeParse(data)
|
||||
if (!result.success) {
|
||||
const zodError = fromZodError(result.error)
|
||||
onError(
|
||||
`Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
60
packages/schemas/src/signInSchema.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const apiKeySchema = z.object({
|
||||
apiKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.startsWith('comfyui-', t('validation.prefix', { prefix: 'comfyui-' }))
|
||||
.length(72, t('validation.length', { length: 72 }))
|
||||
})
|
||||
|
||||
export const signInSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email(t('validation.invalidEmail'))
|
||||
.min(1, t('validation.required')),
|
||||
password: z.string().min(1, t('validation.required'))
|
||||
})
|
||||
|
||||
export type SignInData = z.infer<typeof signInSchema>
|
||||
|
||||
const passwordSchema = z.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(8, t('validation.minLength', { length: 8 }))
|
||||
.max(32, t('validation.maxLength', { length: 32 }))
|
||||
.regex(/[A-Z]/, t('validation.password.uppercase'))
|
||||
.regex(/[a-z]/, t('validation.password.lowercase'))
|
||||
.regex(/\d/, t('validation.password.number'))
|
||||
.regex(/[^A-Za-z0-9]/, t('validation.password.special')),
|
||||
confirmPassword: z.string().min(1, t('validation.required'))
|
||||
})
|
||||
|
||||
export const updatePasswordSchema = passwordSchema.refine(
|
||||
(data) => data.password === data.confirmPassword,
|
||||
{
|
||||
message: t('validation.password.match'),
|
||||
path: ['confirmPassword']
|
||||
}
|
||||
)
|
||||
|
||||
export const signUpSchema = passwordSchema
|
||||
.extend({
|
||||
email: z
|
||||
.string()
|
||||
.email(t('validation.invalidEmail'))
|
||||
.min(1, t('validation.required')),
|
||||
personalDataConsent: z.boolean()
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t('validation.password.match'),
|
||||
path: ['confirmPassword']
|
||||
})
|
||||
.refine((data) => data.personalDataConsent === true, {
|
||||
message: t('validation.personalDataConsentRequired'),
|
||||
path: ['personalDataConsent']
|
||||
})
|
||||
|
||||
export type SignUpData = z.infer<typeof signUpSchema>
|
||||
8
packages/schemas/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
334
pnpm-lock.yaml
generated
@@ -17,6 +17,12 @@ importers:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.4.73-0
|
||||
version: 0.4.73-0
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/schemas':
|
||||
specifier: workspace:*
|
||||
version: link:packages/schemas
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/tailwind-utils
|
||||
@@ -71,6 +77,9 @@ 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)
|
||||
@@ -171,12 +180,6 @@ importers:
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
version: 9.35.0
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.2.66
|
||||
version: 1.2.66
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(eslint@9.35.0(jiti@2.4.2))(jsonc-eslint-parser@2.4.0)(vue-eslint-parser@10.2.0(eslint@9.35.0(jiti@2.4.2)))(yaml-eslint-parser@1.3.0)
|
||||
@@ -352,6 +355,35 @@ importers:
|
||||
specifier: ^3.24.1
|
||||
version: 3.24.1(zod@3.24.1)
|
||||
|
||||
packages/design-system:
|
||||
dependencies:
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.1.178
|
||||
version: 1.2.66
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.1.3
|
||||
version: 1.2.0
|
||||
devDependencies:
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.17
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
|
||||
packages/schemas:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.24.1
|
||||
zod-validation-error:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0(zod@3.24.1)
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
|
||||
packages/tailwind-utils:
|
||||
dependencies:
|
||||
clsx:
|
||||
@@ -2905,18 +2937,73 @@ 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==}
|
||||
|
||||
@@ -3058,10 +3145,16 @@ packages:
|
||||
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
anymatch@3.1.3:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
arg@5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
|
||||
argparse@1.0.10:
|
||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||
|
||||
@@ -3237,6 +3330,10 @@ packages:
|
||||
camel-case@4.1.2:
|
||||
resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==}
|
||||
|
||||
camelcase-css@2.0.1:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
camelcase@8.0.0:
|
||||
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -3366,6 +3463,10 @@ packages:
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
commander@4.1.1:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
commander@8.3.0:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -3571,6 +3672,9 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
diff-match-patch@1.0.5:
|
||||
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
|
||||
|
||||
@@ -3578,6 +3682,9 @@ packages:
|
||||
resolution: {integrity: sha512-7SCDfnQtBObcngVXNPZcnxGxqqPTK4UqeXeKAch+RGH5qpqadWbV9FmN71x9Bb4tTs0TNFb4FT/4Kz4P4Cjqcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
doctypes@1.1.0:
|
||||
resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==}
|
||||
|
||||
@@ -4466,6 +4573,10 @@ packages:
|
||||
resolution: {integrity: sha512-LUU2Gx8EhYxpdzTR6BmjL1ifgOAQJQELTHOiPv9KITaKjZvJ9Jmgigx01tuZ49id37LorpGc9dPBPlXTboXScw==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
jiti@1.21.7:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
jiti@2.4.2:
|
||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||
hasBin: true
|
||||
@@ -4689,6 +4800,10 @@ packages:
|
||||
resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@@ -5051,6 +5166,9 @@ packages:
|
||||
resolution: {integrity: sha512-SsI/exkodHsh+ofCV7An2PZWRaJC7eFVl7gtHQlMWFEDmWtb7cELr/GK32Nhe/6dZQhbr81o+Moswx9aXN3RRg==}
|
||||
engines: {node: '>=18.2.0'}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
nanoid@3.3.8:
|
||||
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -5142,6 +5260,10 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-hash@3.0.0:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
object-keys@1.1.1:
|
||||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5317,6 +5439,10 @@ packages:
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
pify@2.3.0:
|
||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pinia@2.2.2:
|
||||
resolution: {integrity: sha512-ja2XqFWZC36mupU4z1ZzxeTApV7DOw44cV4dhQ9sGwun+N89v/XP7+j7q6TanS1u1tdbK4r+1BUx7heMaIdagA==}
|
||||
peerDependencies:
|
||||
@@ -5329,6 +5455,10 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
pirates@4.0.7:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
@@ -5345,10 +5475,47 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss-import@15.1.0:
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
|
||||
postcss-js@4.1.0:
|
||||
resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
|
||||
engines: {node: ^12 || ^14 || >= 16}
|
||||
peerDependencies:
|
||||
postcss: ^8.4.21
|
||||
|
||||
postcss-load-config@4.0.2:
|
||||
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
|
||||
engines: {node: '>= 14'}
|
||||
peerDependencies:
|
||||
postcss: '>=8.0.9'
|
||||
ts-node: '>=9.0.0'
|
||||
peerDependenciesMeta:
|
||||
postcss:
|
||||
optional: true
|
||||
ts-node:
|
||||
optional: true
|
||||
|
||||
postcss-nested@6.2.0:
|
||||
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.2.14
|
||||
|
||||
postcss-selector-parser@6.1.0:
|
||||
resolution: {integrity: sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
postcss@8.5.1:
|
||||
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -5545,6 +5712,9 @@ packages:
|
||||
resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
read-cache@1.0.0:
|
||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
@@ -5866,6 +6036,11 @@ packages:
|
||||
subscribable-things@2.1.53:
|
||||
resolution: {integrity: sha512-zWvN9F/eYQWDKszXl4NXkyqPXvMDZDmXfcHiM5C5WQZTTY2OK+2TZeDlA9oio69FEPqPu9T6yeEcAhQ2uRmnaw==}
|
||||
|
||||
sucrase@3.35.0:
|
||||
resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
superjson@2.2.2:
|
||||
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -5906,6 +6081,11 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.1.0'
|
||||
|
||||
tailwindcss@3.4.17:
|
||||
resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tailwindcss@4.1.12:
|
||||
resolution: {integrity: sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==}
|
||||
|
||||
@@ -5930,6 +6110,13 @@ packages:
|
||||
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
three@0.170.0:
|
||||
resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==}
|
||||
|
||||
@@ -6016,6 +6203,9 @@ packages:
|
||||
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
|
||||
engines: {node: '>=6.10'}
|
||||
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
ts-map@1.0.3:
|
||||
resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
|
||||
|
||||
@@ -9675,10 +9865,28 @@ 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))
|
||||
@@ -9692,6 +9900,10 @@ 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)':
|
||||
@@ -9825,11 +10037,15 @@ snapshots:
|
||||
|
||||
ansi-styles@6.2.1: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
arg@5.0.2: {}
|
||||
|
||||
argparse@1.0.10:
|
||||
dependencies:
|
||||
sprintf-js: 1.0.3
|
||||
@@ -10035,6 +10251,8 @@ snapshots:
|
||||
pascal-case: 3.1.2
|
||||
tslib: 2.8.1
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
camelcase@8.0.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001737: {}
|
||||
@@ -10148,6 +10366,8 @@ snapshots:
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
commander@8.3.0: {}
|
||||
|
||||
compare-versions@6.1.1: {}
|
||||
@@ -10330,6 +10550,8 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff-match-patch@1.0.5: {}
|
||||
|
||||
dirty-json@0.9.2:
|
||||
@@ -10338,6 +10560,8 @@ snapshots:
|
||||
unescape-js: 1.1.4
|
||||
utf8: 3.0.0
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
doctypes@1.1.0: {}
|
||||
|
||||
dom-accessibility-api@0.5.16: {}
|
||||
@@ -11287,6 +11511,8 @@ snapshots:
|
||||
chalk: 4.1.2
|
||||
pretty-format: 30.0.5
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
jiti@2.4.2: {}
|
||||
|
||||
jiti@2.5.1: {}
|
||||
@@ -11508,6 +11734,8 @@ snapshots:
|
||||
|
||||
lilconfig@3.1.2: {}
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
lines-and-columns@2.0.3: {}
|
||||
@@ -12064,6 +12292,12 @@ snapshots:
|
||||
'@babel/runtime': 7.28.4
|
||||
tslib: 2.8.1
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
object-assign: 4.1.1
|
||||
thenify-all: 1.6.0
|
||||
|
||||
nanoid@3.3.8: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
@@ -12177,6 +12411,8 @@ snapshots:
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@3.0.0: {}
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
|
||||
ohash@2.0.11: {}
|
||||
@@ -12367,6 +12603,8 @@ snapshots:
|
||||
|
||||
pidtree@0.6.0: {}
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pinia@2.2.2(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.3
|
||||
@@ -12375,6 +12613,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.2
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
@@ -12395,11 +12635,42 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.1):
|
||||
dependencies:
|
||||
postcss: 8.5.1
|
||||
postcss-value-parser: 4.2.0
|
||||
read-cache: 1.0.0
|
||||
resolve: 1.22.10
|
||||
|
||||
postcss-js@4.1.0(postcss@8.5.1):
|
||||
dependencies:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.5.1
|
||||
|
||||
postcss-load-config@4.0.2(postcss@8.5.1):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
yaml: 2.8.1
|
||||
optionalDependencies:
|
||||
postcss: 8.5.1
|
||||
|
||||
postcss-nested@6.2.0(postcss@8.5.1):
|
||||
dependencies:
|
||||
postcss: 8.5.1
|
||||
postcss-selector-parser: 6.1.2
|
||||
|
||||
postcss-selector-parser@6.1.0:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.5.1:
|
||||
dependencies:
|
||||
nanoid: 3.3.8
|
||||
@@ -12675,6 +12946,10 @@ snapshots:
|
||||
|
||||
react@19.1.1: {}
|
||||
|
||||
read-cache@1.0.0:
|
||||
dependencies:
|
||||
pify: 2.3.0
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
@@ -13057,6 +13332,16 @@ snapshots:
|
||||
rxjs-interop: 2.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
sucrase@3.35.0:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
commander: 4.1.1
|
||||
glob: 10.4.5
|
||||
lines-and-columns: 1.2.4
|
||||
mz: 2.7.0
|
||||
pirates: 4.0.7
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
superjson@2.2.2:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
@@ -13094,6 +13379,33 @@ snapshots:
|
||||
dependencies:
|
||||
tailwindcss: 4.1.12
|
||||
|
||||
tailwindcss@3.4.17:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
chokidar: 3.6.0
|
||||
didyoumean: 1.2.2
|
||||
dlv: 1.1.3
|
||||
fast-glob: 3.3.3
|
||||
glob-parent: 6.0.2
|
||||
is-glob: 4.0.3
|
||||
jiti: 1.21.7
|
||||
lilconfig: 3.1.3
|
||||
micromatch: 4.0.8
|
||||
normalize-path: 3.0.0
|
||||
object-hash: 3.0.0
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.5.1
|
||||
postcss-import: 15.1.0(postcss@8.5.1)
|
||||
postcss-js: 4.1.0(postcss@8.5.1)
|
||||
postcss-load-config: 4.0.2(postcss@8.5.1)
|
||||
postcss-nested: 6.2.0(postcss@8.5.1)
|
||||
postcss-selector-parser: 6.1.2
|
||||
resolve: 1.22.10
|
||||
sucrase: 3.35.0
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
tailwindcss@4.1.12: {}
|
||||
|
||||
tapable@2.2.3: {}
|
||||
@@ -13128,6 +13440,14 @@ snapshots:
|
||||
glob: 10.4.5
|
||||
minimatch: 9.0.5
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
|
||||
thenify@3.3.1:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
three@0.170.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
@@ -13193,6 +13513,8 @@ snapshots:
|
||||
|
||||
ts-dedent@2.2.0: {}
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
ts-map@1.0.3: {}
|
||||
|
||||
tsconfig-paths@4.2.0:
|
||||
|
||||
BIN
public/assets/images/default-template.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
10
src/assets/icons/custom/dark-info.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 623 B |
@@ -1,7 +1,19 @@
|
||||
<template>
|
||||
<div class="flex-1 w-full h-full">
|
||||
<div :class="containerClasses">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<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>
|
||||
|
||||
@@ -8,15 +8,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
ratio?: 'square' | 'portrait' | 'tallPortrait'
|
||||
const { ratio = 'square', type } = defineProps<{
|
||||
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
|
||||
type?: string
|
||||
}>()
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const baseClasses =
|
||||
'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'
|
||||
'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`
|
||||
}
|
||||
|
||||
const ratioClasses = {
|
||||
smallSquare: 'aspect-240/311',
|
||||
square: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tallPortrait: 'aspect-256/353'
|
||||
|
||||
@@ -2,26 +2,40 @@
|
||||
<div :class="topStyle">
|
||||
<slot class="absolute top-0 left-0 w-full h-full"></slot>
|
||||
|
||||
<div class="absolute top-2 left-2 flex gap-2">
|
||||
<div
|
||||
v-if="slots['top-left']"
|
||||
class="absolute top-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||
>
|
||||
<slot name="top-left"></slot>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2 flex gap-2">
|
||||
<div
|
||||
v-if="slots['top-right']"
|
||||
class="absolute top-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||
>
|
||||
<slot name="top-right"></slot>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-2 left-2 flex gap-2">
|
||||
<div
|
||||
v-if="slots['bottom-left']"
|
||||
class="absolute bottom-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||
>
|
||||
<slot name="bottom-left"></slot>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-2 right-2 flex gap-2">
|
||||
<div
|
||||
v-if="slots['bottom-right']"
|
||||
class="absolute bottom-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||
>
|
||||
<slot name="bottom-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
ratio?: 'square' | 'landscape'
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
v-if="hasError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
|
||||
>
|
||||
<i class="pi pi-image text-2xl" />
|
||||
<img
|
||||
src="/assets/images/default-template.png"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="imageClass"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
class="search-box-input w-full"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:autofocus="autofocus"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
@@ -57,7 +58,8 @@ const {
|
||||
icon = 'pi pi-search',
|
||||
debounceTime = 300,
|
||||
filterIcon,
|
||||
filters = []
|
||||
filters = [],
|
||||
autofocus = false
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
@@ -65,6 +67,7 @@ const {
|
||||
debounceTime?: number
|
||||
filterIcon?: string
|
||||
filters?: TFilter[]
|
||||
autofocus?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
757
src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
Normal file
@@ -0,0 +1,757 @@
|
||||
<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.vramLowToHigh',
|
||||
'VRAM Utilization (Low to High)'
|
||||
),
|
||||
value: 'vram-low-to-high'
|
||||
},
|
||||
{
|
||||
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>
|
||||
@@ -62,6 +62,7 @@
|
||||
<template v-if="comfyAppReady">
|
||||
<TitleEditor />
|
||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||
<NodeOptions />
|
||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||
</template>
|
||||
@@ -87,6 +88,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodeOptions from '@/components/graph/selectionToolbox/NodeOptions.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
|
||||
@@ -480,13 +480,6 @@ describe('SelectionToolbox', () => {
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('should still show MoreOptions when no items selected', () => {
|
||||
canvasStore.selectedItems = []
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('.more-options').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide most buttons when no items selected', () => {
|
||||
canvasStore.selectedItems = []
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:command="command"
|
||||
/>
|
||||
<ExecuteButton v-if="showExecute" />
|
||||
<MoreOptions />
|
||||
<NodeOptionsButton />
|
||||
</Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -68,7 +68,7 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||
import MoreOptions from './selectionToolbox/MoreOptions.vue'
|
||||
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
||||
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<Button
|
||||
ref="buttonRef"
|
||||
v-tooltip.top="{
|
||||
value: $t('g.moreOptions'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
data-testid="more-options-button"
|
||||
text
|
||||
class="h-8 w-8 px-0"
|
||||
severity="secondary"
|
||||
@click="toggle"
|
||||
>
|
||||
<i-lucide:more-vertical class="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<Popover
|
||||
ref="popover"
|
||||
:append-to="'body'"
|
||||
@@ -51,7 +36,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
@@ -64,6 +48,7 @@ import {
|
||||
import {
|
||||
type MenuOption,
|
||||
type SubMenuOption,
|
||||
registerNodeOptionsInstance,
|
||||
useMoreOptionsMenu
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
|
||||
@@ -74,7 +59,8 @@ import MenuOptionItem from './MenuOptionItem.vue'
|
||||
import SubmenuPopover from './SubmenuPopover.vue'
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
const buttonRef = ref<InstanceType<typeof Button> | HTMLElement | null>(null)
|
||||
const targetElement = ref<HTMLElement | null>(null)
|
||||
const isTriggeredByToolbox = ref<boolean>(true)
|
||||
// Track open state ourselves so we can restore after drag/move
|
||||
const isOpen = ref(false)
|
||||
const wasOpenBeforeHide = ref(false)
|
||||
@@ -90,11 +76,6 @@ const canvasInteractions = useCanvasInteractions()
|
||||
const minimap = useMinimap()
|
||||
const containerStyles = minimap.containerStyles
|
||||
|
||||
function getButtonEl(): HTMLElement | null {
|
||||
const el = (buttonRef.value as any)?.$el || buttonRef.value
|
||||
return el instanceof HTMLElement ? el : null
|
||||
}
|
||||
|
||||
let lastLogTs = 0
|
||||
const LOG_INTERVAL = 120 // ms
|
||||
let overlayElCache: HTMLElement | null = null
|
||||
@@ -109,7 +90,7 @@ function resolveOverlayEl(): HTMLElement | null {
|
||||
return direct
|
||||
}
|
||||
// Fallback: try to locate a recent popover root near the button (same z-index class + absolute)
|
||||
const btn = getButtonEl()
|
||||
const btn = targetElement.value
|
||||
if (btn) {
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll('div.absolute.z-50')
|
||||
@@ -132,20 +113,24 @@ function resolveOverlayEl(): HTMLElement | null {
|
||||
|
||||
const repositionPopover = () => {
|
||||
if (!isOpen.value) return
|
||||
const btn = getButtonEl()
|
||||
const btn = targetElement.value
|
||||
const overlayEl = resolveOverlayEl()
|
||||
if (!btn || !overlayEl) return
|
||||
const rect = btn.getBoundingClientRect()
|
||||
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
|
||||
const left = rect.left + rect.width / 2
|
||||
const top = rect.bottom + marginY
|
||||
const left = isTriggeredByToolbox.value
|
||||
? rect.left + rect.width / 2
|
||||
: rect.right - rect.width / 4
|
||||
const top = isTriggeredByToolbox.value
|
||||
? rect.bottom + marginY
|
||||
: rect.top - marginY - 6
|
||||
try {
|
||||
overlayEl.style.position = 'fixed'
|
||||
overlayEl.style.left = `${left}px`
|
||||
overlayEl.style.top = `${top}px`
|
||||
overlayEl.style.transform = 'translate(-50%, 0)'
|
||||
} catch (e) {
|
||||
console.warn('[MoreOptions] Failed to set overlay style', e)
|
||||
console.warn('[NodeOptions] Failed to set overlay style', e)
|
||||
return
|
||||
}
|
||||
const now = performance.now()
|
||||
@@ -156,9 +141,16 @@ const repositionPopover = () => {
|
||||
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)
|
||||
|
||||
function openPopover(triggerEvent?: Event): boolean {
|
||||
const el = getButtonEl()
|
||||
function openPopover(
|
||||
triggerEvent?: Event,
|
||||
element?: HTMLElement,
|
||||
clickedFromToolbox?: boolean
|
||||
): boolean {
|
||||
const el = element || targetElement.value
|
||||
if (!el || !el.isConnected) return false
|
||||
targetElement.value = el
|
||||
if (clickedFromToolbox !== undefined)
|
||||
isTriggeredByToolbox.value = clickedFromToolbox
|
||||
bump()
|
||||
popover.value?.show(triggerEvent ?? new Event('reopen'), el)
|
||||
isOpen.value = true
|
||||
@@ -191,7 +183,7 @@ function attemptRestore() {
|
||||
if (isOpen.value) return
|
||||
if (!wasOpenBeforeHide.value && !moreOptionsRestorePending.value) return
|
||||
// Try immediately
|
||||
if (openPopover(new Event('reopen'))) {
|
||||
if (openPopover(new Event('reopen'), targetElement.value || undefined)) {
|
||||
wasOpenBeforeHide.value = false
|
||||
restoreAttempts = 0
|
||||
return
|
||||
@@ -202,13 +194,24 @@ function attemptRestore() {
|
||||
requestAnimationFrame(() => attemptRestore())
|
||||
}
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
const toggle = (
|
||||
event: Event,
|
||||
element?: HTMLElement,
|
||||
clickedFromToolbox?: boolean
|
||||
) => {
|
||||
if (isOpen.value) closePopover('manual')
|
||||
else openPopover(event)
|
||||
else openPopover(event, element, clickedFromToolbox)
|
||||
}
|
||||
|
||||
const hide = (reason: HideReason = 'manual') => closePopover(reason)
|
||||
|
||||
// Export functions for external triggering
|
||||
defineExpose({
|
||||
toggle,
|
||||
hide,
|
||||
isOpen
|
||||
})
|
||||
|
||||
const hideAll = () => {
|
||||
hideAllSubmenus(
|
||||
menuOptionsWithSubmenu.value,
|
||||
@@ -305,6 +308,13 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// Register this instance globally
|
||||
registerNodeOptionsInstance({
|
||||
toggle,
|
||||
hide,
|
||||
isOpen
|
||||
})
|
||||
|
||||
if (moreOptionsRestorePending.value && !isOpen.value) {
|
||||
requestAnimationFrame(() => attemptRestore())
|
||||
}
|
||||
@@ -312,5 +322,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSync()
|
||||
// Unregister on unmount
|
||||
registerNodeOptionsInstance(null)
|
||||
})
|
||||
</script>
|
||||
33
src/components/graph/selectionToolbox/NodeOptionsButton.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<Button
|
||||
ref="buttonRef"
|
||||
v-tooltip.top="{
|
||||
value: $t('g.moreOptions'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
data-testid="more-options-button"
|
||||
text
|
||||
class="h-8 w-8 px-0"
|
||||
severity="secondary"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i-lucide:more-vertical class="w-4 h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
|
||||
const buttonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
const el = (buttonRef.value as any)?.$el || buttonRef.value
|
||||
const buttonEl = el instanceof HTMLElement ? el : null
|
||||
if (buttonEl) {
|
||||
toggleNodeOptions(event, buttonEl, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
-->
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
v-bind="$attrs"
|
||||
v-bind="{ ...$attrs, options: filteredOptions }"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:max-selected-labels="0"
|
||||
@@ -105,10 +105,11 @@
|
||||
</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 } from 'vue'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
@@ -158,7 +159,7 @@ const {
|
||||
const selectedItems = defineModel<Option[]>({
|
||||
required: true
|
||||
})
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
@@ -167,6 +168,40 @@ 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,9 +1,15 @@
|
||||
<template>
|
||||
<div :class="wrapperStyle">
|
||||
<div :class="wrapperStyle" @click="focusInput">
|
||||
<i-lucide:search :class="iconColorStyle" />
|
||||
<InputText
|
||||
ref="input"
|
||||
v-model="searchQuery"
|
||||
:placeholder="placeHolder || 'Search...'"
|
||||
:aria-label="
|
||||
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
|
||||
"
|
||||
:placeholder="
|
||||
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
|
||||
"
|
||||
type="text"
|
||||
unstyled
|
||||
:class="inputStyle"
|
||||
@@ -13,8 +19,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
@@ -29,6 +36,13 @@ 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 overflow-hidden whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<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>
|
||||
@@ -1,273 +0,0 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
<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>
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -1,68 +0,0 @@
|
||||
<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>
|
||||
@@ -1,185 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
<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>
|
||||
@@ -1,112 +0,0 @@
|
||||
<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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="px-4">
|
||||
<span>{{ $t('templateWorkflows.title') }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,50 +0,0 @@
|
||||
<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,6 +1,12 @@
|
||||
<template>
|
||||
<BaseThumbnail>
|
||||
<div class="w-full h-full flex items-center justify-center p-4">
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center p-4"
|
||||
:style="{
|
||||
backgroundImage: 'url(/assets/images/default-template.png)',
|
||||
backgroundRepeat: 'round'
|
||||
}"
|
||||
>
|
||||
<audio controls class="w-full relative" :src="src" @click.stop />
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
|
||||
@@ -51,8 +51,9 @@ describe('BaseThumbnail', () => {
|
||||
vm.error = true
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.pi-file').exists()).toBe(true)
|
||||
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
|
||||
expect(
|
||||
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('applies transition classes to content', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="relative w-64 h-64 rounded-t-lg overflow-hidden select-none">
|
||||
<div
|
||||
class="relative w-full aspect-square rounded-t-lg overflow-hidden select-none"
|
||||
>
|
||||
<div
|
||||
v-if="!error"
|
||||
ref="contentRef"
|
||||
@@ -11,7 +13,11 @@
|
||||
<slot />
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<i class="pi pi-file text-4xl" />
|
||||
<img
|
||||
src="/assets/images/default-template.png"
|
||||
draggable="false"
|
||||
class="transform-gpu transition-transform duration-300 ease-out w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<i :class="icon" class="text-xs text-neutral" />
|
||||
<i :class="icon" class="text-sm text-neutral" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
<template>
|
||||
<h3
|
||||
class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between m-0 px-3 py-0 pt-5',
|
||||
collapsible && 'cursor-pointer select-none'
|
||||
)
|
||||
"
|
||||
@click="collapsible && toggleCollapse()"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { title } = defineProps<{
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
title,
|
||||
modelValue = false,
|
||||
collapsible = false
|
||||
} = 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>
|
||||
|
||||
@@ -7,19 +7,27 @@
|
||||
<slot name="header-title"></slot>
|
||||
</PanelHeader>
|
||||
|
||||
<nav class="flex-1 px-3 py-4 flex flex-col gap-1">
|
||||
<nav
|
||||
class="flex-1 px-3 py-4 flex flex-col gap-1 overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
<template v-for="(item, index) in navItems" :key="index">
|
||||
<div v-if="'items' in item" class="flex flex-col gap-2">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<NavItem
|
||||
@@ -36,7 +44,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
@@ -53,6 +61,9 @@ 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
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
@@ -20,6 +22,7 @@ export interface SafeWidgetData {
|
||||
label?: string
|
||||
options?: Record<string, unknown>
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
spec?: InputSpec
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -53,6 +56,7 @@ export interface GraphNodeManager {
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
|
||||
@@ -82,6 +86,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
@@ -89,15 +94,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback
|
||||
callback: widget.callback,
|
||||
spec
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined, // Already a valid WidgetValue
|
||||
options: undefined,
|
||||
callback: undefined
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
@@ -32,6 +32,47 @@ export enum BadgeVariant {
|
||||
DEPRECATED = 'deprecated'
|
||||
}
|
||||
|
||||
// Global singleton for NodeOptions component reference
|
||||
let nodeOptionsInstance: null | NodeOptionsInstance = null
|
||||
|
||||
/**
|
||||
* Toggle the node options popover
|
||||
* @param event - The trigger event
|
||||
* @param element - The target element (button) that triggered the popover
|
||||
*/
|
||||
export function toggleNodeOptions(
|
||||
event: Event,
|
||||
element: HTMLElement,
|
||||
clickedFromToolbox: boolean = false
|
||||
) {
|
||||
if (nodeOptionsInstance?.toggle) {
|
||||
nodeOptionsInstance.toggle(event, element, clickedFromToolbox)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the node options popover
|
||||
*/
|
||||
interface NodeOptionsInstance {
|
||||
toggle: (
|
||||
event: Event,
|
||||
element: HTMLElement,
|
||||
clickedFromToolbox: boolean
|
||||
) => void
|
||||
hide: () => void
|
||||
isOpen: Ref<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the NodeOptions component instance
|
||||
* @param instance - The NodeOptions component instance
|
||||
*/
|
||||
export function registerNodeOptionsInstance(
|
||||
instance: null | NodeOptionsInstance
|
||||
) {
|
||||
nodeOptionsInstance = instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing the More Options menu configuration
|
||||
* Refactored to use smaller, focused composables for better maintainability
|
||||
@@ -181,6 +222,7 @@ export function useMoreOptionsMenu() {
|
||||
menuOptions,
|
||||
menuOptionsWithSubmenu,
|
||||
bump,
|
||||
hasSubgraphs
|
||||
hasSubgraphs,
|
||||
registerNodeOptionsInstance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ 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[] {
|
||||
@@ -264,7 +266,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Browse Templates',
|
||||
function: () => {
|
||||
dialogService.showTemplateWorkflowsDialog()
|
||||
useWorkflowTemplateSelectorDialog().show()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,57 +1,213 @@
|
||||
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'
|
||||
| 'vram-low-to-high'
|
||||
| 'model-size-low-to-high'
|
||||
>('newest')
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
return Array.isArray(templateData) ? templateData : []
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const templateData = templatesArray.value
|
||||
if (templateData.length === 0) {
|
||||
return []
|
||||
// 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
|
||||
}
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
return templateData
|
||||
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||
return results.map((result) => result.item)
|
||||
})
|
||||
|
||||
const filteredByModels = computed(() => {
|
||||
if (selectedModels.value.length === 0) {
|
||||
return filteredBySearch.value
|
||||
}
|
||||
|
||||
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)
|
||||
return filteredBySearch.value.filter((template) => {
|
||||
if (!template.models || !Array.isArray(template.models)) {
|
||||
return false
|
||||
}
|
||||
return selectedModels.value.some((selectedModel) =>
|
||||
template.models?.includes(selectedModel)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 'vram-low-to-high':
|
||||
// TODO: Implement VRAM sorting when VRAM data is available
|
||||
// For now, keep original order
|
||||
return templates
|
||||
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,
|
||||
resetFilters
|
||||
totalCount,
|
||||
|
||||
// Methods
|
||||
resetFilters,
|
||||
removeModelFilter,
|
||||
removeUseCaseFilter,
|
||||
removeLicenseFilter
|
||||
}
|
||||
}
|
||||
|
||||
38
src/composables/useWorkflowTemplateSelectorDialog.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,7 @@
|
||||
"nodesRunning": "nodes running",
|
||||
"duplicate": "Duplicate",
|
||||
"moreWorkflows": "More workflows",
|
||||
"seeTutorial": "See a tutorial",
|
||||
"nodeRenderError": "Node Render Error",
|
||||
"nodeContentError": "Node Content Error",
|
||||
"nodeHeaderError": "Node Header Error",
|
||||
@@ -685,6 +686,7 @@
|
||||
"ComfyUI Examples": "ComfyUI Examples",
|
||||
"Custom Nodes": "Custom Nodes",
|
||||
"Basics": "Basics",
|
||||
"GettingStarted": "Getting Started",
|
||||
"Flux": "Flux",
|
||||
"ControlNet": "ControlNet",
|
||||
"Upscaling": "Upscaling",
|
||||
@@ -693,6 +695,7 @@
|
||||
"Area Composition": "Area Composition",
|
||||
"3D": "3D",
|
||||
"Audio": "Audio",
|
||||
"LLMs": "LLMs",
|
||||
"Image API": "Image API",
|
||||
"Video API": "Video API",
|
||||
"LLM API": "LLM API",
|
||||
@@ -1001,6 +1004,24 @@
|
||||
"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": {
|
||||
@@ -1851,7 +1872,15 @@
|
||||
"copyTooltip": "Copy message to clipboard"
|
||||
},
|
||||
"widgets": {
|
||||
"selectModel": "Select model"
|
||||
"selectModel": "Select model",
|
||||
"uploadSelect": {
|
||||
"placeholder": "Select...",
|
||||
"placeholderImage": "Select image...",
|
||||
"placeholderAudio": "Select audio...",
|
||||
"placeholderVideo": "Select video...",
|
||||
"placeholderModel": "Select model...",
|
||||
"placeholderUnknown": "Select media..."
|
||||
}
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"inputs": "Inputs",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
class="settings-search-box w-full mb-2"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
autofocus
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<Listbox
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useTemplateWorkflows() {
|
||||
const getTemplateThumbnailUrl = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
index = '1'
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
@@ -85,13 +85,12 @@ export function useTemplateWorkflows() {
|
||||
/**
|
||||
* Gets formatted template description
|
||||
*/
|
||||
const getTemplateDescription = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
const getTemplateDescription = (template: TemplateInfo) => {
|
||||
return (
|
||||
(template.localizedDescription || template.description)
|
||||
?.replace(/[-_]/g, ' ')
|
||||
.trim() ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { groupBy } from 'es-toolkit/compat'
|
||||
import Fuse from 'fuse.js'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { i18n, st } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { getCategoryIcon } from '@/utils/categoryIcons'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { api } from '@/scripts/api'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
} from '../types/template'
|
||||
|
||||
const SHOULD_SORT_CATEGORIES = new Set([
|
||||
// API Node templates should be strictly sorted by name to avoid any
|
||||
// favoritism or bias towards a particular API. Other categories can
|
||||
// have their ordering specified in index.json freely.
|
||||
'Image API',
|
||||
'Video API'
|
||||
])
|
||||
// Enhanced template interface for easier filtering
|
||||
interface EnhancedTemplate extends TemplateInfo {
|
||||
sourceModule: string
|
||||
category?: string
|
||||
categoryType?: string
|
||||
categoryGroup?: string // 'GENERATION TYPE' or 'CLOSED SOURCE MODELS'
|
||||
isEssential?: boolean
|
||||
searchableText?: string
|
||||
}
|
||||
|
||||
export const useWorkflowTemplatesStore = defineStore(
|
||||
'workflowTemplates',
|
||||
@@ -26,36 +31,13 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
|
||||
const isLoaded = ref(false)
|
||||
|
||||
/**
|
||||
* Sort a list of templates in alphabetical order by localized display name.
|
||||
*/
|
||||
const sortTemplateList = (templates: TemplateInfo[]) =>
|
||||
templates.sort((a, b) => {
|
||||
const aName = st(
|
||||
`templateWorkflows.name.${normalizeI18nKey(a.name)}`,
|
||||
a.title ?? a.name
|
||||
)
|
||||
const bName = st(
|
||||
`templateWorkflows.name.${normalizeI18nKey(b.name)}`,
|
||||
b.name
|
||||
)
|
||||
return aName.localeCompare(bName)
|
||||
})
|
||||
// Store filter mappings for dynamic categories
|
||||
type FilterData = {
|
||||
category?: string
|
||||
categoryGroup?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort any template categories (grouped templates) that should be sorted.
|
||||
* Leave other categories' templates in their original order specified in index.json
|
||||
*/
|
||||
const sortCategoryTemplates = (categories: WorkflowTemplates[]) =>
|
||||
categories.map((category) => {
|
||||
if (SHOULD_SORT_CATEGORIES.has(category.title)) {
|
||||
return {
|
||||
...category,
|
||||
templates: sortTemplateList(category.templates)
|
||||
}
|
||||
}
|
||||
return category
|
||||
})
|
||||
const categoryFilters = ref(new Map<string, FilterData>())
|
||||
|
||||
/**
|
||||
* Add localization fields to a template.
|
||||
@@ -144,12 +126,13 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Original grouped templates for backward compatibility
|
||||
*/
|
||||
const groupedTemplates = computed<TemplateGroup[]>(() => {
|
||||
// Get regular categories
|
||||
const allTemplates = [
|
||||
...sortCategoryTemplates(coreTemplates.value).map(
|
||||
localizeTemplateCategory
|
||||
),
|
||||
...coreTemplates.value.map(localizeTemplateCategory),
|
||||
...Object.entries(customTemplates.value).map(
|
||||
([moduleName, templates]) => ({
|
||||
moduleName,
|
||||
@@ -169,38 +152,286 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
]
|
||||
|
||||
// Group templates by their main category
|
||||
const groupedByCategory = Object.entries(
|
||||
groupBy(allTemplates, (template) =>
|
||||
template.moduleName === 'default'
|
||||
? st(
|
||||
'templateWorkflows.category.ComfyUI Examples',
|
||||
'ComfyUI Examples'
|
||||
)
|
||||
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
|
||||
)
|
||||
).map(([label, modules]) => ({ label, modules }))
|
||||
const groupedByCategory = [
|
||||
{
|
||||
label: st(
|
||||
'templateWorkflows.category.ComfyUI Examples',
|
||||
'ComfyUI Examples'
|
||||
),
|
||||
modules: [
|
||||
createAllCategory(),
|
||||
...allTemplates.filter((t) => t.moduleName === 'default')
|
||||
]
|
||||
},
|
||||
...(Object.keys(customTemplates.value).length > 0
|
||||
? [
|
||||
{
|
||||
label: st(
|
||||
'templateWorkflows.category.Custom Nodes',
|
||||
'Custom Nodes'
|
||||
),
|
||||
modules: allTemplates.filter((t) => t.moduleName !== 'default')
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
// Insert the "All" category at the top of the "ComfyUI Examples" group
|
||||
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
|
||||
(group) =>
|
||||
group.label ===
|
||||
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
return groupedByCategory
|
||||
})
|
||||
|
||||
/**
|
||||
* Enhanced templates with proper categorization for filtering
|
||||
*/
|
||||
const enhancedTemplates = computed<EnhancedTemplate[]>(() => {
|
||||
const allTemplates: EnhancedTemplate[] = []
|
||||
|
||||
// Process core templates
|
||||
coreTemplates.value.forEach((category) => {
|
||||
category.templates.forEach((template) => {
|
||||
const enhancedTemplate: EnhancedTemplate = {
|
||||
...template,
|
||||
sourceModule: category.moduleName,
|
||||
category: category.title,
|
||||
categoryType: category.type,
|
||||
categoryGroup: category.category,
|
||||
isEssential: category.isEssential,
|
||||
searchableText: [
|
||||
template.title || template.name,
|
||||
template.description || '',
|
||||
category.title,
|
||||
...(template.tags || []),
|
||||
...(template.models || [])
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
allTemplates.push(enhancedTemplate)
|
||||
})
|
||||
})
|
||||
|
||||
// Process custom templates
|
||||
Object.entries(customTemplates.value).forEach(
|
||||
([moduleName, templates]) => {
|
||||
templates.forEach((name) => {
|
||||
const enhancedTemplate: EnhancedTemplate = {
|
||||
name,
|
||||
title: name,
|
||||
description: name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
sourceModule: moduleName,
|
||||
category: 'Extensions',
|
||||
categoryType: 'extension',
|
||||
searchableText: `${name} ${moduleName} extension`
|
||||
}
|
||||
allTemplates.push(enhancedTemplate)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (comfyExamplesGroupIndex !== -1) {
|
||||
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
|
||||
createAllCategory()
|
||||
return allTemplates
|
||||
})
|
||||
|
||||
/**
|
||||
* Fuse.js instance for advanced template searching and filtering
|
||||
*/
|
||||
const templateFuse = computed(() => {
|
||||
const fuseOptions = {
|
||||
keys: [
|
||||
{ name: 'searchableText', weight: 0.4 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'name', weight: 0.2 },
|
||||
{ name: 'tags', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3,
|
||||
includeScore: true
|
||||
}
|
||||
|
||||
return new Fuse(enhancedTemplates.value, fuseOptions)
|
||||
})
|
||||
|
||||
/**
|
||||
* Filter templates by category ID using stored filter mappings
|
||||
*/
|
||||
const filterTemplatesByCategory = (categoryId: string) => {
|
||||
if (categoryId === 'all') {
|
||||
return enhancedTemplates.value
|
||||
}
|
||||
|
||||
if (categoryId === 'basics') {
|
||||
// Filter for templates from categories marked as essential
|
||||
return enhancedTemplates.value.filter((t) => t.isEssential)
|
||||
}
|
||||
|
||||
// Handle extension-specific filters
|
||||
if (categoryId.startsWith('extension-')) {
|
||||
const moduleName = categoryId.replace('extension-', '')
|
||||
return enhancedTemplates.value.filter(
|
||||
(t) => t.sourceModule === moduleName
|
||||
)
|
||||
}
|
||||
|
||||
return groupedByCategory
|
||||
// Look up the filter from our stored mappings
|
||||
const filter = categoryFilters.value.get(categoryId)
|
||||
if (!filter) {
|
||||
return enhancedTemplates.value
|
||||
}
|
||||
|
||||
// Apply the filter
|
||||
return enhancedTemplates.value.filter((template) => {
|
||||
if (filter.category && template.category !== filter.category) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filter.categoryGroup &&
|
||||
template.categoryGroup !== filter.categoryGroup
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* New navigation structure dynamically built from JSON categories
|
||||
*/
|
||||
const navGroupedTemplates = computed<(NavItemData | NavGroupData)[]>(() => {
|
||||
if (!isLoaded.value) return []
|
||||
|
||||
const items: (NavItemData | NavGroupData)[] = []
|
||||
|
||||
// Clear and rebuild filter mappings
|
||||
categoryFilters.value.clear()
|
||||
|
||||
// 1. All Templates - always first
|
||||
items.push({
|
||||
id: 'all',
|
||||
label: st('templateWorkflows.category.All', 'All Templates'),
|
||||
icon: getCategoryIcon('all')
|
||||
})
|
||||
|
||||
// 2. Basics (isEssential categories) - always second if it exists
|
||||
let gettingStartedText = 'Getting Started'
|
||||
const essentialCat = coreTemplates.value.find(
|
||||
(cat) => cat.isEssential && cat.templates.length > 0
|
||||
)
|
||||
const hasEssentialCategories = Boolean(essentialCat)
|
||||
|
||||
if (essentialCat) {
|
||||
gettingStartedText = essentialCat.title
|
||||
}
|
||||
if (hasEssentialCategories) {
|
||||
items.push({
|
||||
id: 'basics',
|
||||
label: gettingStartedText,
|
||||
icon: 'icon-[lucide--graduation-cap]'
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Group categories from JSON dynamically
|
||||
const categoryGroups = new Map<
|
||||
string,
|
||||
{ title: string; items: NavItemData[] }
|
||||
>()
|
||||
|
||||
// Process all categories from JSON
|
||||
coreTemplates.value.forEach((category) => {
|
||||
// Skip essential categories as they're handled as Basics
|
||||
if (category.isEssential) return
|
||||
|
||||
const categoryGroup = category.category
|
||||
const categoryIcon = category.icon
|
||||
|
||||
if (categoryGroup) {
|
||||
if (!categoryGroups.has(categoryGroup)) {
|
||||
categoryGroups.set(categoryGroup, {
|
||||
title: categoryGroup,
|
||||
items: []
|
||||
})
|
||||
}
|
||||
|
||||
const group = categoryGroups.get(categoryGroup)!
|
||||
|
||||
// Generate unique ID for this category
|
||||
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
|
||||
|
||||
// Store the filter mapping
|
||||
categoryFilters.value.set(categoryId, {
|
||||
category: category.title,
|
||||
categoryGroup: categoryGroup
|
||||
})
|
||||
|
||||
group.items.push({
|
||||
id: categoryId,
|
||||
label: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(category.title)}`,
|
||||
category.title
|
||||
),
|
||||
icon: categoryIcon || getCategoryIcon(category.type || 'default')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Add grouped categories
|
||||
categoryGroups.forEach((group, groupName) => {
|
||||
if (group.items.length > 0) {
|
||||
items.push({
|
||||
title: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(groupName)}`,
|
||||
groupName
|
||||
.split(' ')
|
||||
.map(
|
||||
(word) =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
)
|
||||
.join(' ')
|
||||
),
|
||||
items: group.items
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Extensions - always last
|
||||
const extensionCounts = enhancedTemplates.value.filter(
|
||||
(t) => t.sourceModule !== 'default'
|
||||
).length
|
||||
|
||||
if (extensionCounts > 0) {
|
||||
// Get unique extension modules
|
||||
const extensionModules = Array.from(
|
||||
new Set(
|
||||
enhancedTemplates.value
|
||||
.filter((t) => t.sourceModule !== 'default')
|
||||
.map((t) => t.sourceModule)
|
||||
)
|
||||
).sort()
|
||||
|
||||
const extensionItems: NavItemData[] = extensionModules.map(
|
||||
(moduleName) => ({
|
||||
id: `extension-${moduleName}`,
|
||||
label: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
|
||||
moduleName
|
||||
),
|
||||
icon: getCategoryIcon('extensions')
|
||||
})
|
||||
)
|
||||
|
||||
items.push({
|
||||
title: st('templateWorkflows.category.Extensions', 'Extensions'),
|
||||
items: extensionItems,
|
||||
collapsible: true
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
async function loadWorkflowTemplates() {
|
||||
try {
|
||||
if (!isLoaded.value) {
|
||||
customTemplates.value = await api.getWorkflowTemplates()
|
||||
coreTemplates.value = await api.getCoreWorkflowTemplates()
|
||||
const locale = i18n.global.locale.value
|
||||
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
|
||||
isLoaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -210,6 +441,10 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
|
||||
return {
|
||||
groupedTemplates,
|
||||
navGroupedTemplates,
|
||||
enhancedTemplates,
|
||||
templateFuse,
|
||||
filterTemplatesByCategory,
|
||||
isLoaded,
|
||||
loadWorkflowTemplates
|
||||
}
|
||||
|
||||
@@ -11,13 +11,25 @@ export interface TemplateInfo {
|
||||
description: string
|
||||
localizedTitle?: string
|
||||
localizedDescription?: string
|
||||
isEssential?: boolean
|
||||
sourceModule?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
date?: string
|
||||
useCase?: string
|
||||
license?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface WorkflowTemplates {
|
||||
moduleName: string
|
||||
templates: TemplateInfo[]
|
||||
title: string
|
||||
localizedTitle?: string
|
||||
category?: string
|
||||
type?: string
|
||||
icon?: string
|
||||
isEssential?: boolean
|
||||
}
|
||||
|
||||
export interface TemplateGroup {
|
||||
|
||||
@@ -38,10 +38,9 @@
|
||||
},
|
||||
dragStyle
|
||||
]"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
v-bind="pointerHandlers"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<template v-if="isCollapsed">
|
||||
@@ -137,6 +136,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -177,8 +177,12 @@ const {
|
||||
readonly = false
|
||||
} = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
|
||||
useNodeEventHandlers()
|
||||
const {
|
||||
handleNodeCollapse,
|
||||
handleNodeTitleUpdate,
|
||||
handleNodeSelect,
|
||||
handleNodeRightClick
|
||||
} = useNodeEventHandlers()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
|
||||
@@ -232,13 +236,25 @@ onErrorCaptured((error) => {
|
||||
|
||||
// Use layout system for node position and dragging
|
||||
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
|
||||
const {
|
||||
handlePointerDown,
|
||||
handlePointerUp,
|
||||
handlePointerMove,
|
||||
isDragging,
|
||||
dragStyle
|
||||
} = useNodePointerInteractions(() => nodeData, handleNodeSelect)
|
||||
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
||||
() => nodeData,
|
||||
handleNodeSelect
|
||||
)
|
||||
|
||||
// Handle right-click context menu
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// First handle the standard right-click behavior (selection)
|
||||
handleNodeRightClick(event as PointerEvent, nodeData)
|
||||
|
||||
// Show the node options menu at the cursor position
|
||||
const targetElement = event.currentTarget as HTMLElement
|
||||
if (targetElement) {
|
||||
toggleNodeOptions(event, targetElement, false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (size.value && transformState?.camera) {
|
||||
|
||||
@@ -137,7 +137,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
value: widget.value,
|
||||
label: widget.label,
|
||||
options: widget.options,
|
||||
callback: widget.callback
|
||||
callback: widget.callback,
|
||||
spec: widget.spec
|
||||
}
|
||||
|
||||
const updateHandler = (value: unknown) => {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: () => ({
|
||||
forwardEventToCanvas: vi.fn(),
|
||||
shouldHandleNodePointerEvents: ref(true)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
startDrag: vi.fn(),
|
||||
endDrag: vi.fn().mockResolvedValue(undefined),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
isDraggingVueNodes: ref(false)
|
||||
}
|
||||
}))
|
||||
|
||||
const createMockVueNodeData = (
|
||||
overrides: Partial<VueNodeData> = {}
|
||||
): VueNodeData => ({
|
||||
id: 'test-node-123',
|
||||
title: 'Test Node',
|
||||
type: 'TestNodeType',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
...overrides
|
||||
})
|
||||
|
||||
const createPointerEvent = (
|
||||
eventType: string,
|
||||
overrides: Partial<PointerEventInit> = {}
|
||||
): PointerEvent => {
|
||||
return new PointerEvent(eventType, {
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
const createMouseEvent = (
|
||||
eventType: string,
|
||||
overrides: Partial<MouseEventInit> = {}
|
||||
): MouseEvent => {
|
||||
return new MouseEvent(eventType, {
|
||||
button: 2, // Right click
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('useNodePointerInteractions', () => {
|
||||
let mockNodeData: VueNodeData
|
||||
let mockOnPointerUp: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockNodeData = createMockVueNodeData()
|
||||
mockOnPointerUp = vi.fn()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should only start drag on left-click', async () => {
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Right-click should not start drag
|
||||
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
|
||||
pointerHandlers.onPointerdown(rightClickEvent)
|
||||
await nextTick()
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
|
||||
// Left-click should start drag and emit callback
|
||||
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
|
||||
pointerHandlers.onPointerdown(leftClickEvent)
|
||||
await nextTick()
|
||||
|
||||
const pointerUpEvent = createPointerEvent('pointerup')
|
||||
pointerHandlers.onPointerup(pointerUpEvent)
|
||||
await nextTick()
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
pointerUpEvent,
|
||||
mockNodeData,
|
||||
false // wasDragging = false (same position)
|
||||
)
|
||||
})
|
||||
|
||||
it('should distinguish drag from click based on distance threshold', async () => {
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Test drag (distance > 4px)
|
||||
pointerHandlers.onPointerdown(
|
||||
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
const dragUpEvent = createPointerEvent('pointerup', {
|
||||
clientX: 200,
|
||||
clientY: 200
|
||||
})
|
||||
pointerHandlers.onPointerup(dragUpEvent)
|
||||
await nextTick()
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
dragUpEvent,
|
||||
mockNodeData,
|
||||
true
|
||||
)
|
||||
|
||||
mockOnPointerUp.mockClear()
|
||||
|
||||
// Test click (same position)
|
||||
const samePos = { clientX: 100, clientY: 100 }
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown', samePos))
|
||||
await nextTick()
|
||||
|
||||
const clickUpEvent = createPointerEvent('pointerup', samePos)
|
||||
pointerHandlers.onPointerup(clickUpEvent)
|
||||
await nextTick()
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
clickUpEvent,
|
||||
mockNodeData,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Test pointer cancel
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
await nextTick()
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
await nextTick()
|
||||
|
||||
// Should not emit callback on cancel
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
|
||||
// Test context menu during drag prevents default
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
await nextTick()
|
||||
|
||||
const contextMenuEvent = createMouseEvent('contextmenu')
|
||||
const preventDefaultSpy = vi.spyOn(contextMenuEvent, 'preventDefault')
|
||||
|
||||
pointerHandlers.onContextmenu(contextMenuEvent)
|
||||
await nextTick()
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not emit callback when nodeData becomes null', async () => {
|
||||
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
nodeDataRef,
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
await nextTick()
|
||||
|
||||
// Clear nodeData before pointerup
|
||||
nodeDataRef.value = null
|
||||
await nextTick()
|
||||
|
||||
pointerHandlers.onPointerup(createPointerEvent('pointerup'))
|
||||
await nextTick()
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should integrate with layout store dragging state', async () => {
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Start drag
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
|
||||
|
||||
// End drag
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type MaybeRefOrGetter, computed, ref, toValue } from 'vue'
|
||||
import { type MaybeRefOrGetter, computed, onUnmounted, ref, toValue } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
@@ -9,16 +9,27 @@ import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayo
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
|
||||
export function useNodePointerInteractions(
|
||||
nodeDataMaybe: MaybeRefOrGetter<VueNodeData>,
|
||||
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>,
|
||||
onPointerUp: (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasDragging: boolean
|
||||
) => void
|
||||
) {
|
||||
const nodeData = toValue(nodeDataMaybe)
|
||||
const nodeData = computed(() => {
|
||||
const value = toValue(nodeDataMaybe)
|
||||
if (!value) {
|
||||
console.warn(
|
||||
'useNodePointerInteractions: nodeDataMaybe resolved to null/undefined'
|
||||
)
|
||||
return null
|
||||
}
|
||||
return value
|
||||
})
|
||||
|
||||
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeData.id)
|
||||
// Avoid potential null access during component initialization
|
||||
const nodeIdComputed = computed(() => nodeData.value?.id ?? '')
|
||||
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeIdComputed)
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
|
||||
useCanvasInteractions()
|
||||
@@ -28,17 +39,21 @@ export function useNodePointerInteractions(
|
||||
const dragStyle = computed(() => ({
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab'
|
||||
}))
|
||||
const lastX = ref(0)
|
||||
const lastY = ref(0)
|
||||
const startPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!nodeData) {
|
||||
if (!nodeData.value) {
|
||||
console.warn(
|
||||
'LGraphNode: nodeData is null/undefined in handlePointerDown'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Only start drag on left-click (button 0)
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
forwardEventToCanvas(event)
|
||||
@@ -52,8 +67,7 @@ export function useNodePointerInteractions(
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
|
||||
startDrag(event)
|
||||
lastY.value = event.clientY
|
||||
lastX.value = event.clientX
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
@@ -62,13 +76,42 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized cleanup function for drag state
|
||||
* Ensures consistent cleanup across all drag termination scenarios
|
||||
*/
|
||||
const cleanupDragState = () => {
|
||||
isDragging.value = false
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely ends drag operation with proper error handling
|
||||
* @param event - PointerEvent to end the drag with
|
||||
*/
|
||||
const safeDragEnd = async (event: PointerEvent): Promise<void> => {
|
||||
try {
|
||||
await endDrag(event)
|
||||
} catch (error) {
|
||||
console.error('Error during endDrag:', error)
|
||||
} finally {
|
||||
cleanupDragState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common drag termination handler with fallback cleanup
|
||||
*/
|
||||
const handleDragTermination = (event: PointerEvent, errorContext: string) => {
|
||||
safeDragEnd(event).catch((error) => {
|
||||
console.error(`Failed to complete ${errorContext}:`, error)
|
||||
cleanupDragState() // Fallback cleanup
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (isDragging.value) {
|
||||
isDragging.value = false
|
||||
void endDrag(event)
|
||||
|
||||
// Clear Vue node dragging state for selection toolbox
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
handleDragTermination(event, 'drag end')
|
||||
}
|
||||
|
||||
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
|
||||
@@ -78,16 +121,52 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
|
||||
// Emit node-click for selection handling in GraphCanvas
|
||||
const dx = event.clientX - lastX.value
|
||||
const dy = event.clientY - lastY.value
|
||||
const dx = event.clientX - startPosition.value.x
|
||||
const dy = event.clientY - startPosition.value.y
|
||||
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
|
||||
onPointerUp(event, nodeData, wasDragging)
|
||||
|
||||
if (!nodeData?.value) return
|
||||
onPointerUp(event, nodeData.value, wasDragging)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pointer cancellation events (e.g., touch cancelled by browser)
|
||||
* Ensures drag state is properly cleaned up when pointer interaction is interrupted
|
||||
*/
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (!isDragging.value) return
|
||||
handleDragTermination(event, 'drag cancellation')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles right-click during drag operations
|
||||
* Cancels the current drag to prevent context menu from appearing while dragging
|
||||
*/
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
event.preventDefault()
|
||||
// Simply cleanup state without calling endDrag to avoid synthetic event creation
|
||||
cleanupDragState()
|
||||
}
|
||||
|
||||
// Cleanup on unmount to prevent resource leaks
|
||||
onUnmounted(() => {
|
||||
if (!isDragging.value) return
|
||||
cleanupDragState()
|
||||
})
|
||||
|
||||
const pointerHandlers = {
|
||||
onPointerdown: handlePointerDown,
|
||||
onPointermove: handlePointerMove,
|
||||
onPointerup: handlePointerUp,
|
||||
onPointercancel: handlePointerCancel,
|
||||
onContextmenu: handleContextMenu
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
dragStyle,
|
||||
handlePointerMove,
|
||||
handlePointerDown,
|
||||
handlePointerUp
|
||||
pointerHandlers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type MaybeRefOrGetter,
|
||||
computed,
|
||||
inject,
|
||||
ref,
|
||||
toValue
|
||||
} from 'vue'
|
||||
|
||||
@@ -50,7 +51,7 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
|
||||
|
||||
// Drag state
|
||||
let isDragging = false
|
||||
const isDragging = ref(false)
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
@@ -59,9 +60,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
* Start dragging the node
|
||||
*/
|
||||
function startDrag(event: PointerEvent) {
|
||||
if (!layoutRef.value) return
|
||||
if (!layoutRef.value || !transformState) return
|
||||
|
||||
isDragging = true
|
||||
isDragging.value = true
|
||||
dragStartPos = { ...position.value }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
|
||||
@@ -87,15 +88,20 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
|
||||
// Capture pointer
|
||||
const target = event.target as HTMLElement
|
||||
target.setPointerCapture(event.pointerId)
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag movement
|
||||
*/
|
||||
const handleDrag = (event: PointerEvent) => {
|
||||
if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) {
|
||||
if (
|
||||
!isDragging.value ||
|
||||
!dragStartPos ||
|
||||
!dragStartMouse ||
|
||||
!transformState
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,16 +147,16 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
* End dragging
|
||||
*/
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (!isDragging) return
|
||||
if (!isDragging.value) return
|
||||
|
||||
isDragging = false
|
||||
isDragging.value = false
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
|
||||
// Release pointer
|
||||
const target = event.target as HTMLElement
|
||||
target.releasePointerCapture(event.pointerId)
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,6 +183,7 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
bounds,
|
||||
isVisible,
|
||||
zIndex,
|
||||
isDragging,
|
||||
|
||||
// Mutations
|
||||
moveTo,
|
||||
@@ -196,7 +203,7 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
zIndex: zIndex.value,
|
||||
cursor: isDragging ? 'grabbing' : 'grab'
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import Select from 'primevue/select'
|
||||
import type { SelectProps } from 'primevue/select'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetSelect from './WidgetSelect.vue'
|
||||
import WidgetSelectDefault from './WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
|
||||
|
||||
describe('WidgetSelect Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
@@ -14,7 +17,8 @@ describe('WidgetSelect Value Binding', () => {
|
||||
options: Partial<
|
||||
SelectProps & { values?: string[]; return_index?: boolean }
|
||||
> = {},
|
||||
callback?: (value: string | number | undefined) => void
|
||||
callback?: (value: string | number | undefined) => void,
|
||||
spec?: ComboInputSpec
|
||||
): SimplifiedWidget<string | number | undefined> => ({
|
||||
name: 'test_select',
|
||||
type: 'combo',
|
||||
@@ -23,7 +27,8 @@ describe('WidgetSelect Value Binding', () => {
|
||||
values: ['option1', 'option2', 'option3'],
|
||||
...options
|
||||
},
|
||||
callback
|
||||
callback,
|
||||
spec
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
@@ -184,4 +189,44 @@ describe('WidgetSelect Value Binding', () => {
|
||||
expect(emitted![0]).toContain('100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Spec-aware rendering', () => {
|
||||
it('uses dropdown variant when combo spec enables image uploads', () => {
|
||||
const spec: ComboInputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'test_select',
|
||||
image_upload: true
|
||||
}
|
||||
const widget = createMockWidget('option1', {}, undefined, spec)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses dropdown variant for audio uploads', () => {
|
||||
const spec: ComboInputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'test_select',
|
||||
audio_upload: true
|
||||
}
|
||||
const widget = createMockWidget('clip.wav', {}, undefined, spec)
|
||||
const wrapper = mountComponent(widget, 'clip.wav')
|
||||
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
|
||||
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
expect(dropdown.props('assetKind')).toBe('audio')
|
||||
expect(dropdown.props('allowUpload')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps default select when no spec or media hints are present', () => {
|
||||
const widget = createMockWidget('plain', {
|
||||
values: ['plain', 'text']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'plain')
|
||||
|
||||
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<Select
|
||||
v-model="localValue"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:disabled="readonly"
|
||||
class="w-full text-xs bg-[#F9F8F4] dark-theme:bg-[#0E0E12] border-[#E1DED5] dark-theme:border-[#15161C] !rounded-lg"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
<WidgetSelectDropdown
|
||||
v-if="isDropdownUIWidget"
|
||||
v-bind="props"
|
||||
:asset-kind="assetKind"
|
||||
:allow-upload="allowUpload"
|
||||
:upload-folder="uploadFolder"
|
||||
@update:model-value="handleUpdateModelValue"
|
||||
/>
|
||||
<WidgetSelectDefault
|
||||
v-else
|
||||
v-bind="props"
|
||||
@update:model-value="handleUpdateModelValue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
type ComboInputSpec,
|
||||
isComboInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
import WidgetSelectDefault from './WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
@@ -39,30 +38,64 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: props.widget.options?.values?.[0] || '',
|
||||
emit
|
||||
function handleUpdateModelValue(value: string | number | undefined) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
|
||||
return props.widget.spec
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
// Extract select options from widget options
|
||||
const selectOptions = computed(() => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (options?.values && Array.isArray(options.values)) {
|
||||
return options.values
|
||||
const specDescriptor = computed<{
|
||||
kind: AssetKind
|
||||
allowUpload: boolean
|
||||
folder: ResultItemType | undefined
|
||||
}>(() => {
|
||||
const spec = comboSpec.value
|
||||
if (!spec) {
|
||||
return {
|
||||
kind: 'unknown',
|
||||
allowUpload: false,
|
||||
folder: undefined
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
const {
|
||||
image_upload,
|
||||
animated_image_upload,
|
||||
video_upload,
|
||||
image_folder,
|
||||
audio_upload
|
||||
} = spec
|
||||
|
||||
let kind: AssetKind = 'unknown'
|
||||
if (video_upload) {
|
||||
kind = 'video'
|
||||
} else if (image_upload || animated_image_upload) {
|
||||
kind = 'image'
|
||||
} else if (audio_upload) {
|
||||
kind = 'audio'
|
||||
}
|
||||
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
|
||||
|
||||
const allowUpload =
|
||||
image_upload === true ||
|
||||
animated_image_upload === true ||
|
||||
audio_upload === true
|
||||
return {
|
||||
kind,
|
||||
allowUpload,
|
||||
folder: image_folder
|
||||
}
|
||||
})
|
||||
|
||||
const assetKind = computed(() => specDescriptor.value.kind)
|
||||
const isDropdownUIWidget = computed(() => assetKind.value !== 'unknown')
|
||||
const allowUpload = computed(() => specDescriptor.value.allowUpload)
|
||||
const uploadFolder = computed<ResultItemType>(() => {
|
||||
return specDescriptor.value.folder ?? 'input'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<Select
|
||||
v-model="localValue"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:disabled="readonly"
|
||||
class="w-full text-xs bg-[#F9F8F4] dark-theme:bg-[#0E0E12] border-[#E1DED5] dark-theme:border-[#15161C] !rounded-lg"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: props.widget.options?.values?.[0] || '',
|
||||
emit
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
// Extract select options from widget options
|
||||
const selectOptions = computed(() => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (options?.values && Array.isArray(options.values)) {
|
||||
return options.values
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import FormDropdown from './form/dropdown/FormDropdown.vue'
|
||||
import type {
|
||||
DropdownItem,
|
||||
FilterOption,
|
||||
SelectedKey
|
||||
} from './form/dropdown/types'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
readonly?: boolean
|
||||
assetKind?: AssetKind
|
||||
allowUpload?: boolean
|
||||
uploadFolder?: ResultItemType
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: props.widget.options?.values?.[0] || '',
|
||||
emit
|
||||
})
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
const selectedSet = ref<Set<SelectedKey>>(new Set())
|
||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
const values = props.widget.options?.values || []
|
||||
|
||||
if (!Array.isArray(values)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return values.map((value: string, index: number) => ({
|
||||
id: index,
|
||||
imageSrc: getMediaUrl(value),
|
||||
name: value,
|
||||
metadata: ''
|
||||
}))
|
||||
})
|
||||
|
||||
const mediaPlaceholder = computed(() => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (options?.placeholder) {
|
||||
return options.placeholder
|
||||
}
|
||||
|
||||
switch (props.assetKind) {
|
||||
case 'image':
|
||||
return t('widgets.uploadSelect.placeholderImage')
|
||||
case 'video':
|
||||
return t('widgets.uploadSelect.placeholderVideo')
|
||||
case 'audio':
|
||||
return t('widgets.uploadSelect.placeholderAudio')
|
||||
case 'model':
|
||||
return t('widgets.uploadSelect.placeholderModel')
|
||||
case 'unknown':
|
||||
return t('widgets.uploadSelect.placeholderUnknown')
|
||||
}
|
||||
|
||||
return t('widgets.uploadSelect.placeholder')
|
||||
})
|
||||
|
||||
const uploadable = computed(() => props.allowUpload === true)
|
||||
|
||||
watch(
|
||||
localValue,
|
||||
(currentValue) => {
|
||||
if (currentValue !== undefined) {
|
||||
const item = dropdownItems.value.find(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
if (item) {
|
||||
selectedSet.value.clear()
|
||||
selectedSet.value.add(item.id)
|
||||
}
|
||||
} else {
|
||||
selectedSet.value.clear()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<SelectedKey>) {
|
||||
let id: SelectedKey | undefined = undefined
|
||||
if (selectedItems.size > 0) {
|
||||
id = selectedItems.values().next().value!
|
||||
}
|
||||
if (id == null) {
|
||||
onChange(undefined)
|
||||
return
|
||||
}
|
||||
const name = dropdownItems.value.find((item) => item.id === id)?.name
|
||||
if (!name) {
|
||||
onChange(undefined)
|
||||
return
|
||||
}
|
||||
onChange(name)
|
||||
}
|
||||
|
||||
// Upload file function (copied from useNodeImageUpload.ts)
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
isPasted: boolean = false,
|
||||
formFields: Partial<{ type: ResultItemType }> = {}
|
||||
) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
// Handle multiple file uploads
|
||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||
const folder = props.uploadFolder ?? 'input'
|
||||
const uploadPromises = files.map((file) =>
|
||||
uploadFile(file, false, { type: folder })
|
||||
)
|
||||
const results = await Promise.all(uploadPromises)
|
||||
return results.filter((path): path is string => path !== null)
|
||||
}
|
||||
|
||||
async function handleFilesUpdate(files: File[]) {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
try {
|
||||
// 1. Upload files to server
|
||||
const uploadedPaths = await uploadFiles(files)
|
||||
|
||||
if (uploadedPaths.length === 0) {
|
||||
toastStore.addAlert('File upload failed')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Update widget options to include new files
|
||||
// This simulates what addToComboValues does but for SimplifiedWidget
|
||||
if (props.widget.options?.values) {
|
||||
uploadedPaths.forEach((path) => {
|
||||
const values = props.widget.options!.values as string[]
|
||||
if (!values.includes(path)) {
|
||||
values.push(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Update widget value to the first uploaded file
|
||||
onChange(uploadedPaths[0])
|
||||
|
||||
// 4. Trigger callback to notify underlying LiteGraph widget
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toastStore.addAlert(`Upload failed: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getMediaUrl(filename: string): string {
|
||||
if (props.assetKind !== 'image') return ''
|
||||
// TODO: This needs to be adapted based on actual ComfyUI API structure
|
||||
return `/api/view?filename=${encodeURIComponent(filename)}&type=input`
|
||||
}
|
||||
|
||||
// TODO handle filter logic
|
||||
const filterSelected = ref('all')
|
||||
const filterOptions = ref<FilterOption[]>([
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'image', name: 'Inputs' },
|
||||
{ id: 'video', name: 'Outputs' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<FormDropdown
|
||||
v-model:selected="selectedSet"
|
||||
v-model:filter-selected="filterSelected"
|
||||
:items="dropdownItems"
|
||||
:placeholder="mediaPlaceholder"
|
||||
:multiple="false"
|
||||
:uploadable="uploadable"
|
||||
:disabled="readonly"
|
||||
:filter-options="filterOptions"
|
||||
v-bind="combinedProps"
|
||||
class="w-full"
|
||||
@update:selected="updateSelectedItems"
|
||||
@update:files="handleFilesUpdate"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import FormDropdownInput from './FormDropdownInput.vue'
|
||||
import FormDropdownMenu from './FormDropdownMenu.vue'
|
||||
import { defaultSearcher, getDefaultSortOptions } from './shared'
|
||||
import type {
|
||||
DropdownItem,
|
||||
FilterOption,
|
||||
LayoutMode,
|
||||
OptionId,
|
||||
SelectedKey,
|
||||
SortOption
|
||||
} from './types'
|
||||
|
||||
interface Props {
|
||||
items: DropdownItem[]
|
||||
placeholder?: string
|
||||
/**
|
||||
* If true, allows multiple selections. If a number is provided,
|
||||
* it specifies the maximum number of selections allowed.
|
||||
*/
|
||||
multiple?: boolean | number
|
||||
|
||||
uploadable?: boolean
|
||||
disabled?: boolean
|
||||
filterOptions?: FilterOption[]
|
||||
sortOptions?: SortOption[]
|
||||
isSelected?: (
|
||||
selected: Set<SelectedKey>,
|
||||
item: DropdownItem,
|
||||
index: number
|
||||
) => boolean
|
||||
searcher?: (
|
||||
query: string,
|
||||
items: DropdownItem[],
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<DropdownItem[]>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: t('widgets.uploadSelect.placeholder'),
|
||||
multiple: false,
|
||||
uploadable: false,
|
||||
disabled: false,
|
||||
filterOptions: () => [],
|
||||
sortOptions: () => getDefaultSortOptions(),
|
||||
isSelected: (selected, item, _index) => selected.has(item.id),
|
||||
searcher: defaultSearcher
|
||||
})
|
||||
|
||||
const selected = defineModel<Set<SelectedKey>>('selected', {
|
||||
default: new Set()
|
||||
})
|
||||
const filterSelected = defineModel<OptionId>('filterSelected', { default: '' })
|
||||
const sortSelected = defineModel<OptionId>('sortSelected', {
|
||||
default: 'default'
|
||||
})
|
||||
const layoutMode = defineModel<LayoutMode>('layoutMode', {
|
||||
default: 'grid'
|
||||
})
|
||||
const files = defineModel<File[]>('files', { default: [] })
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
|
||||
maxWait: 700
|
||||
})
|
||||
const isQuerying = ref(false)
|
||||
const toastStore = useToastStore()
|
||||
const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const triggerRef = useTemplateRef('triggerRef')
|
||||
const isOpen = ref(false)
|
||||
|
||||
const maxSelectable = computed(() => {
|
||||
if (props.multiple === true) return Infinity
|
||||
if (typeof props.multiple === 'number') return props.multiple
|
||||
return 1
|
||||
})
|
||||
|
||||
const filteredItems = ref<DropdownItem[]>([])
|
||||
|
||||
watch(searchQuery, (value) => {
|
||||
isQuerying.value = value !== debouncedSearchQuery.value
|
||||
})
|
||||
|
||||
watch(
|
||||
debouncedSearchQuery,
|
||||
(_, __, onCleanup) => {
|
||||
let isCleanup = false
|
||||
let cleanupFn: undefined | (() => void)
|
||||
onCleanup(() => {
|
||||
isCleanup = true
|
||||
cleanupFn?.()
|
||||
})
|
||||
|
||||
void props
|
||||
.searcher(
|
||||
debouncedSearchQuery.value,
|
||||
props.items,
|
||||
(cb) => (cleanupFn = cb)
|
||||
)
|
||||
.then((result) => {
|
||||
if (!isCleanup) filteredItems.value = result
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCleanup) isQuerying.value = false
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const defaultSorter = computed<SortOption['sorter']>(() => {
|
||||
const sorter = props.sortOptions.find(
|
||||
(option) => option.id === 'default'
|
||||
)?.sorter
|
||||
return sorter || (({ items }) => items.slice())
|
||||
})
|
||||
const selectedSorter = computed<SortOption['sorter']>(() => {
|
||||
if (sortSelected.value === 'default') return defaultSorter.value
|
||||
const sorter = props.sortOptions.find(
|
||||
(option) => option.id === sortSelected.value
|
||||
)?.sorter
|
||||
return sorter || defaultSorter.value
|
||||
})
|
||||
const sortedItems = computed(() => {
|
||||
return selectedSorter.value({ items: filteredItems.value }) || []
|
||||
})
|
||||
|
||||
function internalIsSelected(item: DropdownItem, index: number): boolean {
|
||||
return props.isSelected?.(selected.value, item, index) ?? false
|
||||
}
|
||||
|
||||
const toggleDropdown = (event: Event) => {
|
||||
if (props.disabled) return
|
||||
if (popoverRef.value && triggerRef.value) {
|
||||
popoverRef.value.toggle(event, triggerRef.value)
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (popoverRef.value) {
|
||||
popoverRef.value.hide()
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
if (props.disabled) return
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
files.value = Array.from(input.files)
|
||||
}
|
||||
// Clear the input value to allow re-selecting the same file
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function handleSelection(item: DropdownItem, index: number) {
|
||||
if (props.disabled) return
|
||||
const sel = selected.value
|
||||
if (internalIsSelected(item, index)) {
|
||||
sel.delete(item.id)
|
||||
} else {
|
||||
if (sel.size < maxSelectable.value) {
|
||||
sel.add(item.id)
|
||||
} else if (maxSelectable.value === 1) {
|
||||
sel.clear()
|
||||
sel.add(item.id)
|
||||
} else {
|
||||
toastStore.addAlert(`Maximum selection limit reached`)
|
||||
return
|
||||
}
|
||||
}
|
||||
selected.value = new Set(sel)
|
||||
|
||||
if (maxSelectable.value === 1) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="triggerRef">
|
||||
<FormDropdownInput
|
||||
:files="files"
|
||||
:is-open="isOpen"
|
||||
:placeholder="placeholder"
|
||||
:items="items"
|
||||
:max-selectable="maxSelectable"
|
||||
:selected="selected"
|
||||
:uploadable="uploadable"
|
||||
:disabled="disabled"
|
||||
@select-click="toggleDropdown"
|
||||
@file-change="handleFileChange"
|
||||
/>
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-50'
|
||||
},
|
||||
content: {
|
||||
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
|
||||
}
|
||||
}"
|
||||
@hide="isOpen = false"
|
||||
>
|
||||
<FormDropdownMenu
|
||||
v-model:filter-selected="filterSelected"
|
||||
v-model:layout-mode="layoutMode"
|
||||
v-model:sort-selected="sortSelected"
|
||||
v-model:search-query="searchQuery"
|
||||
:filter-options="filterOptions"
|
||||
:sort-options="sortOptions"
|
||||
:disabled="disabled"
|
||||
:is-querying="isQuerying"
|
||||
:items="sortedItems"
|
||||
:is-selected="internalIsSelected"
|
||||
:max-selectable="maxSelectable"
|
||||
@close="closeDropdown"
|
||||
@item-click="handleSelection"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { WidgetInputBaseClass } from '../../layout'
|
||||
import type { DropdownItem, SelectedKey } from './types'
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean
|
||||
placeholder?: string
|
||||
files: File[]
|
||||
items: DropdownItem[]
|
||||
selected: Set<SelectedKey>
|
||||
maxSelectable: number
|
||||
uploadable: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isOpen: false,
|
||||
placeholder: 'Select...'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-click', event: MouseEvent): void
|
||||
(e: 'file-change', event: Event): void
|
||||
}>()
|
||||
|
||||
const selectedItems = computed(() => {
|
||||
return props.items.filter((item) => props.selected.has(item.id))
|
||||
})
|
||||
|
||||
const chevronClass = computed(() =>
|
||||
cn('mr-2 size-4 transition-transform duration-200 flex-shrink-0', {
|
||||
'rotate-180': props.isOpen
|
||||
})
|
||||
)
|
||||
|
||||
const theButtonStyle = computed(() => [
|
||||
'bg-transparent border-0 outline-none text-zinc-400',
|
||||
{
|
||||
'hover:bg-zinc-500/30 hover:text-black hover:dark-theme:text-white cursor-pointer':
|
||||
!props.disabled,
|
||||
'cursor-not-allowed': props.disabled
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
|
||||
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled
|
||||
})
|
||||
"
|
||||
>
|
||||
<!-- Dropdown -->
|
||||
<button
|
||||
:class="
|
||||
cn(theButtonStyle, 'flex justify-between items-center flex-1 h-8', {
|
||||
'rounded-l-lg': uploadable,
|
||||
'rounded-lg': !uploadable
|
||||
})
|
||||
"
|
||||
@click="emit('select-click', $event)"
|
||||
>
|
||||
<span class="px-4 py-2 min-w-0 text-left">
|
||||
<span v-if="!selectedItems.length" class="min-w-0">
|
||||
{{ props.placeholder }}
|
||||
</span>
|
||||
<span v-else class="line-clamp-1 min-w-0 break-all">
|
||||
{{ selectedItems.map((item) => (item as any)?.name).join(', ') }}
|
||||
</span>
|
||||
</span>
|
||||
<i-lucide:chevron-down :class="chevronClass" />
|
||||
</button>
|
||||
<!-- Open File -->
|
||||
<label
|
||||
v-if="uploadable"
|
||||
:class="
|
||||
cn(
|
||||
theButtonStyle,
|
||||
'relative',
|
||||
'size-8 flex justify-center items-center border-l rounded-r-lg border-zinc-300/10'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i-lucide:folder-search class="size-4" />
|
||||
<input
|
||||
type="file"
|
||||
class="opacity-0 absolute inset-0 -z-1"
|
||||
:multiple="maxSelectable > 1"
|
||||
:disabled="disabled"
|
||||
@change="emit('file-change', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
|
||||
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
|
||||
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
|
||||
import type {
|
||||
DropdownItem,
|
||||
FilterOption,
|
||||
LayoutMode,
|
||||
OptionId,
|
||||
SortOption
|
||||
} from './types'
|
||||
|
||||
interface Props {
|
||||
items: DropdownItem[]
|
||||
isSelected: (item: DropdownItem, index: number) => boolean
|
||||
isQuerying: boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'item-click', item: DropdownItem, index: number): void
|
||||
}>()
|
||||
|
||||
// Define models for two-way binding
|
||||
const filterSelected = defineModel<OptionId>('filterSelected')
|
||||
const layoutMode = defineModel<LayoutMode>('layoutMode')
|
||||
const sortSelected = defineModel<OptionId>('sortSelected')
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
|
||||
// Handle item selection
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-103 h-[640px] pt-4 bg-white dark-theme:bg-charcoal-800 rounded-lg outline outline-offset-[-1px] outline-sand-100 dark-theme:outline-zinc-800 flex flex-col"
|
||||
>
|
||||
<!-- Filter -->
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
v-model:filter-selected="filterSelected"
|
||||
:filter-options="filterOptions"
|
||||
/>
|
||||
<!-- Actions -->
|
||||
<FormDropdownMenuActions
|
||||
v-model:layout-mode="layoutMode"
|
||||
v-model:sort-selected="sortSelected"
|
||||
v-model:search-query="searchQuery"
|
||||
:sort-options="sortOptions"
|
||||
:is-querying="isQuerying"
|
||||
/>
|
||||
<!-- List -->
|
||||
<div class="flex overflow-hidden relative h-full">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
|
||||
{
|
||||
'grid-cols-4': layoutMode === 'grid',
|
||||
'grid-cols-1 gap-y-2': layoutMode === 'list',
|
||||
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
|
||||
}
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 inset-x-3 h-5 bg-gradient-to-b from-white dark-theme:from-neutral-900 to-transparent pointer-events-none z-10"
|
||||
/>
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
class="flex justify-center items-center absolute inset-0"
|
||||
>
|
||||
<i-lucide:circle-off
|
||||
title="No items"
|
||||
class="size-30 text-zinc-500/20"
|
||||
/>
|
||||
</div>
|
||||
<!-- Item -->
|
||||
<FormDropdownMenuItem
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:index="index"
|
||||
:selected="isSelected(item, index)"
|
||||
:image-src="item.imageSrc"
|
||||
:name="item.name"
|
||||
:metadata="item.metadata"
|
||||
:layout="layoutMode"
|
||||
@click="emit('item-click', item, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { LayoutMode, OptionId, SortOption } from './types'
|
||||
|
||||
defineProps<{
|
||||
isQuerying: boolean
|
||||
sortOptions: SortOption[]
|
||||
}>()
|
||||
|
||||
const layoutMode = defineModel<LayoutMode>('layoutMode')
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const sortSelected = defineModel<OptionId>('sortSelected')
|
||||
|
||||
const actionButtonStyle =
|
||||
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-sand-100 dark-theme:outline-neutral-700 transition-all duration-150'
|
||||
|
||||
const resetInputStyle = 'bg-transparent border-0 outline-0 ring-0 text-left'
|
||||
|
||||
const layoutSwitchItemStyle =
|
||||
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-black hover:dark-theme:text-white active:scale-95'
|
||||
|
||||
const sortPopoverRef = useTemplateRef('sortPopoverRef')
|
||||
const sortTriggerRef = useTemplateRef('sortTriggerRef')
|
||||
const isSortPopoverOpen = ref(false)
|
||||
|
||||
function toggleSortPopover(event: Event) {
|
||||
if (!sortPopoverRef.value || !sortTriggerRef.value) return
|
||||
isSortPopoverOpen.value = !isSortPopoverOpen.value
|
||||
sortPopoverRef.value.toggle(event, sortTriggerRef.value)
|
||||
}
|
||||
function closeSortPopover() {
|
||||
isSortPopoverOpen.value = false
|
||||
sortPopoverRef.value?.hide()
|
||||
}
|
||||
|
||||
function handleSortSelected(item: SortOption) {
|
||||
sortSelected.value = item.id
|
||||
closeSortPopover()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-2 text-zinc-400 px-4">
|
||||
<label
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
|
||||
searchQuery?.trim() !== '' ? 'text-black dark-theme:text-white' : '',
|
||||
'hover:!outline-blue-500/80',
|
||||
'focus-within:!outline-blue-500/80'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i-lucide:loader-circle
|
||||
v-if="isQuerying"
|
||||
class="mr-2 size-4 animate-spin"
|
||||
/>
|
||||
<i-lucide:search v-else class="mr-2 size-4" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:class="resetInputStyle"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Sort Select -->
|
||||
<button
|
||||
ref="sortTriggerRef"
|
||||
:class="
|
||||
cn(
|
||||
resetInputStyle,
|
||||
actionButtonStyle,
|
||||
'relative w-8 flex justify-center items-center cursor-pointer',
|
||||
'hover:!outline-blue-500/80',
|
||||
'active:!scale-95'
|
||||
)
|
||||
"
|
||||
@click="toggleSortPopover"
|
||||
>
|
||||
<div
|
||||
v-if="sortSelected !== 'default'"
|
||||
class="size-2 absolute top-[-2px] left-[-2px] bg-blue-500 rounded-full"
|
||||
/>
|
||||
<i-lucide:arrow-up-down class="size-4" />
|
||||
</button>
|
||||
<!-- Sort Popover -->
|
||||
<Popover
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-50'
|
||||
},
|
||||
content: {
|
||||
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
|
||||
}
|
||||
}"
|
||||
@hide="isSortPopoverOpen = false"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-2 p-2 min-w-32',
|
||||
'bg-zinc-200 dark-theme:bg-charcoal-700',
|
||||
'rounded-lg outline outline-offset-[-1px] outline-sand-200 dark-theme:outline-zinc-700'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-for="item of sortOptions"
|
||||
:key="item.name"
|
||||
:class="
|
||||
cn(
|
||||
resetInputStyle,
|
||||
'flex justify-between items-center h-6 cursor-pointer',
|
||||
'hover:!text-blue-500'
|
||||
)
|
||||
"
|
||||
@click="handleSortSelected(item)"
|
||||
>
|
||||
<span>{{ item.name }}</span>
|
||||
<i-lucide:check v-if="sortSelected === item.id" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<!-- Layout Switch -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
'flex justify-center items-center p-1 gap-1 hover:!outline-blue-500/80'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
resetInputStyle,
|
||||
layoutSwitchItemStyle,
|
||||
layoutMode === 'list'
|
||||
? 'bg-neutral-500/50 text-black dark-theme:text-white'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
@click="layoutMode = 'list'"
|
||||
>
|
||||
<i-lucide:list class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
resetInputStyle,
|
||||
layoutSwitchItemStyle,
|
||||
layoutMode === 'grid'
|
||||
? 'bg-neutral-500/50 text-black dark-theme:text-white'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
@click="layoutMode = 'grid'"
|
||||
>
|
||||
<i-lucide:layout-grid class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { FilterOption, OptionId } from './types'
|
||||
|
||||
defineProps<{
|
||||
filterOptions: FilterOption[]
|
||||
}>()
|
||||
|
||||
const filterSelected = defineModel<OptionId>('filterSelected')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-1 text-zinc-400 px-4 mb-4">
|
||||
<div
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
:class="
|
||||
cn(
|
||||
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
|
||||
'transition-all duration-150',
|
||||
'hover:text-black hover:dark-theme:text-white hover:bg-zinc-500/10',
|
||||
'active:scale-95',
|
||||
filterSelected === option.id
|
||||
? '!bg-zinc-500/20 text-black dark-theme:text-white'
|
||||
: 'bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="filterSelected = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { LayoutMode } from './types'
|
||||
|
||||
interface Props {
|
||||
index: number
|
||||
selected: boolean
|
||||
imageSrc: string
|
||||
name: string
|
||||
metadata?: string
|
||||
layout?: LayoutMode
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [index: number]
|
||||
imageLoad: [event: Event]
|
||||
}>()
|
||||
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
|
||||
function handleClick() {
|
||||
emit('click', props.index)
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
emit('imageLoad', event)
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex gap-1 select-none group/item cursor-pointer',
|
||||
'transition-all duration-150',
|
||||
{
|
||||
'flex-col text-center': layout === 'grid',
|
||||
'flex-row text-left max-h-16 bg-zinc-500/20 rounded-lg hover:scale-102 active:scale-98':
|
||||
layout === 'list',
|
||||
'flex-row text-left hover:bg-zinc-500/20 rounded-lg':
|
||||
layout === 'list-small',
|
||||
// selection
|
||||
'ring-2 ring-blue-500': layout === 'list' && selected
|
||||
}
|
||||
)
|
||||
"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Image -->
|
||||
<div
|
||||
v-if="layout !== 'list-small'"
|
||||
:class="
|
||||
cn(
|
||||
'relative',
|
||||
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-zinc-300/10',
|
||||
'transition-all duration-150',
|
||||
{
|
||||
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
|
||||
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
|
||||
layout === 'grid',
|
||||
// selection
|
||||
'ring-2 ring-blue-500': layout === 'grid' && selected
|
||||
}
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Selected Icon -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="rounded-full bg-blue-500 border-1 border-white size-4 absolute top-1 left-1"
|
||||
>
|
||||
<i-lucide:check class="size-3 text-white -translate-y-[0.5px]" />
|
||||
</div>
|
||||
<img
|
||||
v-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
class="size-full object-cover"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="size-full bg-gradient-to-tr from-blue-400 via-teal-500 to-green-400"
|
||||
/>
|
||||
</div>
|
||||
<!-- Name -->
|
||||
<div
|
||||
:class="
|
||||
cn('flex gap-1', {
|
||||
'flex-col': layout === 'grid',
|
||||
'flex-col px-4 py-1 w-full justify-center': layout === 'list',
|
||||
'flex-row p-2 items-center justify-between w-full':
|
||||
layout === 'list-small'
|
||||
})
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'block text-[15px] line-clamp-2 wrap-break-word',
|
||||
'transition-colors duration-150',
|
||||
// selection
|
||||
!!selected && 'text-blue-500'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
<!-- Meta Data -->
|
||||
<span class="block text-xs text-slate-400">{{
|
||||
metadata || actualDimensions
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DropdownItem, SortOption } from './types'
|
||||
|
||||
export async function defaultSearcher(query: string, items: DropdownItem[]) {
|
||||
if (query.trim() === '') return items
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
return items.filter((item) => {
|
||||
const name = item.name.toLowerCase()
|
||||
return words.every((word) => name.includes(word))
|
||||
})
|
||||
}
|
||||
|
||||
export function getDefaultSortOptions(): SortOption[] {
|
||||
return [
|
||||
{
|
||||
name: 'Default',
|
||||
id: 'default',
|
||||
sorter: ({ items }) => items.slice()
|
||||
},
|
||||
{
|
||||
name: 'A-Z',
|
||||
id: 'a-z',
|
||||
sorter: ({ items }) =>
|
||||
items.slice().sort((a, b) => {
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type OptionId = string | number | symbol
|
||||
export type SelectedKey = OptionId
|
||||
|
||||
export interface DropdownItem {
|
||||
id: SelectedKey
|
||||
imageSrc: string
|
||||
name: string
|
||||
metadata: string
|
||||
}
|
||||
export interface SortOption {
|
||||
id: OptionId
|
||||
name: string
|
||||
sorter: (ctx: { items: readonly DropdownItem[] }) => DropdownItem[]
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
id: OptionId
|
||||
name: string
|
||||
}
|
||||
|
||||
export type LayoutMode = 'list' | 'grid' | 'list-small'
|
||||
@@ -1,526 +1 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
zComfyWorkflow,
|
||||
zNodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
|
||||
import { zKeybinding } from '@/schemas/keyBindingSchema'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
|
||||
const zNodeType = z.string()
|
||||
const zQueueIndex = z.number()
|
||||
const zPromptId = z.string()
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
|
||||
const zResultItem = z.object({
|
||||
filename: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: resultItemType.optional()
|
||||
})
|
||||
export type ResultItem = z.infer<typeof zResultItem>
|
||||
const zOutputs = z
|
||||
.object({
|
||||
audio: z.array(zResultItem).optional(),
|
||||
images: z.array(zResultItem).optional(),
|
||||
video: z.array(zResultItem).optional(),
|
||||
animated: z.array(z.boolean()).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// WS messages
|
||||
const zStatusWsMessageStatus = z.object({
|
||||
exec_info: z.object({
|
||||
queue_remaining: z.number().int()
|
||||
})
|
||||
})
|
||||
|
||||
const zStatusWsMessage = z.object({
|
||||
status: zStatusWsMessageStatus.nullish(),
|
||||
sid: z.string().nullish()
|
||||
})
|
||||
|
||||
const zProgressWsMessage = z.object({
|
||||
value: z.number().int(),
|
||||
max: z.number().int(),
|
||||
prompt_id: zPromptId,
|
||||
node: zNodeId
|
||||
})
|
||||
|
||||
const zNodeProgressState = z.object({
|
||||
value: z.number(),
|
||||
max: z.number(),
|
||||
state: z.enum(['pending', 'running', 'finished', 'error']),
|
||||
node_id: zNodeId,
|
||||
prompt_id: zPromptId,
|
||||
display_node_id: zNodeId.optional(),
|
||||
parent_node_id: zNodeId.optional(),
|
||||
real_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zProgressStateWsMessage = z.object({
|
||||
prompt_id: zPromptId,
|
||||
nodes: z.record(zNodeId, zNodeProgressState)
|
||||
})
|
||||
|
||||
const zExecutingWsMessage = z.object({
|
||||
node: zNodeId,
|
||||
display_node: zNodeId,
|
||||
prompt_id: zPromptId
|
||||
})
|
||||
|
||||
const zExecutedWsMessage = zExecutingWsMessage.extend({
|
||||
output: zOutputs,
|
||||
merge: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zExecutionWsMessageBase = z.object({
|
||||
prompt_id: zPromptId,
|
||||
timestamp: z.number().int()
|
||||
})
|
||||
|
||||
const zExecutionStartWsMessage = zExecutionWsMessageBase
|
||||
const zExecutionSuccessWsMessage = zExecutionWsMessageBase
|
||||
const zExecutionCachedWsMessage = zExecutionWsMessageBase.extend({
|
||||
nodes: z.array(zNodeId)
|
||||
})
|
||||
const zExecutionInterruptedWsMessage = zExecutionWsMessageBase.extend({
|
||||
node_id: zNodeId,
|
||||
node_type: zNodeType,
|
||||
executed: z.array(zNodeId)
|
||||
})
|
||||
const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
|
||||
node_id: zNodeId,
|
||||
node_type: zNodeType,
|
||||
executed: z.array(zNodeId),
|
||||
exception_message: z.string(),
|
||||
exception_type: z.string(),
|
||||
traceback: z.array(z.string()),
|
||||
current_inputs: z.any(),
|
||||
current_outputs: z.any()
|
||||
})
|
||||
|
||||
const zProgressTextWsMessage = z.object({
|
||||
nodeId: zNodeId,
|
||||
text: z.string()
|
||||
})
|
||||
|
||||
const zDisplayComponentWsMessage = z.object({
|
||||
node_id: zNodeId,
|
||||
component: z.enum(['ChatHistoryWidget']),
|
||||
props: z.record(z.string(), z.any()).optional()
|
||||
})
|
||||
|
||||
const zTerminalSize = z.object({
|
||||
cols: z.number(),
|
||||
row: z.number()
|
||||
})
|
||||
const zLogEntry = z.object({
|
||||
t: z.string(),
|
||||
m: z.string()
|
||||
})
|
||||
const zLogsWsMessage = z.object({
|
||||
size: zTerminalSize.optional(),
|
||||
entries: z.array(zLogEntry)
|
||||
})
|
||||
const zLogRawResponse = z.object({
|
||||
size: zTerminalSize,
|
||||
entries: z.array(zLogEntry)
|
||||
})
|
||||
|
||||
const zFeatureFlagsWsMessage = z.record(z.string(), z.any())
|
||||
|
||||
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
|
||||
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
|
||||
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
|
||||
export type ExecutingWsMessage = z.infer<typeof zExecutingWsMessage>
|
||||
export type ExecutedWsMessage = z.infer<typeof zExecutedWsMessage>
|
||||
export type ExecutionStartWsMessage = z.infer<typeof zExecutionStartWsMessage>
|
||||
export type ExecutionSuccessWsMessage = z.infer<
|
||||
typeof zExecutionSuccessWsMessage
|
||||
>
|
||||
export type ExecutionCachedWsMessage = z.infer<typeof zExecutionCachedWsMessage>
|
||||
export type ExecutionInterruptedWsMessage = z.infer<
|
||||
typeof zExecutionInterruptedWsMessage
|
||||
>
|
||||
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
|
||||
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
|
||||
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||
export type DisplayComponentWsMessage = z.infer<
|
||||
typeof zDisplayComponentWsMessage
|
||||
>
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
|
||||
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
|
||||
// End of ws messages
|
||||
|
||||
const zPromptInputItem = z.object({
|
||||
inputs: z.record(z.string(), z.any()),
|
||||
class_type: zNodeType
|
||||
})
|
||||
|
||||
const zPromptInputs = z.record(zPromptInputItem)
|
||||
|
||||
const zExtraPngInfo = z
|
||||
.object({
|
||||
workflow: zComfyWorkflow
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zExtraData = z.object({
|
||||
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
||||
extra_pnginfo: zExtraPngInfo.optional(),
|
||||
client_id: z.string()
|
||||
})
|
||||
const zOutputsToExecute = z.array(zNodeId)
|
||||
|
||||
const zExecutionStartMessage = z.tuple([
|
||||
z.literal('execution_start'),
|
||||
zExecutionStartWsMessage
|
||||
])
|
||||
|
||||
const zExecutionSuccessMessage = z.tuple([
|
||||
z.literal('execution_success'),
|
||||
zExecutionSuccessWsMessage
|
||||
])
|
||||
|
||||
const zExecutionCachedMessage = z.tuple([
|
||||
z.literal('execution_cached'),
|
||||
zExecutionCachedWsMessage
|
||||
])
|
||||
|
||||
const zExecutionInterruptedMessage = z.tuple([
|
||||
z.literal('execution_interrupted'),
|
||||
zExecutionInterruptedWsMessage
|
||||
])
|
||||
|
||||
const zExecutionErrorMessage = z.tuple([
|
||||
z.literal('execution_error'),
|
||||
zExecutionErrorWsMessage
|
||||
])
|
||||
|
||||
const zStatusMessage = z.union([
|
||||
zExecutionStartMessage,
|
||||
zExecutionSuccessMessage,
|
||||
zExecutionCachedMessage,
|
||||
zExecutionInterruptedMessage,
|
||||
zExecutionErrorMessage
|
||||
])
|
||||
|
||||
const zStatus = z.object({
|
||||
status_str: z.enum(['success', 'error']),
|
||||
completed: z.boolean(),
|
||||
messages: z.array(zStatusMessage)
|
||||
})
|
||||
|
||||
const zTaskPrompt = z.tuple([
|
||||
zQueueIndex,
|
||||
zPromptId,
|
||||
zPromptInputs,
|
||||
zExtraData,
|
||||
zOutputsToExecute
|
||||
])
|
||||
|
||||
const zRunningTaskItem = z.object({
|
||||
taskType: z.literal('Running'),
|
||||
prompt: zTaskPrompt,
|
||||
// @Deprecated
|
||||
remove: z.object({
|
||||
name: z.literal('Cancel'),
|
||||
cb: z.function()
|
||||
})
|
||||
})
|
||||
|
||||
const zPendingTaskItem = z.object({
|
||||
taskType: z.literal('Pending'),
|
||||
prompt: zTaskPrompt
|
||||
})
|
||||
|
||||
const zTaskOutput = z.record(zNodeId, zOutputs)
|
||||
|
||||
const zNodeOutputsMeta = z.object({
|
||||
node_id: zNodeId,
|
||||
display_node: zNodeId,
|
||||
prompt_id: zPromptId.optional(),
|
||||
read_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zTaskMeta = z.record(zNodeId, zNodeOutputsMeta)
|
||||
|
||||
const zHistoryTaskItem = z.object({
|
||||
taskType: z.literal('History'),
|
||||
prompt: zTaskPrompt,
|
||||
status: zStatus.optional(),
|
||||
outputs: zTaskOutput,
|
||||
meta: zTaskMeta.optional()
|
||||
})
|
||||
|
||||
const zTaskItem = z.union([
|
||||
zRunningTaskItem,
|
||||
zPendingTaskItem,
|
||||
zHistoryTaskItem
|
||||
])
|
||||
|
||||
const zTaskType = z.union([
|
||||
z.literal('Running'),
|
||||
z.literal('Pending'),
|
||||
z.literal('History')
|
||||
])
|
||||
|
||||
export type TaskType = z.infer<typeof zTaskType>
|
||||
export type TaskPrompt = z.infer<typeof zTaskPrompt>
|
||||
export type TaskStatus = z.infer<typeof zStatus>
|
||||
export type TaskOutput = z.infer<typeof zTaskOutput>
|
||||
|
||||
// `/queue`
|
||||
export type RunningTaskItem = z.infer<typeof zRunningTaskItem>
|
||||
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>
|
||||
// `/history`
|
||||
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>
|
||||
export type TaskItem = z.infer<typeof zTaskItem>
|
||||
|
||||
const zEmbeddingsResponse = z.array(z.string())
|
||||
const zExtensionsResponse = z.array(z.string())
|
||||
const zError = z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
details: z.string(),
|
||||
extra_info: z
|
||||
.object({
|
||||
input_name: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional()
|
||||
})
|
||||
const zNodeError = z.object({
|
||||
errors: z.array(zError),
|
||||
class_type: z.string(),
|
||||
dependent_outputs: z.array(z.any())
|
||||
})
|
||||
const zPromptResponse = z.object({
|
||||
node_errors: z.record(zNodeId, zNodeError).optional(),
|
||||
prompt_id: z.string().optional(),
|
||||
exec_info: z
|
||||
.object({
|
||||
queue_remaining: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
error: z.union([z.string(), zError])
|
||||
})
|
||||
|
||||
const zDeviceStats = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
index: z.number(),
|
||||
vram_total: z.number(),
|
||||
vram_free: z.number(),
|
||||
torch_vram_total: z.number(),
|
||||
torch_vram_free: z.number()
|
||||
})
|
||||
|
||||
const zSystemStats = z.object({
|
||||
system: z.object({
|
||||
os: z.string(),
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
pytorch_version: z.string(),
|
||||
required_frontend_version: z.string().optional(),
|
||||
argv: z.array(z.string()),
|
||||
ram_total: z.number(),
|
||||
ram_free: z.number()
|
||||
}),
|
||||
devices: z.array(zDeviceStats)
|
||||
})
|
||||
const zUser = z.object({
|
||||
storage: z.enum(['server']),
|
||||
// `migrated` is only available in single-user mode.
|
||||
migrated: z.boolean().optional(),
|
||||
// `users` is only available in multi-user server mode.
|
||||
users: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
const zUserData = z.array(z.array(z.string(), z.string()))
|
||||
const zUserDataFullInfo = z.object({
|
||||
path: z.string(),
|
||||
size: z.number(),
|
||||
modified: z.number()
|
||||
})
|
||||
const zBookmarkCustomization = z.object({
|
||||
icon: z.string().optional(),
|
||||
color: z.string().optional()
|
||||
})
|
||||
export type BookmarkCustomization = z.infer<typeof zBookmarkCustomization>
|
||||
|
||||
const zLinkReleaseTriggerAction = z.enum(
|
||||
Object.values(LinkReleaseTriggerAction) as [string, ...string[]]
|
||||
)
|
||||
|
||||
const zNodeBadgeMode = z.enum(
|
||||
Object.values(NodeBadgeMode) as [string, ...string[]]
|
||||
)
|
||||
|
||||
const zSettings = z.object({
|
||||
'Comfy.ColorPalette': z.string(),
|
||||
'Comfy.CustomColorPalettes': colorPalettesSchema,
|
||||
'Comfy.Canvas.BackgroundImage': z.string().optional(),
|
||||
'Comfy.ConfirmClear': z.boolean(),
|
||||
'Comfy.DevMode': z.boolean(),
|
||||
'Comfy.Workflow.ShowMissingNodesWarning': z.boolean(),
|
||||
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),
|
||||
'Comfy.DisableFloatRounding': z.boolean(),
|
||||
'Comfy.DisableSliders': z.boolean(),
|
||||
'Comfy.DOMClippingEnabled': z.boolean(),
|
||||
'Comfy.EditAttention.Delta': z.number(),
|
||||
'Comfy.EnableTooltips': z.boolean(),
|
||||
'Comfy.EnableWorkflowViewRestore': z.boolean(),
|
||||
'Comfy.FloatRoundingPrecision': z.number(),
|
||||
'Comfy.Graph.CanvasInfo': z.boolean(),
|
||||
'Comfy.Graph.CanvasMenu': z.boolean(),
|
||||
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
|
||||
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
|
||||
'Comfy.Graph.ZoomSpeed': z.number(),
|
||||
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
|
||||
'Comfy.GroupSelectedNodes.Padding': z.number(),
|
||||
'Comfy.Locale': z.string(),
|
||||
'Comfy.NodeLibrary.Bookmarks': z.array(z.string()),
|
||||
'Comfy.NodeLibrary.Bookmarks.V2': z.array(z.string()),
|
||||
'Comfy.NodeLibrary.BookmarksCustomization': z.record(
|
||||
z.string(),
|
||||
zBookmarkCustomization
|
||||
),
|
||||
'Comfy.LinkRelease.Action': zLinkReleaseTriggerAction,
|
||||
'Comfy.LinkRelease.ActionShift': zLinkReleaseTriggerAction,
|
||||
'Comfy.ModelLibrary.AutoLoadAll': z.boolean(),
|
||||
'Comfy.ModelLibrary.NameFormat': z.enum(['filename', 'title']),
|
||||
'Comfy.NodeSearchBoxImpl.NodePreview': z.boolean(),
|
||||
'Comfy.NodeSearchBoxImpl': z.enum(['default', 'simple']),
|
||||
'Comfy.NodeSearchBoxImpl.ShowCategory': z.boolean(),
|
||||
'Comfy.NodeSearchBoxImpl.ShowIdName': z.boolean(),
|
||||
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency': z.boolean(),
|
||||
'Comfy.NodeSuggestions.number': z.number(),
|
||||
'Comfy.Node.BypassAllLinksOnDelete': z.boolean(),
|
||||
'Comfy.Node.Opacity': z.number(),
|
||||
'Comfy.Node.MiddleClickRerouteNode': z.boolean(),
|
||||
'Comfy.Node.ShowDeprecated': z.boolean(),
|
||||
'Comfy.Node.ShowExperimental': z.boolean(),
|
||||
'Comfy.Pointer.ClickBufferTime': z.number(),
|
||||
'Comfy.Pointer.ClickDrift': z.number(),
|
||||
'Comfy.Pointer.DoubleClickTime': z.number(),
|
||||
'Comfy.PreviewFormat': z.string(),
|
||||
'Comfy.PromptFilename': z.boolean(),
|
||||
'Comfy.Sidebar.Location': z.enum(['left', 'right']),
|
||||
'Comfy.Sidebar.Size': z.enum(['small', 'normal']),
|
||||
'Comfy.Sidebar.UnifiedWidth': z.boolean(),
|
||||
'Comfy.SnapToGrid.GridSize': z.number(),
|
||||
'Comfy.TextareaWidget.FontSize': z.number(),
|
||||
'Comfy.TextareaWidget.Spellcheck': z.boolean(),
|
||||
'Comfy.UseNewMenu': z.enum(['Disabled', 'Top', 'Bottom']),
|
||||
'Comfy.TreeExplorer.ItemPadding': z.number(),
|
||||
'Comfy.Validation.Workflows': z.boolean(),
|
||||
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
|
||||
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
|
||||
'Comfy.Workflow.WorkflowTabsPosition': z.enum([
|
||||
'Sidebar',
|
||||
'Topbar',
|
||||
'Topbar (2nd-row)'
|
||||
]),
|
||||
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
|
||||
'Comfy.WidgetControlMode': z.enum(['before', 'after']),
|
||||
'Comfy.Window.UnloadConfirmation': z.boolean(),
|
||||
'Comfy.NodeBadge.NodeSourceBadgeMode': zNodeBadgeMode,
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode': zNodeBadgeMode,
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode': zNodeBadgeMode,
|
||||
'Comfy.NodeBadge.ShowApiPricing': z.boolean(),
|
||||
'Comfy.Notification.ShowVersionUpdates': z.boolean(),
|
||||
'Comfy.QueueButton.BatchCountLimit': z.number(),
|
||||
'Comfy.Queue.MaxHistoryItems': z.number(),
|
||||
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),
|
||||
'Comfy.Keybinding.NewBindings': z.array(zKeybinding),
|
||||
'Comfy.Extension.Disabled': z.array(z.string()),
|
||||
'Comfy.LinkRenderMode': z.number(),
|
||||
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),
|
||||
'Comfy.Node.SnapHighlightsNode': z.boolean(),
|
||||
'Comfy.Server.ServerConfigValues': z.record(z.string(), z.any()),
|
||||
'Comfy.Server.LaunchArgs': z.record(z.string(), z.string()),
|
||||
'LiteGraph.Canvas.MaximumFps': z.number(),
|
||||
'Comfy.Workflow.ConfirmDelete': z.boolean(),
|
||||
'Comfy.Workflow.AutoSaveDelay': z.number(),
|
||||
'Comfy.Workflow.AutoSave': z.enum(['off', 'after delay']),
|
||||
'Comfy.RerouteBeta': z.boolean(),
|
||||
'LiteGraph.Canvas.MinFontSizeForLOD': z.number(),
|
||||
'Comfy.Canvas.SelectionToolbox': z.boolean(),
|
||||
'LiteGraph.Node.TooltipDelay': z.number(),
|
||||
'LiteGraph.ContextMenu.Scaling': z.boolean(),
|
||||
'LiteGraph.Reroute.SplineOffset': z.number(),
|
||||
'LiteGraph.Canvas.LowQualityRenderingZoomThreshold': z.number(),
|
||||
'Comfy.Toast.DisableReconnectingToast': z.boolean(),
|
||||
'Comfy.Workflow.Persist': z.boolean(),
|
||||
'Comfy.TutorialCompleted': z.boolean(),
|
||||
'Comfy.InstalledVersion': z.string().nullable(),
|
||||
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
|
||||
'Comfy.Minimap.Visible': z.boolean(),
|
||||
'Comfy.Minimap.NodeColors': z.boolean(),
|
||||
'Comfy.Minimap.ShowLinks': z.boolean(),
|
||||
'Comfy.Minimap.ShowGroups': z.boolean(),
|
||||
'Comfy.Minimap.RenderBypassState': z.boolean(),
|
||||
'Comfy.Minimap.RenderErrorState': z.boolean(),
|
||||
'Comfy.Canvas.NavigationMode': z.string(),
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': z.string(),
|
||||
'Comfy.Canvas.MouseWheelScroll': z.string(),
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||
'Comfy-Desktop.SendStatistics': z.boolean(),
|
||||
'Comfy-Desktop.WindowStyle': z.string(),
|
||||
'Comfy-Desktop.UV.PythonInstallMirror': z.string(),
|
||||
'Comfy-Desktop.UV.PypiInstallMirror': z.string(),
|
||||
'Comfy-Desktop.UV.TorchInstallMirror': z.string(),
|
||||
'Comfy.MaskEditor.UseNewEditor': z.boolean(),
|
||||
'Comfy.MaskEditor.BrushAdjustmentSpeed': z.number(),
|
||||
'Comfy.MaskEditor.UseDominantAxis': z.boolean(),
|
||||
'Comfy.Load3D.ShowGrid': z.boolean(),
|
||||
'Comfy.Load3D.ShowPreview': z.boolean(),
|
||||
'Comfy.Load3D.BackgroundColor': z.string(),
|
||||
'Comfy.Load3D.LightIntensity': z.number(),
|
||||
'Comfy.Load3D.LightIntensityMaximum': z.number(),
|
||||
'Comfy.Load3D.LightIntensityMinimum': z.number(),
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': z.number(),
|
||||
'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']),
|
||||
'Comfy.Load3D.3DViewerEnable': z.boolean(),
|
||||
'Comfy.Memory.AllowManualUnload': z.boolean(),
|
||||
'pysssss.SnapToGrid': z.boolean(),
|
||||
/** VHS setting is used for queue video preview support. */
|
||||
'VHS.AdvancedPreviews': z.string(),
|
||||
/** Release data settings */
|
||||
'Comfy.Release.Version': z.string(),
|
||||
'Comfy.Release.Status': z.enum([
|
||||
'skipped',
|
||||
'changelog seen',
|
||||
"what's new seen"
|
||||
]),
|
||||
'Comfy.Release.Timestamp': z.number(),
|
||||
/** Settings used for testing */
|
||||
'test.setting': z.any(),
|
||||
'main.sub.setting.name': z.any(),
|
||||
'single.setting': z.any(),
|
||||
'LiteGraph.Node.DefaultPadding': z.boolean(),
|
||||
'LiteGraph.Pointer.TrackpadGestures': z.boolean()
|
||||
})
|
||||
|
||||
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
|
||||
export type ExtensionsResponse = z.infer<typeof zExtensionsResponse>
|
||||
export type PromptResponse = z.infer<typeof zPromptResponse>
|
||||
export type NodeError = z.infer<typeof zNodeError>
|
||||
export type Settings = z.infer<typeof zSettings>
|
||||
export type DeviceStats = z.infer<typeof zDeviceStats>
|
||||
export type SystemStats = z.infer<typeof zSystemStats>
|
||||
export type User = z.infer<typeof zUser>
|
||||
export type UserData = z.infer<typeof zUserData>
|
||||
export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo>
|
||||
export type TerminalSize = z.infer<typeof zTerminalSize>
|
||||
export type LogEntry = z.infer<typeof zLogEntry>
|
||||
export type LogsRawResponse = z.infer<typeof zLogRawResponse>
|
||||
export * from '@comfyorg/schemas/apiSchema'
|
||||
|
||||