Compare commits

..

1 Commits

Author SHA1 Message Date
Arjan Singh
81087c111a [fix] Stop widget scroll events from being intercepted by LiteGraph 2025-09-25 21:33:32 -07:00
255 changed files with 5752 additions and 9987 deletions

View File

@@ -78,7 +78,7 @@ jobs:
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --with-deps
run: npx 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: pnpm exec tsx scripts/diff-i18n capture
run: npx 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: pnpm exec tsx scripts/diff-i18n diff
run: npx tsx scripts/diff-i18n diff
working-directory: ComfyUI_frontend
- name: Update i18n in custom node repository
run: |

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --with-deps
run: npx 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

View File

@@ -33,7 +33,7 @@ jobs:
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --with-deps
run: npx 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

View File

@@ -19,11 +19,11 @@ jobs:
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --with-deps
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Run Playwright tests and update snapshots
id: playwright-tests
run: pnpm exec playwright test --update-snapshots
run: npx playwright test --update-snapshots
continue-on-error: true
working-directory: ComfyUI_frontend
- uses: actions/upload-artifact@v4

View File

@@ -148,7 +148,7 @@ jobs:
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
run: npx 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 \
pnpm exec playwright test --project=${{ matrix.browser }} \
npx 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
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
npx playwright merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
npx playwright merge-reports --reporter=json ./all-blob-reports
working-directory: ComfyUI_frontend
- name: Upload HTML report

View File

@@ -68,7 +68,7 @@ jobs:
- name: Generate Manager API types
run: |
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
pnpm dlx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
- name: Validate generated types
run: |

View File

@@ -68,7 +68,7 @@ jobs:
- name: Generate API types
run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
npx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
- name: Validate generated types
run: |

View File

@@ -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

View File

@@ -45,7 +45,7 @@ const config: StorybookConfig = {
compiler: 'vue3',
customCollections: {
comfy: FileSystemIconLoader(
process.cwd() + '/packages/design-system/src/icons'
process.cwd() + '/src/assets/icons/custom'
)
}
}),

View File

@@ -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 `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.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 `src/assets/icons/custom/` and processed by `build/customIconCollection.ts` with automatic validation.
For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md).
For detailed instructions and code examples, see [src/assets/icons/README.md](src/assets/icons/README.md).
## Working with litegraph.js

View File

@@ -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
pnpm exec playwright install chromium --with-deps
npx 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
pnpm exec playwright test --ui
npx playwright test --ui
```
UI mode features:
@@ -89,8 +89,8 @@ UI mode features:
For CI or headless testing:
```bash
pnpm exec playwright test # Run all tests
pnpm exec playwright test widget.spec.ts # Run specific test file
npx playwright test # Run all tests
npx 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
pnpm exec playwright test --update-snapshots
npx playwright test --update-snapshots
```
### Creating New Screenshot Baselines

View File

@@ -13,13 +13,6 @@ export class VueNodeHelpers {
return this.page.locator('[data-node-id]')
}
/**
* Get locator for a Vue node by its NodeId
*/
getNodeLocator(nodeId: string): Locator {
return this.page.locator(`[data-node-id="${nodeId}"]`)
}
/**
* Get locator for selected Vue node components (using visual selection indicators)
*/

View File

@@ -80,12 +80,6 @@ test.describe('Templates', () => {
// Load a template
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.page
.locator(
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
)
.click()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()
@@ -108,72 +102,48 @@ test.describe('Templates', () => {
expect(await comfyPage.templates.content.isVisible()).toBe(true)
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
// Set locale to French before opening templates
await comfyPage.setSetting('Comfy.Locale', 'fr')
// Load the templates dialog and wait for the French index file request
const requestPromise = comfyPage.page.waitForRequest(
'**/templates/index.fr.json'
)
await comfyPage.executeCommand('Comfy.BrowseTemplates')
const request = await requestPromise
// Verify French index was requested
expect(request.url()).toContain('templates/index.fr.json')
await expect(comfyPage.templates.content).toBeVisible()
})
test('Falls back to English templates when locale file not found', async ({
test('Uses title field as fallback when the key is not found in locales', async ({
comfyPage
}) => {
// Set locale to a language that doesn't have a template file
await comfyPage.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
// Capture request for the index.json
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
// Add a new template that won't have a translation pre-generated
const response = [
{
moduleName: 'default',
title: 'FALLBACK CATEGORY',
type: 'image',
templates: [
{
name: 'unknown_key_has_no_translation_available',
title: 'FALLBACK TEMPLATE NAME',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'No translations found'
}
]
}
]
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const englishRequest = await englishRequestPromise
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(englishRequest.url()).toContain('templates/index.json')
// Verify English titles are shown as fallback
// Expect the title to be used as fallback for template cards
await expect(
comfyPage.templates.content.getByRole('heading', {
name: 'Image Generation'
})
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
).toBeVisible()
// Expect the title to be used as fallback for the template categories
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({
@@ -181,33 +151,46 @@ test.describe('Templates', () => {
}) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await comfyPage.templates.content.waitFor({ state: 'visible' })
await expect(comfyPage.templates.content).toBeVisible()
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
const nav = comfyPage.page
.locator('header')
.filter({ hasText: 'Templates' })
// Wait for at least one template card to appear
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
timeout: 5000
})
const cardCount = await comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.count()
expect(cardCount).toBeGreaterThan(0)
// Take snapshot of the template grid
const templateGrid = comfyPage.templates.content.locator('.grid').first()
await expect(templateGrid).toBeVisible()
await expect(nav).toBeVisible() // Nav should be visible at desktop size
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
const mobileSize = { width: 640, height: 800 }
await comfyPage.page.setViewportSize(mobileSize)
expect(cardCount).toBeGreaterThan(0)
// Check cards at mobile viewport size
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
await expect(templateGrid).toBeVisible()
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
const tabletSize = { width: 1024, height: 800 }
await comfyPage.page.setViewportSize(tabletSize)
expect(cardCount).toBeGreaterThan(0)
// Check cards at tablet size
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
await expect(templateGrid).toBeVisible()
await expect(nav).toBeVisible() // Nav should be visible at tablet size
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
})
test('hover effects work on template cards', async ({ comfyPage }) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Get a template card
const firstCard = comfyPage.page.locator('.template-card').first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Take snapshot before hover
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
// Hover over the card
await firstCard.hover()
// Take snapshot after hover to verify hover effect
await expect(firstCard).toHaveScreenshot('template-card-after-hover.png')
})
test('template cards descriptions adjust height dynamically', async ({
@@ -274,42 +257,21 @@ test.describe('Templates', () => {
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for cards to load
// Verify cards are visible with varying content lengths
await expect(
comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
comfyPage.page.getByText('This is a short description.')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a medium length description')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a much longer description')
).toBeVisible({ timeout: 5000 })
// Verify all three cards with different descriptions are visible
const shortDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
const mediumDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-medium-description"]'
)
const longDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-long-description"]'
)
await expect(shortDescCard).toBeVisible()
await expect(mediumDescCard).toBeVisible()
await expect(longDescCard).toBeVisible()
// Verify descriptions are visible and have line-clamp class
// The description is in a p tag with text-muted class
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
await expect(shortDesc).toContainText('short description')
await expect(mediumDesc).toContainText('medium length description')
await expect(longDesc).toContainText('much longer description')
// Verify grid layout maintains consistency
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
// Take snapshot of a grid with specific cards
const templateGrid = comfyPage.templates.content
.locator('.grid:has-text("Short Description")')
.first()
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(
'template-grid-varying-content.png'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -85,7 +85,6 @@ test.describe('Version Mismatch Warnings', () => {
test('should persist dismissed state across sessions', async ({
comfyPage
}) => {
test.setTimeout(30_000)
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({
@@ -107,11 +106,6 @@ test.describe('Version Mismatch Warnings', () => {
const dismissButton = warningToast.getByRole('button', { name: 'Close' })
await dismissButton.click()
// Wait for the dismissed state to be persisted
await comfyPage.page.waitForFunction(
() => !!localStorage.getItem('comfy.versionMismatch.dismissals')
)
// Reload the page, keeping local storage
await comfyPage.setup({ clearStorage: false })

View File

@@ -1,20 +1,67 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
} from '../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Node Collapse', () => {
test.describe('NodeHeader', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should allow collapsing node with collapse icon', async ({
comfyPage
}) => {
test('displays node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
})
test('allows title renaming', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
})
test('handles node collapsing', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
@@ -43,7 +90,7 @@ test.describe('Vue Node Collapse', () => {
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
})
test('should show collapse/expand icon state', async ({ comfyPage }) => {
test('shows collapse/expand icon state', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
@@ -63,9 +110,7 @@ test.describe('Vue Node Collapse', () => {
expect(iconClass).toContain('pi-chevron-down')
})
test('should preserve title when collapsing/expanding', async ({
comfyPage
}) => {
test('preserves title when collapsing/expanding', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')

View File

@@ -1,33 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should not capture drag while zooming with ctrl+shift+drag', async ({
comfyPage
}) => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBoundingBox = await checkpointNode.boundingBox()
if (!nodeBoundingBox) throw new Error('Node bounding box not available')
const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2
const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2
// Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over
// the node. The node should not capture the drag while drag-zooming.
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(
{ x: 200, y: 300 },
{ x: nodeMidpointX, y: nodeMidpointY }
)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
})
})

View File

@@ -1,791 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '../../../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
if (!box) throw new Error('Slot bounding box not available')
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
}
async function getInputLinkDetails(
page: Page,
nodeId: NodeId,
slotIndex: number
) {
return await page.evaluate(
([targetNodeId, targetSlot]) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return null
const node = graph.getNodeById(targetNodeId)
if (!node) return null
const input = node.inputs?.[targetSlot]
if (!input) return null
const linkId = input.link
if (linkId == null) return null
const link = graph.getLink?.(linkId)
if (!link) return null
return {
id: link.id,
originId: link.origin_id,
originSlot:
typeof link.origin_slot === 'string'
? Number.parseInt(link.origin_slot, 10)
: link.origin_slot,
targetId: link.target_id,
targetSlot:
typeof link.target_slot === 'string'
? Number.parseInt(link.target_slot, 10)
: link.target_slot,
parentId: link.parentId ?? null
}
},
[nodeId, slotIndex] as const
)
}
// Test helpers to reduce repetition across cases
function slotLocator(
page: Page,
nodeId: NodeId,
slotIndex: number,
isInput: boolean
) {
const key = getSlotKey(String(nodeId), slotIndex, isInput)
return page.locator(`[data-slot-key="${key}"]`)
}
async function expectVisibleAll(...locators: Locator[]) {
await Promise.all(locators.map((l) => expect(l).toBeVisible()))
}
async function getSlotCenter(
page: Page,
nodeId: NodeId,
slotIndex: number,
isInput: boolean
) {
const locator = slotLocator(page, nodeId, slotIndex, isInput)
await expect(locator).toBeVisible()
return await getCenter(locator)
}
async function connectSlots(
page: Page,
from: { nodeId: NodeId; index: number },
to: { nodeId: NodeId; index: number },
nextFrame: () => Promise<void>
) {
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
const toLoc = slotLocator(page, to.nodeId, to.index, true)
await expectVisibleAll(fromLoc, toLoc)
await fromLoc.dragTo(toLoc)
await nextFrame()
}
test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
test('should show a link dragging out from a slot when dragging on a slot', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const slot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
await expect(slot).toBeVisible()
const start = await getCenter(slot)
// Arbitrary value
const dragTarget = {
x: start.x + 180,
y: start.y - 140
}
await comfyMouse.move(start)
await comfyMouse.drag(dragTarget)
await comfyPage.nextFrame()
try {
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-dragging-link.png'
)
} finally {
await comfyMouse.drop()
}
})
test('should create a link when dropping on a compatible slot', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({
originId: samplerNode.id,
originSlot: 0,
targetId: vaeNode.id,
targetSlot: 0
})
})
test('should not create a link when slot types are incompatible', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
expect(samplerNode && clipNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const clipInput = await clipNode.getInput(0)
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
await expectVisibleAll(outputSlot, inputSlot)
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.getLinkCount()).toBe(0)
const graphLinkDetails = await getInputLinkDetails(
comfyPage.page,
clipNode.id,
0
)
expect(graphLinkDetails).toBeNull()
})
test('should not create a link when dropping onto a slot on the same node', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const samplerInput = await samplerNode.getInput(3)
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
await expectVisibleAll(outputSlot, inputSlot)
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await samplerInput.getLinkCount()).toBe(0)
})
test('should reuse the existing origin when dragging an input link', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
await comfyMouse.drag(vaeInputCenter)
await comfyMouse.drop()
const dragTarget = {
x: vaeInputCenter.x + 160,
y: vaeInputCenter.y - 100
}
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-input-drag-reuses-origin.png'
)
await comfyMouse.drop()
})
test('ctrl+alt drag from an input starts a fresh link', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
await comfyMouse.drag(vaeInputCenter)
await comfyMouse.drop()
await comfyPage.nextFrame()
const dragTarget = {
x: vaeInputCenter.x + 140,
y: vaeInputCenter.y - 110
}
await comfyMouse.move(vaeInputCenter)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Alt')
try {
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-input-drag-ctrl-alt.png'
)
} finally {
await comfyMouse.drop().catch(() => {})
await comfyPage.page.keyboard.up('Alt').catch(() => {})
await comfyPage.page.keyboard.up('Control').catch(() => {})
}
await comfyPage.nextFrame()
// Tcehnically intended to disconnect existing as well
expect(await vaeInput.getLinkCount()).toBe(0)
expect(await samplerOutput.getLinkCount()).toBe(0)
})
test('dropping an input link back on its slot restores the original connection', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
try {
await comfyMouse.drag(vaeInputCenter)
} finally {
await comfyMouse.drop()
}
await comfyPage.nextFrame()
const originalLink = await getInputLinkDetails(
comfyPage.page,
vaeNode.id,
0
)
expect(originalLink).not.toBeNull()
const dragTarget = {
x: vaeInputCenter.x + 150,
y: vaeInputCenter.y - 100
}
// To prevent needing a screenshot expectation for whether the link's off
const vaeInputLocator = slotLocator(comfyPage.page, vaeNode.id, 0, true)
const inputBox = await vaeInputLocator.boundingBox()
if (!inputBox) throw new Error('Input slot bounding box not available')
const isOutsideX =
dragTarget.x < inputBox.x || dragTarget.x > inputBox.x + inputBox.width
const isOutsideY =
dragTarget.y < inputBox.y || dragTarget.y > inputBox.y + inputBox.height
expect(isOutsideX || isOutsideY).toBe(true)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drop()
await comfyPage.nextFrame()
const restoredLink = await getInputLinkDetails(
comfyPage.page,
vaeNode.id,
0
)
expect(restoredLink).not.toBeNull()
if (!restoredLink || !originalLink) {
throw new Error('Expected both original and restored links to exist')
}
expect(restoredLink).toMatchObject({
originId: originalLink.originId,
originSlot: originalLink.originSlot,
targetId: originalLink.targetId,
targetSlot: originalLink.targetSlot,
parentId: originalLink.parentId
})
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
})
test('rerouted input drag preview remains anchored to reroute', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
const outputPosition = await samplerOutput.getPosition()
const inputPosition = await vaeInput.getPosition()
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
if (!node) throw new Error('Target node not found')
const input = node.inputs?.[targetSlot]
if (!input) throw new Error('Target input slot not found')
const linkId = input.link
if (linkId == null) throw new Error('Expected existing link on input')
const link = graph.getLink(linkId)
if (!link) throw new Error('Link not found')
// Convert the client/canvas pixel coordinates to graph space
const pos = app.canvas.ds.convertCanvasToOffset([
clientPoint.x,
clientPoint.y
])
graph.createReroute(pos, link)
},
[vaeNode.id, 0, reroutePoint] as const
)
await comfyPage.nextFrame()
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
const dragTarget = {
x: vaeInputCenter.x + 160,
y: vaeInputCenter.y - 120
}
let dropped = false
try {
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-reroute-input-drag.png'
)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drop()
dropped = true
} finally {
if (!dropped) {
await comfyMouse.drop().catch(() => {})
}
}
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails?.originId).toBe(samplerNode.id)
expect(linkDetails?.parentId).not.toBeNull()
})
test('rerouted output shift-drag preview remains anchored to reroute', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
const outputPosition = await samplerOutput.getPosition()
const inputPosition = await vaeInput.getPosition()
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
if (!node) throw new Error('Target node not found')
const input = node.inputs?.[targetSlot]
if (!input) throw new Error('Target input slot not found')
const linkId = input.link
if (linkId == null) throw new Error('Expected existing link on input')
const link = graph.getLink(linkId)
if (!link) throw new Error('Link not found')
// Convert the client/canvas pixel coordinates to graph space
const pos = app.canvas.ds.convertCanvasToOffset([
clientPoint.x,
clientPoint.y
])
graph.createReroute(pos, link)
},
[vaeNode.id, 0, reroutePoint] as const
)
await comfyPage.nextFrame()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dragTarget = {
x: outputCenter.x + 150,
y: outputCenter.y - 140
}
let dropPending = false
let shiftHeld = false
try {
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
shiftHeld = true
dropPending = true
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-reroute-output-shift-drag.png'
)
await comfyMouse.move(outputCenter)
await comfyMouse.drop()
dropPending = false
} finally {
if (dropPending) await comfyMouse.drop().catch(() => {})
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails?.originId).toBe(samplerNode.id)
expect(linkDetails?.parentId).not.toBeNull()
})
test('dragging input to input drags existing link', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
// Step 1: Connect CLIP's only output (index 0) to KSampler's second input (index 1)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
// Verify initial link exists between CLIP -> KSampler input[1]
const initialLink = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
expect(initialLink).not.toBeNull()
expect(initialLink).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 1
})
// Step 2: Drag from KSampler's second input to its third input (index 2)
const input2Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
1,
true
)
const input3Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
2,
true
)
await comfyMouse.move(input2Center)
await comfyMouse.drag(input3Center)
await comfyMouse.drop()
await comfyPage.nextFrame()
// Expect old link removed from input[1]
const afterSecondInput = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
expect(afterSecondInput).toBeNull()
// Expect new link exists at input[2] from CLIP
const afterThirdInput = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
2
)
expect(afterThirdInput).not.toBeNull()
expect(afterThirdInput).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 2
})
})
test('shift-dragging an output with multiple links should drag all links', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
const clipOutput = await clipNode.getOutput(0)
// Connect output[0] -> inputs[1] and [2]
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 2 },
() => comfyPage.nextFrame()
)
expect(await clipOutput.getLinkCount()).toBe(2)
const outputCenter = await getSlotCenter(
comfyPage.page,
clipNode.id,
0,
false
)
const dragTarget = {
x: outputCenter.x + 40,
y: outputCenter.y - 140
}
let dropPending = false
let shiftHeld = false
try {
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
shiftHeld = true
await comfyMouse.drag(dragTarget)
dropPending = true
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-shift-output-multi-link.png'
)
} finally {
if (dropPending) await comfyMouse.drop().catch(() => {})
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
})
test('should snap to node center while dragging and link on drop', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
// Start drag from CLIP output[0]
const clipOutputCenter = await getSlotCenter(
comfyPage.page,
clipNode.id,
0,
false
)
// Drag to the visual center of the KSampler Vue node (not a slot)
const samplerVue = comfyPage.vueNodes.getNodeLocator(String(samplerNode.id))
await expect(samplerVue).toBeVisible()
const samplerCenter = await getCenter(samplerVue)
await comfyMouse.move(clipOutputCenter)
await comfyMouse.drag(samplerCenter)
// During drag, the preview should snap/highlight a compatible input on KSampler
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-snap-to-node.png')
// Drop to create the link
await comfyMouse.drop()
await comfyPage.nextFrame()
// Validate a link was created to one of KSampler's compatible inputs (1 or 2)
const linkOnInput1 = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
const linkOnInput2 = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
2
)
const linked = linkOnInput1 ?? linkOnInput2
expect(linked).not.toBeNull()
expect(linked?.originId).toBe(clipNode.id)
expect(linked?.targetId).toBe(samplerNode.id)
})
test('should snap to a specific compatible slot when targeting it', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
// Drag from CLIP output[0] to KSampler input[2] (third slot) which is the
// second compatible input for CLIP
const clipOutputCenter = await getSlotCenter(
comfyPage.page,
clipNode.id,
0,
false
)
const samplerInput3Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
2,
true
)
await comfyMouse.move(clipOutputCenter)
await comfyMouse.drag(samplerInput3Center)
// Expect the preview to show snapping to the targeted slot
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-snap-to-slot.png')
// Finish the connection
await comfyMouse.drop()
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
2
)
expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 2
})
})
})

View File

@@ -1,76 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Nodes Renaming', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should display node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
})
test('should allow title renaming by double clicking on the node header', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
})
test('Double click node body does not trigger edit', async ({
comfyPage
}) => {
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBbox = await loadCheckpointNode.boundingBox()
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()
const editingTitleInput = comfyPage.page.getByTestId('node-title-input')
await expect(editingTitleInput).not.toBeVisible()
})
})

View File

@@ -0,0 +1,221 @@
import type { Locator } from '@playwright/test'
import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
import { fitToViewInstant } from '../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
if (!box) throw new Error('Slot bounding box not available')
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
}
test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
test('should show a link dragging out from a slot when dragging on a slot', async ({
comfyPage,
comfyMouse
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const outputSlot = await samplerNode.getOutput(0)
await outputSlot.removeLinks()
await comfyPage.nextFrame()
const slotKey = getSlotKey(String(samplerNode.id), 0, false)
const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`)
await expect(slotLocator).toBeVisible()
const start = await getCenter(slotLocator)
const canvasBox = await comfyPage.canvas.boundingBox()
if (!canvasBox) throw new Error('Canvas bounding box not available')
// Arbitrary value
const dragTarget = {
x: start.x + 180,
y: start.y - 140
}
await comfyMouse.move(start)
await comfyMouse.drag(dragTarget)
await comfyPage.nextFrame()
try {
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-dragging-link.png'
)
} finally {
await comfyMouse.drop()
}
})
test('should create a link when dropping on a compatible slot', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
expect(vaeNodes.length).toBeGreaterThan(0)
const vaeNode = vaeNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
const linkDetails = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return null
const source = graph.getNodeById(sourceId)
if (!source) return null
const linkId = source.outputs[0]?.links?.[0]
if (linkId == null) return null
const link = graph.links[linkId]
if (!link) return null
return {
originId: link.origin_id,
originSlot: link.origin_slot,
targetId: link.target_id,
targetSlot: link.target_slot
}
}, samplerNode.id)
expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({
originId: samplerNode.id,
originSlot: 0,
targetId: vaeNode.id,
targetSlot: 0
})
})
test('should not create a link when slot types are incompatible', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
expect(clipNodes.length).toBeGreaterThan(0)
const clipNode = clipNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const clipInput = await clipNode.getInput(0)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(clipNode.id), 0, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.getLinkCount()).toBe(0)
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return 0
const source = graph.getNodeById(sourceId)
if (!source) return 0
return source.outputs[0]?.links?.length ?? 0
}, samplerNode.id)
expect(graphLinkCount).toBe(0)
})
test('should not create a link when dropping onto a slot on the same node', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const samplerInput = await samplerNode.getInput(3)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await samplerInput.getLinkCount()).toBe(0)
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return 0
const source = graph.getNodeById(sourceId)
if (!source) return 0
return source.outputs[0]?.links?.length ?? 0
}, samplerNode.id)
expect(graphLinkCount).toBe(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1,7 +1,7 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
} from '../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -15,8 +15,7 @@ test.describe('Vue Node Selection', () => {
const modifiers = [
{ key: 'Control', name: 'ctrl' },
{ key: 'Shift', name: 'shift' },
{ key: 'Meta', name: 'meta' }
{ key: 'Shift', name: 'shift' }
] as const
for (const { key: modifier, name } of modifiers) {

View File

@@ -1,49 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Vue Node Custom Colors', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('displays color picker button and allows color selection', async ({
comfyPage
}) => {
const loadCheckpointNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'Load Checkpoint'
})
await loadCheckpointNode.getByText('Load Checkpoint').click()
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container')
.locator('i[data-testid="blue"]')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-color-blue.png'
)
})
test('should load node colors from workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/every_node_color')
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-colors-dark-all-colors.png'
)
})
test('should show brightened node colors on light theme', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.loadWorkflow('nodes/every_node_color')
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-colors-light-all-colors.png'
)
})
})

View File

@@ -1,85 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const PIN_HOTKEY = 'p'
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
test.describe('Vue Node Pin', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling pin on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
await expect(pinIndicator).not.toBeVisible()
})
test('should allow toggling pin on multiple selected nodes with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.keyboard.press(PIN_HOTKEY)
const pinIndicator1 = checkpointNode.locator(PIN_INDICATOR)
await expect(pinIndicator1).toBeVisible()
const pinIndicator2 = ksamplerNode.locator(PIN_INDICATOR)
await expect(pinIndicator2).toBeVisible()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
await expect(pinIndicator1).not.toBeVisible()
await expect(pinIndicator2).not.toBeVisible()
})
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
// Try to drag the node
const headerPos = await checkpointNodeHeader.boundingBox()
if (!headerPos) throw new Error('Failed to get header position')
await comfyPage.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
// Verify the node is not dragged (same position before and after click-and-drag)
const headerPosAfterDrag = await checkpointNodeHeader.boundingBox()
if (!headerPosAfterDrag)
throw new Error('Failed to get header position after drag')
expect(headerPosAfterDrag).toEqual(headerPos)
// Unpin the node with the hotkey
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
// Try to drag the node again
await comfyPage.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
// Verify the node is dragged
const headerPosAfterDrag2 = await checkpointNodeHeader.boundingBox()
if (!headerPosAfterDrag2)
throw new Error('Failed to get header position after drag')
expect(headerPosAfterDrag2).not.toEqual(headerPos)
})
})

View File

@@ -1,49 +0,0 @@
import {
type ComfyPage,
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Multiline String Widget', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
const getFirstClipNode = (comfyPage: ComfyPage) =>
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
const getFirstMultilineStringWidget = (comfyPage: ComfyPage) =>
getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
test('should allow entering text', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
await textarea.fill('Hello World')
await expect(textarea).toHaveValue('Hello World')
await textarea.fill('Hello World 2')
await expect(textarea).toHaveValue('Hello World 2')
})
test('should support entering multiline content', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const multilineValue = ['Line 1', 'Line 2', 'Line 3'].join('\n')
await textarea.fill(multilineValue)
await expect(textarea).toHaveValue(multilineValue)
})
test('should retain value after focus changes', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
await textarea.fill('Keep me around')
// Click another node
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await loadCheckpointNode.click()
await getFirstClipNode(comfyPage).click()
await expect(textarea).toHaveValue('Keep me around')
})
})

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'
const fileName = fileURLToPath(import.meta.url)
const dirName = dirname(fileName)
const customIconsPath = join(dirName, 'icons')
const customIconsPath = join(dirName, '..', 'src', 'assets', 'icons', 'custom')
// Iconify collection structure
interface IconifyIcon {

View File

@@ -110,7 +110,7 @@ pnpm build
For faster iteration during development, use watch mode:
```bash
pnpm exec vite build --watch
npx vite build --watch
```
Note: Watch mode provides faster rebuilds than full builds, but still no hot reload

View File

@@ -181,31 +181,5 @@ export default defineConfig([
{ disallowTypeAnnotations: false }
]
}
},
{
files: ['**/*.spec.ts'],
ignores: ['browser_tests/tests/**/*.spec.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Program',
message: '.spec.ts files are only allowed under browser_tests/tests/'
}
]
}
},
{
files: ['browser_tests/tests/**/*.test.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Program',
message:
'.test.ts files are not allowed in browser_tests/tests/; use .spec.ts instead'
}
]
}
}
])

View File

@@ -1,25 +1,15 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
workspaces: {
'.': {
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/design-system': {
entry: ['src/**/*.ts'],
project: ['src/**/*.{js,ts}', '*.{js,ts,mts}']
}
},
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'],
ignoreBinaries: ['only-allow', 'openapi-typescript'],
ignoreDependencies: [
// Weird importmap things
'@iconify/json',
@@ -35,7 +25,9 @@ const config: KnipConfig = {
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'src/types/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts'
'src/scripts/ui/components/splitButton.ts',
// Staged for for use with subgraph widget promotion
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.3",
"version": "1.28.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -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": "pnpm exec nx e2e",
"test:browser": "npx 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": "pnpm dlx only-allow pnpm",
"preinstall": "npx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"lint": "eslint src --cache",
@@ -35,13 +35,15 @@
"knip": "knip --cache",
"knip:no-cache": "knip",
"locale": "lobe-i18n locale",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"collect-i18n": "npx 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",
@@ -105,7 +107,6 @@
"@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/tailwind-utils": "workspace:*",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
@@ -124,7 +125,6 @@
"@tiptap/extension-table-row": "^2.10.4",
"@tiptap/starter-kit": "^2.10.4",
"@vueuse/core": "^11.0.0",
"@vueuse/integrations": "^13.9.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",

View File

@@ -1,31 +0,0 @@
{
"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"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
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'
})
]
}

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}

View File

@@ -7,7 +7,6 @@ export default defineConfig({
headless: true
},
reporter: 'list',
workers: 1,
timeout: 60000,
testMatch: /collect-i18n-.*\.ts/
})

318
pnpm-lock.yaml generated
View File

@@ -17,9 +17,6 @@ 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/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
@@ -74,9 +71,6 @@ importers:
'@vueuse/core':
specifier: ^11.0.0
version: 11.0.0(vue@3.5.13(typescript@5.9.2))
'@vueuse/integrations':
specifier: ^13.9.0
version: 13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -177,6 +171,12 @@ 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,22 +352,6 @@ 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/tailwind-utils:
dependencies:
clsx:
@@ -2921,73 +2905,18 @@ packages:
'@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
'@vueuse/core@13.9.0':
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
peerDependencies:
vue: ^3.5.0
'@vueuse/integrations@13.9.0':
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
peerDependencies:
async-validator: ^4
axios: ^1
change-case: ^5
drauu: ^0.4
focus-trap: ^7
fuse.js: ^7
idb-keyval: ^6
jwt-decode: ^4
nprogress: ^0.2
qrcode: ^1.5
sortablejs: ^1
universal-cookie: ^7 || ^8
vue: ^3.5.0
peerDependenciesMeta:
async-validator:
optional: true
axios:
optional: true
change-case:
optional: true
drauu:
optional: true
focus-trap:
optional: true
fuse.js:
optional: true
idb-keyval:
optional: true
jwt-decode:
optional: true
nprogress:
optional: true
qrcode:
optional: true
sortablejs:
optional: true
universal-cookie:
optional: true
'@vueuse/metadata@11.0.0':
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
'@vueuse/metadata@12.8.2':
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
'@vueuse/metadata@13.9.0':
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
'@vueuse/shared@11.0.0':
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
'@vueuse/shared@13.9.0':
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
peerDependencies:
vue: ^3.5.0
'@webgpu/types@0.1.51':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
@@ -3129,16 +3058,10 @@ 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==}
@@ -3314,10 +3237,6 @@ 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'}
@@ -3447,10 +3366,6 @@ 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'}
@@ -3656,9 +3571,6 @@ 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==}
@@ -3666,9 +3578,6 @@ 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==}
@@ -4557,10 +4466,6 @@ 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
@@ -4784,10 +4689,6 @@ 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==}
@@ -5150,9 +5051,6 @@ 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}
@@ -5244,10 +5142,6 @@ 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'}
@@ -5423,10 +5317,6 @@ 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:
@@ -5439,10 +5329,6 @@ 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==}
@@ -5459,47 +5345,10 @@ 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}
@@ -5696,9 +5545,6 @@ 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==}
@@ -6020,11 +5866,6 @@ 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'}
@@ -6065,11 +5906,6 @@ 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==}
@@ -6094,13 +5930,6 @@ 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==}
@@ -6187,9 +6016,6 @@ 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==}
@@ -9849,28 +9675,10 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/core@13.9.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.9.0
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
vue: 3.5.13(typescript@5.9.2)
'@vueuse/integrations@13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@vueuse/core': 13.9.0(vue@3.5.13(typescript@5.9.2))
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
vue: 3.5.13(typescript@5.9.2)
optionalDependencies:
axios: 1.11.0
fuse.js: 7.0.0
'@vueuse/metadata@11.0.0': {}
'@vueuse/metadata@12.8.2': {}
'@vueuse/metadata@13.9.0': {}
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
@@ -9884,10 +9692,6 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/shared@13.9.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
vue: 3.5.13(typescript@5.9.2)
'@webgpu/types@0.1.51': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
@@ -10021,15 +9825,11 @@ 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
@@ -10235,8 +10035,6 @@ snapshots:
pascal-case: 3.1.2
tslib: 2.8.1
camelcase-css@2.0.1: {}
camelcase@8.0.0: {}
caniuse-lite@1.0.30001737: {}
@@ -10350,8 +10148,6 @@ snapshots:
commander@2.20.3: {}
commander@4.1.1: {}
commander@8.3.0: {}
compare-versions@6.1.1: {}
@@ -10534,8 +10330,6 @@ snapshots:
dependencies:
dequal: 2.0.3
didyoumean@1.2.2: {}
diff-match-patch@1.0.5: {}
dirty-json@0.9.2:
@@ -10544,8 +10338,6 @@ snapshots:
unescape-js: 1.1.4
utf8: 3.0.0
dlv@1.1.3: {}
doctypes@1.1.0: {}
dom-accessibility-api@0.5.16: {}
@@ -11495,8 +11287,6 @@ snapshots:
chalk: 4.1.2
pretty-format: 30.0.5
jiti@1.21.7: {}
jiti@2.4.2: {}
jiti@2.5.1: {}
@@ -11718,8 +11508,6 @@ snapshots:
lilconfig@3.1.2: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
lines-and-columns@2.0.3: {}
@@ -12276,12 +12064,6 @@ 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: {}
@@ -12395,8 +12177,6 @@ snapshots:
object-assign@4.1.1: {}
object-hash@3.0.0: {}
object-keys@1.1.1: {}
ohash@2.0.11: {}
@@ -12587,8 +12367,6 @@ 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
@@ -12597,8 +12375,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.2
pirates@4.0.7: {}
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -12619,42 +12395,11 @@ 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
@@ -12930,10 +12675,6 @@ 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
@@ -13316,16 +13057,6 @@ 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
@@ -13363,33 +13094,6 @@ 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: {}
@@ -13424,14 +13128,6 @@ 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: {}
@@ -13497,8 +13193,6 @@ snapshots:
ts-dedent@2.2.0: {}
ts-interface-checker@0.1.13: {}
ts-map@1.0.3: {}
tsconfig-paths@4.2.0:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,9 +1,9 @@
import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import type { ComfyNodeDef } from '../src/schemas/nodeDefSchema'
import type { ComfyApi } from '../src/scripts/api'
import { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import { normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json'
@@ -26,23 +26,18 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
})
})
// Note: Don't mock the object_info API endpoint - let it hit the actual backend
const nodeDefs: ComfyNodeDefImpl[] = await comfyPage.page.evaluate(
async () => {
// @ts-expect-error - app is dynamically added to window
const api = window['app'].api
const rawNodeDefs = await api.getNodeDefs()
const { ComfyNodeDefImpl } = await import('../src/stores/nodeDefStore')
return (
Object.values(rawNodeDefs)
// Ignore DevTools nodes (used for internal testing)
.filter((def: ComfyNodeDef) => !def.name.startsWith('DevTools'))
.map((def: ComfyNodeDef) => new ComfyNodeDefImpl(def))
)
}
const nodeDefs: ComfyNodeDefImpl[] = (
Object.values(
await comfyPage.page.evaluate(async () => {
// @ts-expect-error - app is dynamically added to window
const api = window['app'].api as ComfyApi
return await api.getNodeDefs()
})
) as ComfyNodeDef[]
)
// Ignore DevTools nodes (used for internal testing)
.filter((def) => !def.name.startsWith('DevTools'))
.map((def) => new ComfyNodeDefImpl(def))
console.log(`Collected ${nodeDefs.length} node definitions`)

View File

@@ -16,6 +16,7 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { usePreserveWidgetScroll } from '@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -24,6 +25,9 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
// Preserve native scrolling in Vue widgets
usePreserveWidgetScroll()
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,7 @@ ComfyUI supports three types of icons that can be used throughout the interface.
```vue
<template>
<!-- Your custom SVG icons from packages/design-system/src/icons/ -->
<!-- Your custom SVG icons from src/assets/icons/custom/ -->
<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 `packages/design-system/src/icons/`:
Place your SVG file in `src/assets/icons/custom/`:
```
packages/design-system/src/icons/
src/assets/icons/custom/
├── 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** (`packages/design-system/src/iconCollection.ts`):
- Scans `packages/design-system/src/icons/` for SVG files
1. **Build-time Processing** (`build/customIconCollection.ts`):
- Scans `src/assets/icons/custom/` 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('packages/design-system/src/icons')
comfy: FileSystemIconLoader('src/assets/icons/custom')
}
})
```

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_1416_62)">
<path d="M7.99998 10.6667V8.00004M7.99998 5.33337H8.00665M14.6666 8.00004C14.6666 11.6819 11.6819 14.6667 7.99998 14.6667C4.31808 14.6667 1.33331 11.6819 1.33331 8.00004C1.33331 4.31814 4.31808 1.33337 7.99998 1.33337C11.6819 1.33337 14.6666 4.31814 14.6666 8.00004Z" stroke="#171718" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1416_62">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 623 B

View File

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 938 B

View File

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 411 B

View File

Before

Width:  |  Height:  |  Size: 830 B

After

Width:  |  Height:  |  Size: 830 B

View File

Before

Width:  |  Height:  |  Size: 857 B

After

Width:  |  Height:  |  Size: 857 B

View File

@@ -1,19 +1,7 @@
<template>
<div :class="containerClasses">
<div class="flex-1 w-full h-full">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { fullHeight = true } = defineProps<{
fullHeight?: boolean
}>()
const containerClasses = computed(() =>
cn('flex-1 w-full', fullHeight && 'h-full')
)
</script>
<script setup lang="ts"></script>

View File

@@ -8,21 +8,15 @@
<script setup lang="ts">
import { computed } from 'vue'
const { ratio = 'square', type } = defineProps<{
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
type?: string
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'portrait' | 'tallPortrait'
}>()
const containerClasses = computed(() => {
const baseClasses =
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
if (type === 'workflow-template-card') {
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
}
'flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
const ratioClasses = {
smallSquare: 'aspect-240/311',
square: 'aspect-256/308',
portrait: 'aspect-256/325',
tallPortrait: 'aspect-256/353'

View File

@@ -2,40 +2,26 @@
<div :class="topStyle">
<slot class="absolute top-0 left-0 w-full h-full"></slot>
<div
v-if="slots['top-left']"
class="absolute top-2 left-2 flex gap-2 flex-wrap justify-start"
>
<div class="absolute top-2 left-2 flex gap-2">
<slot name="top-left"></slot>
</div>
<div
v-if="slots['top-right']"
class="absolute top-2 right-2 flex gap-2 flex-wrap justify-end"
>
<div class="absolute top-2 right-2 flex gap-2">
<slot name="top-right"></slot>
</div>
<div
v-if="slots['bottom-left']"
class="absolute bottom-2 left-2 flex gap-2 flex-wrap justify-start"
>
<div class="absolute bottom-2 left-2 flex gap-2">
<slot name="bottom-left"></slot>
</div>
<div
v-if="slots['bottom-right']"
class="absolute bottom-2 right-2 flex gap-2 flex-wrap justify-end"
>
<div class="absolute bottom-2 right-2 flex gap-2">
<slot name="bottom-right"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
const slots = useSlots()
import { computed } from 'vue'
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'landscape'

View File

@@ -24,13 +24,7 @@
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
>
<img
src="/assets/images/default-template.png"
:alt="alt"
draggable="false"
:class="imageClass"
:style="imageStyle"
/>
<i class="pi pi-image text-2xl" />
</div>
</div>
</template>

View File

@@ -13,7 +13,6 @@
class="search-box-input w-full"
:model-value="modelValue"
:placeholder="placeholder"
:autofocus="autofocus"
@input="handleInput"
/>
<InputIcon v-if="!modelValue" :class="icon" />
@@ -58,8 +57,7 @@ const {
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = [],
autofocus = false
filters = []
} = defineProps<{
modelValue: string
placeholder?: string
@@ -67,7 +65,6 @@ const {
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
autofocus?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,757 +0,0 @@
<template>
<BaseModalLayout
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
<template #header-icon>
<i class="icon-[comfy--template]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{
$t('sideToolbar.templates', 'Templates')
}}</span>
</template>
</LeftSidePanel>
</template>
<template #header>
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
</template>
<template #header-right-area>
<div class="flex gap-2">
<IconTextButton
v-if="filteredCount !== totalCount"
type="secondary"
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
@click="resetFilters"
>
<template #icon>
<i-lucide:filter-x />
</template>
</IconTextButton>
</div>
</template>
<template #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
v-model:search-query="modelSearchText"
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:cpu />
</template>
</MultiSelect>
<!-- Use Case Filter -->
<MultiSelect
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:target />
</template>
</MultiSelect>
<!-- License Filter -->
<MultiSelect
v-model="selectedLicenseObjects"
:label="licenseFilterLabel"
:options="licenseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:file-text />
</template>
</MultiSelect>
<!-- Sort Options -->
<div class="absolute right-5">
<SingleSelect
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
class="min-w-[270px]"
>
<template #icon>
<i-lucide:arrow-up-down />
</template>
</SingleSelect>
</div>
</div>
<div
v-if="!isLoading"
class="px-6 pt-4 pb-2 text-2xl font-semibold text-neutral"
>
<span>
{{ pageTitle }}
</span>
</div>
</template>
<template #content>
<!-- No Results State (only show when loaded and no results) -->
<div
v-if="!isLoading && filteredTemplates.length === 0"
class="flex flex-col items-center justify-center h-64 text-neutral-500"
>
<i-lucide:search class="w-12 h-12 mb-4 opacity-50" />
<p class="text-lg mb-2">
{{ $t('templateWorkflows.noResults', 'No templates found') }}
</p>
<p class="text-sm">
{{
$t(
'templateWorkflows.noResultsHint',
'Try adjusting your search or filters'
)
}}
</p>
</div>
<div v-else>
<!-- Title -->
<span
v-if="isLoading"
class="inline-block h-8 w-48 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></span>
<!-- Template Cards Grid -->
<div
:key="templateListKey"
:style="gridStyle"
data-testid="template-workflows-content"
>
<!-- Loading Skeletons (show while loading initial data) -->
<CardContainer
v-for="n in isLoading ? 12 : 0"
:key="`initial-skeleton-${n}`"
ratio="smallSquare"
type="workflow-template-card"
>
<template #top>
<CardTop ratio="landscape">
<template #default>
<div
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
></div>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="px-4 py-3">
<div
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
></div>
<div
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></div>
</div>
</CardBottom>
</template>
</CardContainer>
<!-- Actual Template Cards -->
<CardContainer
v-for="template in isLoading ? [] : displayTemplates"
:key="template.name"
ref="cardRefs"
v-memo="[template.name, hoveredTemplate === template.name]"
ratio="smallSquare"
type="workflow-template-card"
:data-testid="`template-workflow-${template.name}`"
@mouseenter="hoveredTemplate = template.name"
@mouseleave="hoveredTemplate = null"
@click="onLoadWorkflow(template)"
>
<template #top>
<CardTop ratio="square">
<template #default>
<!-- Template Thumbnail -->
<div
class="w-full h-full relative rounded-lg overflow-hidden"
>
<template v-if="template.mediaType === 'audio'">
<AudioThumbnail :src="getBaseThumbnailSrc(template)" />
</template>
<template
v-else-if="template.thumbnailVariant === 'compareSlider'"
>
<CompareSliderThumbnail
:base-image-src="getBaseThumbnailSrc(template)"
:overlay-image-src="getOverlayThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template
v-else-if="template.thumbnailVariant === 'hoverDissolve'"
>
<HoverDissolveThumbnail
:base-image-src="getBaseThumbnailSrc(template)"
:overlay-image-src="getOverlayThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else>
<DefaultThumbnail
:src="getBaseThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
:hover-zoom="
template.thumbnailVariant === 'zoomHover' ? 16 : 5
"
/>
</template>
<ProgressSpinner
v-if="loadingTemplate === template.name"
class="absolute inset-0 z-10 w-12 h-12 m-auto"
/>
</div>
</template>
<template #bottom-right>
<template v-if="template.tags && template.tags.length > 0">
<SquareChip
v-for="tag in template.tags"
:key="tag"
:label="tag"
/>
</template>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="flex flex-col gap-2 pt-3">
<h3
class="line-clamp-1 text-sm m-0"
:title="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
>
{{
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
}}
</h3>
<div class="flex justify-between gap-2">
<div class="flex-1">
<p
class="line-clamp-2 text-sm text-muted m-0"
:title="getTemplateDescription(template)"
>
{{ getTemplateDescription(template) }}
</p>
</div>
<div
v-if="template.tutorialUrl"
class="flex flex-col-reverse justify-center"
>
<IconButton
v-if="hoveredTemplate === template.name"
v-tooltip.bottom="$t('g.seeTutorial')"
v-bind="$attrs"
type="primary"
size="sm"
@click.stop="openTutorial(template)"
>
<i class="icon-[lucide--info] size-4" />
</IconButton>
</div>
</div>
</div>
</CardBottom>
</template>
</CardContainer>
<!-- Loading More Skeletons -->
<CardContainer
v-for="n in isLoadingMore ? 6 : 0"
:key="`skeleton-${n}`"
ratio="smallSquare"
type="workflow-template-card"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
></div>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="px-4 py-3">
<div
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
></div>
<div
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></div>
</div>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<!-- Load More Trigger -->
<div
v-if="!isLoading && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center mt-4"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
</div>
</div>
<!-- Results Summary -->
<div
v-if="!isLoading"
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
>
{{
$t('templateWorkflows.resultsCount', {
count: filteredCount,
total: totalCount
})
}}
</div>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose } = defineProps<{
onClose: () => void
}>()
provide(OnCloseKey, onClose)
// Workflow templates store and composable
const workflowTemplatesStore = useWorkflowTemplatesStore()
const {
loadTemplates,
loadWorkflowTemplate,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription
} = useTemplateWorkflows()
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
}
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
}
// Open tutorial in new tab
const openTutorial = (template: TemplateInfo) => {
if (template.tutorialUrl) {
window.open(template.tutorialUrl, '_blank')
}
}
// Get navigation items from the store, with skeleton items while loading
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
// Show skeleton navigation items while loading
if (isLoading.value) {
return [
{
id: 'skeleton-all',
label: 'All Templates',
icon: 'icon-[lucide--layout-grid]'
},
{
id: 'skeleton-basics',
label: 'Basics',
icon: 'icon-[lucide--graduation-cap]'
},
{
title: 'Generation Type',
items: [
{ id: 'skeleton-1', label: '...', icon: 'icon-[lucide--loader-2]' },
{ id: 'skeleton-2', label: '...', icon: 'icon-[lucide--loader-2]' }
]
},
{
title: 'Closed Source Models',
items: [
{ id: 'skeleton-3', label: '...', icon: 'icon-[lucide--loader-2]' }
]
}
]
}
return workflowTemplatesStore.navGroupedTemplates
})
const gridStyle = computed(() => createGridStyle())
// Get enhanced templates for better filtering
const allTemplates = computed(() => {
return workflowTemplatesStore.enhancedTemplates
})
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {
return allTemplates.value
}
return workflowTemplatesStore.filterTemplatesByCategory(selectedNavItem.value)
})
// Template filtering
const {
searchQuery,
selectedModels,
selectedUseCases,
selectedLicenses,
sortBy,
filteredTemplates,
availableModels,
availableUseCases,
availableLicenses,
filteredCount,
totalCount,
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)
// Convert between string array and object array for MultiSelect component
const selectedModelObjects = computed({
get() {
return selectedModels.value.map((model) => ({ name: model, value: model }))
},
set(value: { name: string; value: string }[]) {
selectedModels.value = value.map((item) => item.value)
}
})
const selectedUseCaseObjects = computed({
get() {
return selectedUseCases.value.map((useCase) => ({
name: useCase,
value: useCase
}))
},
set(value: { name: string; value: string }[]) {
selectedUseCases.value = value.map((item) => item.value)
}
})
const selectedLicenseObjects = computed({
get() {
return selectedLicenses.value.map((license) => ({
name: license,
value: license
}))
},
set(value: { name: string; value: string }[]) {
selectedLicenses.value = value.map((item) => item.value)
}
})
// Loading states
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)
// Navigation
const selectedNavItem = ref<string | null>('all')
// Search text for model filter
const modelSearchText = ref<string>('')
// Filter options
const modelOptions = computed(() =>
availableModels.value.map((model) => ({
name: model,
value: model
}))
)
const useCaseOptions = computed(() =>
availableUseCases.value.map((useCase) => ({
name: useCase,
value: useCase
}))
)
const licenseOptions = computed(() =>
availableLicenses.value.map((license) => ({
name: license,
value: license
}))
)
// Filter labels
const modelFilterLabel = computed(() => {
if (selectedModelObjects.value.length === 0) {
return t('templateWorkflows.modelFilter', 'Model Filter')
} else if (selectedModelObjects.value.length === 1) {
return selectedModelObjects.value[0].name
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
}
})
const useCaseFilterLabel = computed(() => {
if (selectedUseCaseObjects.value.length === 0) {
return t('templateWorkflows.useCaseFilter', 'Use Case')
} else if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
} else {
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
}
})
const licenseFilterLabel = computed(() => {
if (selectedLicenseObjects.value.length === 0) {
return t('templateWorkflows.licenseFilter', 'License')
} else if (selectedLicenseObjects.value.length === 1) {
return selectedLicenseObjects.value[0].name
} else {
return t('templateWorkflows.licensesSelected', {
count: selectedLicenseObjects.value.length
})
}
})
// Sort options
const sortOptions = computed(() => [
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
},
{
name: t(
'templateWorkflows.sort.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>

View File

@@ -62,7 +62,6 @@
<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>
@@ -88,12 +87,10 @@ 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'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -104,6 +101,7 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -189,8 +187,8 @@ watch(
}
)
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
const allNodes = computed(() =>
Array.from(vueNodeLifecycle.vueNodeData.value.values())
)
watchEffect(() => {
@@ -225,6 +223,7 @@ watch(
for (const n of comfyApp.graph.nodes) {
if (!n.widgets) continue
for (const w of n.widgets) {
// @ts-expect-error fixme ts strict error
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w)
if (w.linkedWidgets) {
@@ -292,36 +291,45 @@ watch(
{ deep: true }
)
// Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
// Update node slot errors
watch(
() => executionStore.lastNodeErrors,
(lastNodeErrors) => {
if (!comfyApp.graph) return
for (const node of comfyApp.graph.nodes) {
// Clear existing errors
const removeSlotError = (node: LGraphNode) => {
for (const slot of node.inputs) {
delete slot.hasErrors
}
for (const slot of node.outputs) {
delete slot.hasErrors
}
}
for (const node of comfyApp.graph.nodes) {
removeSlotError(node)
const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) continue
const validErrors = nodeErrors.errors.filter(
(error) => error.extra_info?.input_name !== undefined
)
const slotErrorsChanged =
validErrors.length > 0 &&
validErrors.some((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
return true
}
return false
})
validErrors.forEach((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
}
})
// Trigger Vue node data update if slot errors changed
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
comfyApp.graph.onTrigger('node:slot-errors:changed', {
nodeId: node.id
})
}
}
comfyApp.canvas.draw(true, true)
@@ -354,6 +362,7 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()

View File

@@ -450,9 +450,9 @@ describe('SelectionToolbox', () => {
describe('Event Handling', () => {
it('should handle wheel events', async () => {
const mockCanvasInteractions = vi.mocked(useCanvasInteractions)
const forwardEventToCanvasSpy = vi.fn()
const handleWheelSpy = vi.fn()
mockCanvasInteractions.mockReturnValue({
forwardEventToCanvas: forwardEventToCanvasSpy
handleWheel: handleWheelSpy
} as any)
const mockExtensionService = vi.mocked(useExtensionService)
@@ -467,7 +467,7 @@ describe('SelectionToolbox', () => {
const panel = wrapper.find('.panel')
await panel.trigger('wheel')
expect(forwardEventToCanvasSpy).toHaveBeenCalled()
expect(handleWheelSpy).toHaveBeenCalled()
})
})
@@ -480,6 +480,13 @@ 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()

View File

@@ -13,7 +13,7 @@
header: 'hidden',
content: 'p-1 h-10 flex flex-row gap-1'
}"
@wheel="canvasInteractions.forwardEventToCanvas"
@wheel="canvasInteractions.handleWheel"
>
<DeleteButton v-if="showDelete" />
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
@@ -38,7 +38,7 @@
:command="command"
/>
<ExecuteButton v-if="showExecute" />
<NodeOptionsButton />
<MoreOptions />
</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 NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import MoreOptions from './selectionToolbox/MoreOptions.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
const commandStore = useCommandStore()

View File

@@ -1,5 +1,20 @@
<template>
<div>
<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>
<Popover
ref="popover"
:append-to="'body'"
@@ -11,7 +26,6 @@
:pt="pt"
@show="onPopoverShow"
@hide="onPopoverHide"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<div class="flex flex-col p-2 min-w-48">
<MenuOptionItem
@@ -36,6 +50,7 @@
<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'
@@ -48,19 +63,16 @@ import {
import {
type MenuOption,
type SubMenuOption,
registerNodeOptionsInstance,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import MenuOptionItem from './MenuOptionItem.vue'
import SubmenuPopover from './SubmenuPopover.vue'
const popover = ref<InstanceType<typeof Popover>>()
const targetElement = ref<HTMLElement | null>(null)
const isTriggeredByToolbox = ref<boolean>(true)
const buttonRef = ref<InstanceType<typeof Button> | HTMLElement | null>(null)
// Track open state ourselves so we can restore after drag/move
const isOpen = ref(false)
const wasOpenBeforeHide = ref(false)
@@ -72,10 +84,15 @@ const currentSubmenu = ref<string | null>(null)
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
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
@@ -90,7 +107,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 = targetElement.value
const btn = getButtonEl()
if (btn) {
const candidates = Array.from(
document.querySelectorAll('div.absolute.z-50')
@@ -113,24 +130,20 @@ function resolveOverlayEl(): HTMLElement | null {
const repositionPopover = () => {
if (!isOpen.value) return
const btn = targetElement.value
const btn = getButtonEl()
const overlayEl = resolveOverlayEl()
if (!btn || !overlayEl) return
const rect = btn.getBoundingClientRect()
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
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
const left = rect.left + rect.width / 2
const top = rect.bottom + marginY
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('[NodeOptions] Failed to set overlay style', e)
console.warn('[MoreOptions] Failed to set overlay style', e)
return
}
const now = performance.now()
@@ -141,16 +154,9 @@ const repositionPopover = () => {
const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)
function openPopover(
triggerEvent?: Event,
element?: HTMLElement,
clickedFromToolbox?: boolean
): boolean {
const el = element || targetElement.value
function openPopover(triggerEvent?: Event): boolean {
const el = getButtonEl()
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
@@ -183,7 +189,7 @@ function attemptRestore() {
if (isOpen.value) return
if (!wasOpenBeforeHide.value && !moreOptionsRestorePending.value) return
// Try immediately
if (openPopover(new Event('reopen'), targetElement.value || undefined)) {
if (openPopover(new Event('reopen'))) {
wasOpenBeforeHide.value = false
restoreAttempts = 0
return
@@ -194,24 +200,13 @@ function attemptRestore() {
requestAnimationFrame(() => attemptRestore())
}
const toggle = (
event: Event,
element?: HTMLElement,
clickedFromToolbox?: boolean
) => {
const toggle = (event: Event) => {
if (isOpen.value) closePopover('manual')
else openPopover(event, element, clickedFromToolbox)
else openPopover(event)
}
const hide = (reason: HideReason = 'manual') => closePopover(reason)
// Export functions for external triggering
defineExpose({
toggle,
hide,
isOpen
})
const hideAll = () => {
hideAllSubmenus(
menuOptionsWithSubmenu.value,
@@ -308,13 +303,6 @@ watch(
)
onMounted(() => {
// Register this instance globally
registerNodeOptionsInstance({
toggle,
hide,
isOpen
})
if (moreOptionsRestorePending.value && !isOpen.value) {
requestAnimationFrame(() => attemptRestore())
}
@@ -322,7 +310,5 @@ onMounted(() => {
onUnmounted(() => {
stopSync()
// Unregister on unmount
registerNodeOptionsInstance(null)
})
</script>

View File

@@ -1,33 +0,0 @@
<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>

Some files were not shown because too many files have changed in this diff Show More