Compare commits

...

10 Commits

Author SHA1 Message Date
bymyself
16b02e5326 address review feedback with minimal safe changes
- Replace type cast with proper type predicate in VueNodeHelpers getNodeIds
- Update comment to clarify shift key behavior in shouldForwardToCanvas

All review suggestions implemented while preserving working delete key functionality.
2025-09-13 22:10:06 -07:00
bymyself
aca3ede241 restore working test structure - fix VueNodeHelpers to match initial working commit
Tests were failing because helper methods were modified. Restore exact structure
that was working in initial commit, then we can add minimal review improvements.
2025-09-13 21:52:23 -07:00
bymyself
b56d5b781a add unit test for keybinding service event forwarding 2025-09-09 20:59:29 -07:00
bymyself
a850beda86 add playwright test for deletion and selection with vue nodes 2025-09-09 20:59:08 -07:00
bymyself
38bca0e134 fix delete hotkey with vue nodes 2025-09-09 20:58:12 -07:00
Christian Byrne
97d00ea47d [test] Add component tests for Vue node slots (#5461)
* add component tests for slots

* use `for of` for better error report

* add runtime type check to make assertions valid

* add runtime type check to make assertions valid
2025-09-09 18:48:51 -07:00
Christian Byrne
0a2260a666 Add design system color variables to tailwind config and use in Vue Nodes (#5430)
* use tailwind colors for

* add updated tokens with scales
2025-09-09 18:45:55 -07:00
Jin Yi
5b834acc86 feat(tailwind): add lucide icon support via iconify plugin (#5453) 2025-09-10 01:20:25 +00:00
Arjan Singh
7d4437c724 [fix] assets service review nits (#5444)
* [fix] assets service review nits

* [fix] lint
2025-09-09 17:00:23 -07:00
Benjamin Lu
9997053290 chore(lint): make ESLint concurrency configurable via pnpm config (#5448)
* chore(lint): make ESLint concurrency configurable via .env (default auto)

* Change default to be 4

* Change to config approach
2025-09-09 15:51:43 -07:00
35 changed files with 976 additions and 355 deletions

View File

@@ -33,3 +33,4 @@ DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579

View File

@@ -12,6 +12,7 @@ import { NodeBadgeMode } from '../../src/types/nodeSource'
import { ComfyActionbar } from '../helpers/actionbar'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { SettingDialog } from './components/SettingDialog'
import {
@@ -144,6 +145,7 @@ export class ComfyPage {
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -172,6 +174,7 @@ export class ComfyPage {
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
}
convertLeafToContent(structure: FolderStructure): FolderStructure {

View File

@@ -0,0 +1,109 @@
/**
* Vue Node Test Helpers
*/
import type { Locator, Page } from '@playwright/test'
export class VueNodeHelpers {
constructor(private page: Page) {}
/**
* Get locator for all Vue node components in the DOM
*/
get nodes(): Locator {
return this.page.locator('[data-node-id]')
}
/**
* Get locator for selected Vue node components (using visual selection indicators)
*/
get selectedNodes(): Locator {
return this.page.locator('[data-node-id].border-blue-500')
}
/**
* Get total count of Vue nodes in the DOM
*/
async getNodeCount(): Promise<number> {
return await this.nodes.count()
}
/**
* Get count of selected Vue nodes
*/
async getSelectedNodeCount(): Promise<number> {
return await this.selectedNodes.count()
}
/**
* Get all Vue node IDs currently in the DOM
*/
async getNodeIds(): Promise<string[]> {
return await this.nodes.evaluateAll(
(nodes) =>
nodes
.map((n) => n.getAttribute('data-node-id'))
.filter((id): id is string => id !== null)
)
}
/**
* Select a specific Vue node by ID
*/
async selectNode(nodeId: string): Promise<void> {
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
}
/**
* Select multiple Vue nodes by IDs using Ctrl+click
*/
async selectNodes(nodeIds: string[]): Promise<void> {
if (nodeIds.length === 0) return
// Select first node normally
await this.selectNode(nodeIds[0])
// Add additional nodes with Ctrl+click
for (let i = 1; i < nodeIds.length; i++) {
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
modifiers: ['Control']
})
}
}
/**
* Clear all selections by clicking empty space
*/
async clearSelection(): Promise<void> {
await this.page.mouse.click(50, 50)
}
/**
* Delete selected Vue nodes using Delete key
*/
async deleteSelected(): Promise<void> {
await this.page.locator('#graph-canvas').focus()
await this.page.keyboard.press('Delete')
}
/**
* Delete selected Vue nodes using Backspace key
*/
async deleteSelectedWithBackspace(): Promise<void> {
await this.page.locator('#graph-canvas').focus()
await this.page.keyboard.press('Backspace')
}
/**
* Wait for Vue nodes to be rendered
*/
async waitForNodes(expectedCount?: number): Promise<void> {
if (expectedCount !== undefined) {
await this.page.waitForFunction(
(count) => document.querySelectorAll('[data-node-id]').length >= count,
expectedCount
)
} else {
await this.page.waitForSelector('[data-node-id]')
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -0,0 +1,141 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Vue Nodes - Delete Key Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
// Enable Vue nodes rendering
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setup()
})
test('Can select all and delete Vue nodes with Delete key', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
// Get initial Vue node count
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
// Select all Vue nodes
await comfyPage.ctrlA()
// Verify all Vue nodes are selected
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialNodeCount)
// Delete with Delete key
await comfyPage.vueNodes.deleteSelected()
// Verify all Vue nodes were deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(0)
})
test('Can select specific Vue node and delete it', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
// Get initial Vue node count
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
// Get first Vue node ID and select it
const nodeIds = await comfyPage.vueNodes.getNodeIds()
await comfyPage.vueNodes.selectNode(nodeIds[0])
// Verify selection
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(1)
// Delete with Delete key
await comfyPage.vueNodes.deleteSelected()
// Verify one Vue node was deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
})
test('Can select and delete Vue node with Backspace key', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
// Select first Vue node
const nodeIds = await comfyPage.vueNodes.getNodeIds()
await comfyPage.vueNodes.selectNode(nodeIds[0])
// Delete with Backspace key instead of Delete
await comfyPage.vueNodes.deleteSelectedWithBackspace()
// Verify Vue node was deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
})
test('Delete key does not delete node when typing in Vue node widgets', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
// Find a text input widget in a Vue node
const textWidget = comfyPage.page
.locator('input[type="text"], textarea')
.first()
// Click on text widget to focus it
await textWidget.click()
await textWidget.fill('test text')
// Press Delete while focused on widget - should delete text, not node
await textWidget.press('Delete')
// Node count should remain the same
const finalNodeCount = await comfyPage.getGraphNodesCount()
expect(finalNodeCount).toBe(initialNodeCount)
})
test('Delete key does not delete node when nothing is selected', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
// Ensure no Vue nodes are selected
await comfyPage.vueNodes.clearSelection()
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(0)
// Press Delete key - should not crash and should handle gracefully
await comfyPage.page.keyboard.press('Delete')
// Vue node count should remain the same
const nodeCount = await comfyPage.vueNodes.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
})
test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
// Multi-select first two Vue nodes using Ctrl+click
const nodeIds = await comfyPage.vueNodes.getNodeIds()
const nodesToSelect = nodeIds.slice(0, 2)
await comfyPage.vueNodes.selectNodes(nodesToSelect)
// Verify expected nodes are selected
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(nodesToSelect.length)
// Delete selected Vue nodes
await comfyPage.vueNodes.deleteSelected()
// Verify expected nodes were deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 174 KiB

View File

@@ -51,8 +51,7 @@ const config: KnipConfig = {
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch'
],
ignoreUnresolved: ['^~icons/']
]
}
export default config

View File

@@ -25,10 +25,10 @@
"preinstall": "npx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"lint": "eslint src --cache --concurrency=auto",
"lint:fix": "eslint src --cache --fix --concurrency=auto",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"lint": "eslint src --cache --concurrency=$npm_package_config_eslint_concurrency",
"lint:fix": "eslint src --fix --cache --concurrency=$npm_package_config_eslint_concurrency",
"lint:no-cache": "eslint src --concurrency=$npm_package_config_eslint_concurrency",
"lint:fix:no-cache": "eslint src --fix --concurrency=$npm_package_config_eslint_concurrency",
"knip": "knip --cache",
"knip:no-cache": "knip",
"locale": "lobe-i18n locale",
@@ -37,8 +37,12 @@
"storybook": "nx storybook -p 6006",
"build-storybook": "storybook build"
},
"config": {
"eslint_concurrency": "4"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@iconify-json/lucide": "^1.2.66",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
@@ -76,7 +80,6 @@
"jsdom": "^26.1.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"nx": "21.4.1",
"prettier": "^3.3.2",
"storybook": "^9.1.1",

22
pnpm-lock.yaml generated
View File

@@ -171,6 +171,9 @@ importers:
'@eslint/js':
specifier: ^9.8.0
version: 9.12.0
'@iconify-json/lucide':
specifier: ^1.2.66
version: 1.2.66
'@iconify/tailwind':
specifier: ^1.2.0
version: 1.2.0
@@ -282,9 +285,6 @@ importers:
lint-staged:
specifier: ^15.2.7
version: 15.2.7
lucide-vue-next:
specifier: ^0.540.0
version: 0.540.0(vue@3.5.13(typescript@5.9.2))
nx:
specifier: 21.4.1
version: 21.4.1
@@ -1595,6 +1595,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-json/lucide@1.2.66':
resolution: {integrity: sha512-TrhmfThWY2FHJIckjz7g34gUx3+cmja61DcHNdmu0rVDBQHIjPMYO1O8mMjoDSqIXEllz9wDZxCqT3lFuI+f/A==}
'@iconify/json@2.2.380':
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
@@ -4736,11 +4739,6 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'}
lucide-vue-next@0.540.0:
resolution: {integrity: sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==}
peerDependencies:
vue: '>=3.0.1'
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -8024,6 +8022,10 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-json/lucide@1.2.66':
dependencies:
'@iconify/types': 2.0.0
'@iconify/json@2.2.380':
dependencies:
'@iconify/types': 2.0.0
@@ -11563,10 +11565,6 @@ snapshots:
lru-cache@8.0.5: {}
lucide-vue-next@0.540.0(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)
lz-string@1.5.0: {}
magic-string@0.30.17:

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
@@ -33,13 +32,13 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconButton, Trophy },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Trophy :size="16" />
<i class="icon-[lucide--trophy] size-4" />
</IconButton>
`
}),
@@ -51,13 +50,13 @@ export const Primary: Story = {
export const Secondary: Story = {
render: (args) => ({
components: { IconButton, Settings },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</IconButton>
`
}),
@@ -69,13 +68,13 @@ export const Secondary: Story = {
export const Transparent: Story = {
render: (args) => ({
components: { IconButton, X },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</IconButton>
`
}),
@@ -87,13 +86,13 @@ export const Transparent: Story = {
export const Small: Story = {
render: (args) => ({
components: { IconButton, Bell },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Bell :size="12" />
<i class="icon-[lucide--bell] size-3" />
</IconButton>
`
}),
@@ -105,42 +104,42 @@ export const Small: Story = {
export const AllVariants: Story = {
render: () => ({
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
components: { IconButton },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconButton type="primary" size="sm" @click="() => {}">
<Trophy :size="12" />
<i class="icon-[lucide--trophy] size-3" />
</IconButton>
<IconButton type="primary" size="md" @click="() => {}">
<Trophy :size="16" />
<i class="icon-[lucide--trophy] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="secondary" size="sm" @click="() => {}">
<Settings :size="12" />
<i class="icon-[lucide--settings] size-3" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="transparent" size="sm" @click="() => {}">
<X :size="12" />
<i class="icon-[lucide--x] size-3" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="primary" size="md" @click="() => {}">
<Bell :size="16" />
<i class="icon-[lucide--bell] size-4" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Heart :size="16" />
<i class="icon-[lucide--heart] size-4" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</IconButton>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
import IconGroup from './IconGroup.vue'
@@ -17,17 +16,17 @@ type Story = StoryObj<typeof IconGroup>
export const Basic: Story = {
render: () => ({
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
components: { IconGroup, IconButton },
template: `
<IconGroup>
<IconButton @click="console.log('Hello World!!')">
<Heart :size="16" />
<i class="icon-[lucide--heart] size-4" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<ExternalLink :size="16" />
<i class="icon-[lucide--external-link] size-4" />
</IconButton>
</IconGroup>
`

View File

@@ -1,14 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
ChevronLeft,
ChevronRight,
Download,
Package,
Save,
Settings,
Trash2,
X
} from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
@@ -49,14 +39,14 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconTextButton, Package },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Package :size="16" />
<i class="icon-[lucide--package] size-4" />
</template>
</IconTextButton>
`
@@ -70,14 +60,14 @@ export const Primary: Story = {
export const Secondary: Story = {
render: (args) => ({
components: { IconTextButton, Settings },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</template>
</IconTextButton>
`
@@ -91,14 +81,14 @@ export const Secondary: Story = {
export const Transparent: Story = {
render: (args) => ({
components: { IconTextButton, X },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</template>
</IconTextButton>
`
@@ -112,14 +102,14 @@ export const Transparent: Story = {
export const WithIconRight: Story = {
render: (args) => ({
components: { IconTextButton, ChevronRight },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<ChevronRight :size="16" />
<i class="icon-[lucide--chevron-right] size-4" />
</template>
</IconTextButton>
`
@@ -134,14 +124,14 @@ export const WithIconRight: Story = {
export const Small: Story = {
render: (args) => ({
components: { IconTextButton, Save },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Save :size="12" />
<i class="icon-[lucide--save] size-3" />
</template>
</IconTextButton>
`
@@ -156,66 +146,60 @@ export const Small: Story = {
export const AllVariants: Story = {
render: () => ({
components: {
IconTextButton,
Download,
Settings,
Trash2,
ChevronRight,
ChevronLeft,
Save
IconTextButton
},
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
<template #icon>
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
<template #icon>
<Settings :size="12" />
<i class="icon-[lucide--settings] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
<template #icon>
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
<template #icon>
<Trash2 :size="12" />
<i class="icon-[lucide--trash-2] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
<template #icon>
<Trash2 :size="16" />
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
<template #icon>
<ChevronRight :size="16" />
<i class="icon-[lucide--chevron-right] size-4" />
</template>
</IconTextButton>
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
<template #icon>
<ChevronLeft :size="16" />
<i class="icon-[lucide--chevron-left] size-4" />
</template>
</IconTextButton>
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
<template #icon>
<Save :size="16" />
<i class="icon-[lucide--save] size-4" />
</template>
</IconTextButton>
</div>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ScrollText } from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
import MoreButton from './MoreButton.vue'
@@ -18,7 +17,7 @@ type Story = StoryObj<typeof MoreButton>
export const Basic: Story = {
render: () => ({
components: { MoreButton, IconTextButton, Download, ScrollText },
components: { MoreButton, IconTextButton },
template: `
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
<MoreButton>
@@ -29,7 +28,7 @@ export const Basic: Story = {
@click="() => { close() }"
>
<template #icon>
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
@@ -39,7 +38,7 @@ export const Basic: Story = {
@click="() => { close() }"
>
<template #icon>
<ScrollText :size="16" />
<i class="icon-[lucide--scroll-text] size-4" />
</template>
</IconTextButton>
</template>

View File

@@ -1,13 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Folder,
Heart,
Info,
MoreVertical,
Star,
Upload
} from 'lucide-vue-next'
import { ref } from 'vue'
import IconButton from '../button/IconButton.vue'
@@ -149,14 +140,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download,
Star,
Upload,
MoreVertical
SquareChip
},
setup() {
const favorited = ref(false)
@@ -202,14 +186,14 @@ const createCardTemplate = (args: CardStoryArgs) => ({
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info clicked')"
>
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
<IconButton
class="!bg-white/90"
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
@click="toggleFavorite"
>
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" />
<i class="icon-[lucide--heart] size-4" :class="favorited ? 'fill-current' : ''" />
</IconButton>
</template>
@@ -222,7 +206,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>
@@ -409,11 +393,7 @@ export const GridOfCards: Story = {
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download
SquareChip
},
setup() {
const cards = ref([
@@ -500,7 +480,7 @@ export const GridOfCards: Story = {
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info:', card.title)"
>
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
</template>
@@ -511,7 +491,7 @@ export const GridOfCards: Story = {
:label="tag"
>
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
<SquareChip :label="card.size" />

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ArrowUpDown } from 'lucide-vue-next'
import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
@@ -57,7 +56,7 @@ export const Default: Story = {
export const WithIcon: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
const options = sampleOptions
@@ -67,7 +66,7 @@ export const WithIcon: Story = {
<div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon>
<ArrowUpDown :size="14" />
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
@@ -94,7 +93,7 @@ export const Preselected: Story = {
export const AllVariants: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
components: { SingleSelect },
setup() {
const options = sampleOptions
const a = ref<string | null>(null)
@@ -110,7 +109,7 @@ export const AllVariants: Story = {
<div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon>
<ArrowUpDown :size="14" />
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
</div>

View File

@@ -136,10 +136,6 @@
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DownloadIcon from '~icons/lucide/download'
import Grid3x3Icon from '~icons/lucide/grid-3-x-3'
import LayersIcon from '~icons/lucide/layers'
import TagIcon from '~icons/lucide/tag'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
@@ -177,20 +173,20 @@ const sortOptions = ref([
])
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed', icon: DownloadIcon },
{ id: 'installed', label: 'Installed', icon: 'icon-[lucide--download]' },
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5', icon: TagIcon },
{ id: 'tag-sdxl', label: 'SDXL', icon: TagIcon },
{ id: 'tag-utility', label: 'Utility', icon: TagIcon }
{ id: 'tag-sd15', label: 'SD 1.5', icon: 'icon-[lucide--tag]' },
{ id: 'tag-sdxl', label: 'SDXL', icon: 'icon-[lucide--tag]' },
{ id: 'tag-utility', label: 'Utility', icon: 'icon-[lucide--tag]' }
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models', icon: LayersIcon },
{ id: 'cat-nodes', label: 'Nodes', icon: Grid3x3Icon }
{ id: 'cat-models', label: 'Models', icon: 'icon-[lucide--layers]' },
{ id: 'cat-nodes', label: 'Nodes', icon: 'icon-[lucide--grid-3x3]' }
]
}
])

View File

@@ -1,20 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Filter,
Folder,
Info,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
Puzzle,
Scroll,
Settings,
Upload,
X
} from 'lucide-vue-next'
import { h, provide, ref } from 'vue'
import { provide, ref } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
@@ -94,20 +79,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
CardContainer,
CardTop,
CardBottom,
SquareChip,
Settings,
Upload,
Download,
Scroll,
Info,
Filter,
Folder,
Puzzle,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
X
SquareChip
},
setup() {
const t = (k: string) => k
@@ -121,7 +93,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
{
id: 'installed',
label: 'Installed',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--folder]'
},
{
title: 'TAGS',
@@ -129,17 +101,17 @@ const createStoryTemplate = (args: StoryArgs) => ({
{
id: 'tag-sd15',
label: 'SD 1.5',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--tag]'
}
]
},
@@ -149,12 +121,12 @@ const createStoryTemplate = (args: StoryArgs) => ({
{
id: 'cat-models',
label: 'Models',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--grid-3x3]'
}
]
}
@@ -205,7 +177,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
@@ -227,7 +199,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
<i class="icon-[lucide--upload] size-3" />
</template>
</IconTextButton>
@@ -239,7 +211,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
@@ -249,7 +221,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
<i class="icon-[lucide--scroll] size-3" />
</template>
</IconTextButton>
</template>
@@ -280,7 +252,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
<i class="icon-[lucide--filter] size-3" />
</template>
</SingleSelect>
</div>
@@ -301,7 +273,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
</template>
<template #bottom-right>
@@ -309,7 +281,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>
@@ -329,7 +301,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
@@ -351,7 +323,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
<i class="icon-[lucide--upload] size-3" />
</template>
</IconTextButton>
@@ -363,7 +335,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
@@ -373,7 +345,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
<i class="icon-[lucide--scroll] size-3" />
</template>
</IconTextButton>
</template>
@@ -401,7 +373,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
<i class="icon-[lucide--filter] size-3" />
</template>
</SingleSelect>
</div>
@@ -422,7 +394,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
</template>
<template #bottom-right>
@@ -430,7 +402,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>

View File

@@ -1,7 +1,5 @@
<template>
<span v-if="icon" class="text-xs text-neutral">
<component :is="icon" />
</span>
<i :class="icon" class="text-xs text-neutral" />
</template>
<script setup lang="ts">

View File

@@ -1,6 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, Folder, Grid3x3, Layers, Tag, Wrench } from 'lucide-vue-next'
import { h } from 'vue'
import NavItem from './NavItem.vue'
@@ -34,31 +32,6 @@ const meta: Meta<typeof NavItem> = {
export default meta
type Story = StoryObj<typeof meta>
export const Interactive: Story = {
args: {
icon: Folder,
active: false,
default: 'Navigation Item'
},
render: (args) => ({
components: { NavItem },
setup() {
const IconComponent = args.icon
const WrappedIcon = {
render() {
return h(IconComponent, { size: 14 })
}
}
return { args, WrappedIcon }
},
template: `
<NavItem :icon="WrappedIcon" :active="args.active" :on-click="() => {}">
{{ args.default }}
</NavItem>
`
})
}
export const InteractiveList: Story = {
render: () => ({
components: { NavItem },
@@ -67,7 +40,7 @@ export const InteractiveList: Story = {
<NavItem
v-for="item in items"
:key="item.id"
:icon="item.wrappedIcon"
:icon="item.icon"
:active="selectedId === item.id"
:on-click="() => selectedId = item.id"
>
@@ -85,32 +58,32 @@ export const InteractiveList: Story = {
{
id: 'downloads',
label: 'Downloads',
wrappedIcon: () => h(Download, { size: 14 })
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
wrappedIcon: () => h(Layers, { size: 14 })
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
wrappedIcon: () => h(Grid3x3, { size: 14 })
icon: 'icon-[lucide--grid-3x3]'
},
{
id: 'tags',
label: 'Tags',
wrappedIcon: () => h(Tag, { size: 14 })
icon: 'icon-[lucide--tag]'
},
{
id: 'settings',
label: 'Settings',
wrappedIcon: () => h(Wrench, { size: 14 })
icon: 'icon-[lucide--wrench]'
},
{
id: 'default',
label: 'Default Icon',
wrappedIcon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
}
]

View File

@@ -1,16 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Folder,
Grid3x3,
Layers,
Puzzle,
Settings,
Tag,
Wrench,
Zap
} from 'lucide-vue-next'
import { h, ref } from 'vue'
import { ref } from 'vue'
import LeftSidePanel from './LeftSidePanel.vue'
@@ -48,22 +37,22 @@ export const Default: Story = {
{
id: 'installed',
label: 'Installed',
icon: () => h(Download, { size: 14 })
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
icon: () => h(Layers, { size: 14 })
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
icon: () => h(Grid3x3, { size: 14 })
icon: 'icon-[lucide--grid-3x3]'
}
]
},
render: (args) => ({
components: { LeftSidePanel, Puzzle },
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
@@ -72,7 +61,7 @@ export const Default: Story = {
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Navigation</span>
@@ -90,7 +79,7 @@ export const WithGroups: Story = {
{
id: 'installed',
label: 'Installed',
icon: () => h(Download, { size: 14 })
icon: 'icon-[lucide--download]'
},
{
title: 'TAGS',
@@ -98,17 +87,17 @@ export const WithGroups: Story = {
{
id: 'tag-sd15',
label: 'SD 1.5',
icon: () => h(Tag, { size: 14 })
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: () => h(Tag, { size: 14 })
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: () => h(Tag, { size: 14 })
icon: 'icon-[lucide--tag]'
}
]
},
@@ -118,19 +107,19 @@ export const WithGroups: Story = {
{
id: 'cat-models',
label: 'Models',
icon: () => h(Layers, { size: 14 })
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: () => h(Grid3x3, { size: 14 })
icon: 'icon-[lucide--grid-3x3]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel, Puzzle },
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
@@ -139,7 +128,7 @@ export const WithGroups: Story = {
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Model Selector</span>
@@ -160,27 +149,27 @@ export const DefaultIcons: Story = {
{
id: 'home',
label: 'Home',
icon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
},
{
id: 'documents',
label: 'Documents',
icon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
},
{
id: 'downloads',
label: 'Downloads',
icon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
},
{
id: 'desktop',
label: 'Desktop',
icon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
}
]
},
render: (args) => ({
components: { LeftSidePanel, Folder },
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
@@ -189,7 +178,7 @@ export const DefaultIcons: Story = {
<div style="height: 400px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<Folder :size="16" class="text-neutral" />
<i class="icon-[lucide--folder] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Files</span>
@@ -207,12 +196,12 @@ export const LongLabels: Story = {
{
id: 'general',
label: 'General Settings',
icon: () => h(() => Wrench, { size: 14 })
icon: 'icon-[lucide--wrench]'
},
{
id: 'appearance',
label: 'Appearance & Themes Configuration',
icon: () => h(() => Wrench, { size: 14 })
icon: 'icon-[lucide--wrench]'
},
{
title: 'ADVANCED OPTIONS',
@@ -220,19 +209,19 @@ export const LongLabels: Story = {
{
id: 'performance',
label: 'Performance & Optimization Settings',
icon: () => h(() => Zap, { size: 14 })
icon: 'icon-[lucide--zap]'
},
{
id: 'experimental',
label: 'Experimental Features (Beta)',
icon: () => h(() => Puzzle, { size: 14 })
icon: 'icon-[lucide--puzzle]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel, Settings },
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
@@ -241,7 +230,7 @@ export const LongLabels: Story = {
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<Settings :size="16" class="text-neutral" />
<i class="icon-[lucide--settings] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Settings</span>

View File

@@ -977,8 +977,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library',
type: 'boolean',
tooltip:
'Use new asset API instead of experiment endpoints for model browsing',
tooltip: 'Use new Asset API for model browsing',
defaultValue: false,
experimental: true
}

View File

@@ -7,7 +7,7 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-[#15161A]',
'bg-white dark-theme:bg-charcoal-primary',
'min-w-[445px]',
'lg-node absolute border border-solid rounded-2xl',
'outline outline-transparent outline-2',
@@ -16,7 +16,8 @@
},
{
'border-blue-500 ring-2 ring-blue-300': isSelected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
'border-sand-primary dark-theme:border-charcoal-tertiary':
!isSelected,
'animate-pulse': executing,
'opacity-50': nodeData.mode === 4,
'border-red-500 bg-red-50': error,
@@ -226,7 +227,8 @@ const hasCustomContent = computed(() => {
})
// Computed classes and conditions for better reusability
const separatorClasses = 'bg-[#e1ded5] dark-theme:bg-[#292A30] h-[1px] mx-0'
const separatorClasses =
'bg-sand-primary dark-theme:bg-charcoal-tertiary h-[1px] mx-0'
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(

View File

@@ -0,0 +1,195 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { type PropType, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import enMessages from '@/locales/en/main.json'
import NodeSlots from './NodeSlots.vue'
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
id: '123',
title: 'Test Node',
type: 'TestType',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})
// Explicit stubs to capture props for assertions
interface StubSlotData {
name?: string
type?: string
boundingRect?: [number, number, number, number]
}
const InputSlotStub = defineComponent({
name: 'InputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-input-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const OutputSlotStub = defineComponent({
name: 'OutputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-output-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const mountSlots = (nodeData: VueNodeData, readonly = false) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(NodeSlots, {
global: {
plugins: [i18n, createPinia()],
stubs: {
InputSlot: InputSlotStub,
OutputSlot: OutputSlotStub
}
},
props: { nodeData, readonly }
})
}
describe('NodeSlots.vue', () => {
it('filters out inputs with widget property and maps indexes correctly', () => {
// Two inputs without widgets (object and string) and one with widget (filtered)
const inputObjNoWidget = {
name: 'objNoWidget',
type: 'number',
boundingRect: [0, 0, 0, 0]
}
const inputObjWithWidget = {
name: 'objWithWidget',
type: 'number',
boundingRect: [0, 0, 0, 0],
widget: { name: 'objWithWidget' }
}
const inputs = [inputObjNoWidget, inputObjWithWidget, 'stringInput']
const wrapper = mountSlots(makeNodeData({ inputs }))
const inputEls = wrapper
.findAll('.stub-input-slot')
.map((w) => w.element as HTMLElement)
// Should filter out the widget-backed input; expect 2 inputs rendered
expect(inputEls.length).toBe(2)
// Verify expected tuple of {index, name, nodeId}
const info = inputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(info).toEqual([
{
index: 0,
name: 'objNoWidget',
nodeId: '123',
type: 'number',
readonly: false
},
// string input is converted to object with default type 'any'
{
index: 1,
name: 'stringInput',
nodeId: '123',
type: 'any',
readonly: false
}
])
// Ensure widget-backed input was indeed filtered out
expect(wrapper.find('[data-name="objWithWidget"]').exists()).toBe(false)
})
it('maps outputs and passes correct indexes', () => {
const outputObj = { name: 'outA', type: 'any', boundingRect: [0, 0, 0, 0] }
const outputs = [outputObj, 'outB']
const wrapper = mountSlots(makeNodeData({ outputs }))
const outputEls = wrapper
.findAll('.stub-output-slot')
.map((w) => w.element as HTMLElement)
expect(outputEls.length).toBe(2)
const outInfo = outputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(outInfo).toEqual([
{ index: 0, name: 'outA', nodeId: '123', type: 'any', readonly: false },
// string output mapped to object with type 'any'
{ index: 1, name: 'outB', nodeId: '123', type: 'any', readonly: false }
])
})
it('renders nothing when there are no inputs/outputs', () => {
const wrapper = mountSlots(makeNodeData({ inputs: [], outputs: [] }))
expect(wrapper.findAll('.stub-input-slot').length).toBe(0)
expect(wrapper.findAll('.stub-output-slot').length).toBe(0)
})
it('passes readonly to child slots', () => {
const wrapper = mountSlots(
makeNodeData({ inputs: ['a'], outputs: ['b'] }),
/* readonly */ true
)
const all = [
...wrapper
.findAll('.stub-input-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement),
...wrapper
.findAll('.stub-output-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement)
]
expect(all.length).toBe(2)
for (const el of all) {
expect.soft(el.dataset.readonly).toBe('true')
}
})
})

View File

@@ -0,0 +1,39 @@
import { z } from 'zod'
// Zod schemas for asset API validation
const zAsset = z.object({
id: z.string(),
name: z.string(),
tags: z.array(z.string()),
size: z.number(),
created_at: z.string().optional()
})
const zAssetResponse = z.object({
assets: z.array(zAsset).optional(),
total: z.number().optional(),
has_more: z.boolean().optional()
})
const zModelFolder = z.object({
name: z.string(),
folders: z.array(z.string())
})
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetResponse = z.infer<typeof zAssetResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
// Common interfaces for API responses
export interface ModelFile {
name: string
pathIndex: number
}
export interface ModelFolderInfo {
name: string
folders: string[]
}

View File

@@ -30,6 +30,7 @@ import type {
User,
UserDataFullInfo
} from '@/schemas/apiSchema'
import type { ModelFile, ModelFolderInfo } from '@/schemas/assetSchema'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON,
@@ -675,15 +676,14 @@ export class ComfyApi extends EventTarget {
* Gets a list of model folder keys (eg ['checkpoints', 'loras', ...])
* @returns The list of model folder keys
*/
async getModelFolders(): Promise<{ name: string; folders: string[] }[]> {
async getModelFolders(): Promise<ModelFolderInfo[]> {
const res = await this.fetchApi(`/experiment/models`)
if (res.status === 404) {
return []
}
const folderBlacklist = ['configs', 'custom_nodes']
return (await res.json()).filter(
(folder: { name: string; folders: string[] }) =>
!folderBlacklist.includes(folder.name)
(folder: ModelFolderInfo) => !folderBlacklist.includes(folder.name)
)
}
@@ -692,9 +692,7 @@ export class ComfyApi extends EventTarget {
* @param {string} folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async getModels(
folder: string
): Promise<{ name: string; pathIndex: number }[]> {
async getModels(folder: string): Promise<ModelFile[]> {
const res = await this.fetchApi(`/experiment/models/${folder}`)
if (res.status === 404) {
return []

View File

@@ -1,63 +1,26 @@
import { fromZodError } from 'zod-validation-error'
import {
type AssetResponse,
type ModelFile,
type ModelFolder,
assetResponseSchema
} from '@/schemas/assetSchema'
import { api } from '@/scripts/api'
const ASSETS_ENDPOINT = '/assets'
const MODELS_TAG = 'models'
const MISSING_TAG = 'missing'
// Types for asset API responses
interface AssetResponse {
assets?: Asset[]
total?: number
has_more?: boolean
}
interface Asset {
id: string
name: string
tags: string[]
size: number
created_at?: string
}
/**
* Type guard for validating asset structure
* Validates asset response data using Zod schema
*/
function isValidAsset(asset: unknown): asset is Asset {
return (
asset !== null &&
typeof asset === 'object' &&
'id' in asset &&
'name' in asset &&
'tags' in asset &&
Array.isArray((asset as Asset).tags)
)
}
function validateAssetResponse(data: unknown): AssetResponse {
const result = assetResponseSchema.safeParse(data)
if (result.success) return result.data
/**
* Creates predicate for filtering assets by folder and excluding missing ones
*/
function createAssetFolderFilter(folder?: string) {
return (asset: unknown): asset is Asset => {
if (!isValidAsset(asset) || asset.tags.includes(MISSING_TAG)) {
return false
}
if (folder && !asset.tags.includes(folder)) {
return false
}
return true
}
}
/**
* Creates predicate for filtering folder assets (requires name)
*/
function createFolderAssetFilter(folder: string) {
return (asset: unknown): asset is Asset => {
if (!isValidAsset(asset) || !asset.name) {
return false
}
return asset.tags.includes(folder) && !asset.tags.includes(MISSING_TAG)
}
const error = fromZodError(result.error)
throw new Error(`Invalid asset response against zod schema:\n${error}`)
}
/**
@@ -66,7 +29,7 @@ function createFolderAssetFilter(folder: string) {
*/
function createAssetService() {
/**
* Handles API response with consistent error handling
* Handles API response with consistent error handling and Zod validation
*/
async function handleAssetRequest(
url: string,
@@ -78,7 +41,8 @@ function createAssetService() {
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
)
}
return await res.json()
const data = await res.json()
return validateAssetResponse(data)
}
/**
* Gets a list of model folder keys from the asset API
@@ -90,9 +54,7 @@ function createAssetService() {
*
* @returns The list of model folder keys
*/
async function getAssetModelFolders(): Promise<
{ name: string; folders: string[] }[]
> {
async function getAssetModelFolders(): Promise<ModelFolder[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
'model folders'
@@ -102,22 +64,17 @@ function createAssetService() {
const blacklistedDirectories = ['configs']
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>()
if (data?.assets) {
const directoryTags = data.assets
.filter(createAssetFolderFilter())
.flatMap((asset) => asset.tags)
.filter(
const discoveredFolders = new Set<string>(
data?.assets
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
?.flatMap((asset) => asset.tags)
?.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
)
for (const tag of directoryTags) {
discoveredFolders.add(tag)
}
}
) ?? []
)
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).sort()
const sortedFolders = Array.from(discoveredFolders).toSorted()
return sortedFolders.map((name) => ({ name, folders: [] }))
}
@@ -126,20 +83,23 @@ function createAssetService() {
* @param folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async function getAssetModels(
folder: string
): Promise<{ name: string; pathIndex: number }[]> {
async function getAssetModels(folder: string): Promise<ModelFile[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
`models for ${folder}`
)
return data?.assets
? data.assets.filter(createFolderAssetFilter(folder)).map((asset) => ({
return (
data?.assets
?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
)
?.map((asset) => ({
name: asset.name,
pathIndex: 0
}))
: []
})) ?? []
)
}
return {

View File

@@ -1,4 +1,5 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
@@ -14,6 +15,19 @@ export const useKeybindingService = () => {
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
// Helper function to determine if an event should be forwarded to canvas
const shouldForwardToCanvas = (event: KeyboardEvent): boolean => {
// Don't forward if modifier keys are pressed (except shift)
if (event.ctrlKey || event.altKey || event.metaKey) {
return false
}
// Keys that LiteGraph handles but aren't in core keybindings
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
const keybindHandler = async function (event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
if (keyCombo.isModifier) {
@@ -26,6 +40,7 @@ export const useKeybindingService = () => {
keyCombo.isReservedByTextInput &&
(target.tagName === 'TEXTAREA' ||
target.tagName === 'INPUT' ||
target.contentEditable === 'true' ||
(target.tagName === 'SPAN' &&
target.classList.contains('property_value')))
) {
@@ -53,6 +68,20 @@ export const useKeybindingService = () => {
return
}
// Forward unhandled canvas-targeted events to LiteGraph
if (!keybinding && shouldForwardToCanvas(event)) {
const canvas = app.canvas
if (
canvas &&
canvas.processKey &&
typeof canvas.processKey === 'function'
) {
// Let LiteGraph handle the event
canvas.processKey(event)
return
}
}
// Only clear dialogs if not using modifiers
if (event.ctrlKey || event.altKey || event.metaKey) {
return

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ModelFile } from '@/schemas/assetSchema'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
import { useSettingStore } from '@/stores/settingStore'
@@ -157,9 +158,7 @@ export class ModelFolder {
constructor(
public directory: string,
private getModelsFunc: (
folder: string
) => Promise<{ name: string; pathIndex: number }[]>
private getModelsFunc: (folder: string) => Promise<ModelFile[]>
) {}
get key(): string {

View File

@@ -1,9 +1,7 @@
import { DefineComponent, FunctionalComponent } from 'vue'
export interface NavItemData {
id: string
label: string
icon: DefineComponent | FunctionalComponent
icon: string
}
export interface NavGroupData {

View File

@@ -1,3 +1,4 @@
import lucide from '@iconify-json/lucide/icons.json'
import { addDynamicIconSelectors } from '@iconify/tailwind'
import { iconCollection } from './build/customIconCollection'
@@ -188,7 +189,72 @@ export default {
800: '#b38400',
900: '#997200',
950: '#664d00'
}
},
// Base colors
'pure-black': '#000000',
'pure-white': '#FFFFFF',
// Charcoal palette
'charcoal-100': '#171718',
'charcoal-200': '#202121',
'charcoal-300': '#262729',
'charcoal-400': '#2D2E32',
'charcoal-500': '#313235',
'charcoal-600': '#3C3D42',
'charcoal-700': '#494A50',
'charcoal-800': '#55565E',
// Stone palette
'stone-100': '#444444',
'stone-200': '#828282',
'stone-300': '#BBBBBB',
// Ivory palette
'ivory-100': '#FDFBFA',
'ivory-200': '#FAF9F5',
'ivory-300': '#F0EEE6',
// Gray palette
'gray-100': '#F3F3F3',
'gray-200': '#E9E9E9',
'gray-300': '#E1E1E1',
'gray-400': '#D9D9D9',
'gray-500': '#C5C5C5',
'gray-600': '#B4B4B4',
'gray-700': '#A0A0A0',
'gray-800': '#8A8A8A',
// Sand palette
'sand-100': '#E1DED5',
'sand-200': '#D6CFC2',
'sand-300': '#888682',
// Slate palette
'slate-100': '#9C9EAB',
'slate-200': '#9FA2BD',
'slate-300': '#5B5E7D',
// Brand colors
'brand-yellow': '#F0FF41',
'brand-blue': '#172DD7',
// Functional colors
'blue-100': '#0B8CE9',
'blue-200': '#31B9F4',
'success-100': '#00CD72',
'success-200': '#47E469',
'warning-100': '#FD9903',
'warning-200': '#FCBF64',
'danger-100': '#C02323',
'danger-200': '#D62952',
error: '#962A2A',
// Alpha variants
'blue-selection': 'rgba(11, 140, 233, 0.3)',
'node-hover-100': 'rgba(85, 86, 94, 0.15)',
'node-hover-200': 'rgba(85, 86, 94, 0.1)',
'modal-tag': 'rgba(217, 217, 217, 0.4)'
},
textColor: {
@@ -235,8 +301,10 @@ export default {
plugins: [
addDynamicIconSelectors({
iconSets: {
comfy: iconCollection
}
comfy: iconCollection,
lucide
},
prefix: 'icon'
}),
function ({ addVariant }) {
addVariant('dark-theme', '.dark-theme &')

View File

@@ -83,19 +83,20 @@ describe('assetService', () => {
expect(folderNames).not.toContain('configs')
})
it('should handle errors and empty responses', async () => {
// Empty response
it('should handle empty responses', async () => {
mockApiResponse([])
const emptyResult = await assetService.getAssetModelFolders()
expect(emptyResult).toHaveLength(0)
})
// Network error
it('should handle network errors', async () => {
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Network error'
)
})
// HTTP error
it('should handle HTTP errors', async () => {
mockApiError(500)
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Unable to load model folders: Server returned 500. Please try again.'
@@ -107,7 +108,6 @@ describe('assetService', () => {
it('should return filtered models for folder', async () => {
const assets = [
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
{ ...MOCK_ASSETS.checkpoints, name: undefined }, // Invalid name
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
{
id: 'uuid-4',

View File

@@ -0,0 +1,182 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
// Mock the app and canvas using factory functions
vi.mock('@/scripts/app', () => {
return {
app: {
canvas: {
processKey: vi.fn()
}
}
}
})
// Mock stores
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
dialogStack: []
}))
}))
// Test utility for creating keyboard events with mocked methods
function createTestKeyboardEvent(
key: string,
options: {
target?: Element
ctrlKey?: boolean
altKey?: boolean
metaKey?: boolean
} = {}
): KeyboardEvent {
const {
target = document.body,
ctrlKey = false,
altKey = false,
metaKey = false
} = options
const event = new KeyboardEvent('keydown', {
key,
ctrlKey,
altKey,
metaKey,
bubbles: true,
cancelable: true
})
// Mock event methods
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [target])
return event
}
describe('keybindingService - Event Forwarding', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let mockCommandExecute: ReturnType<typeof vi.fn>
let mockProcessKey: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Get reference to mocked function
mockProcessKey = vi.mocked(app.canvas.processKey)
// Mock command store execute
mockCommandExecute = vi.fn()
const commandStore = useCommandStore()
commandStore.execute = mockCommandExecute
// Reset dialog store mock to empty
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: []
} as any)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
it('should forward Delete key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
// Should forward to canvas processKey
expect(mockProcessKey).toHaveBeenCalledWith(event)
// Should not execute any command
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should forward Backspace key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Backspace')
await keybindingService.keybindHandler(event)
expect(mockProcessKey).toHaveBeenCalledWith(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should not forward Delete key when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = createTestKeyboardEvent('Delete', { target: inputElement })
await keybindingService.keybindHandler(event)
// Should not forward to canvas when in input field
expect(mockProcessKey).not.toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should not forward Delete key when typing in textarea', async () => {
const textareaElement = document.createElement('textarea')
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
await keybindingService.keybindHandler(event)
expect(mockProcessKey).not.toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should not forward Delete key when canvas processKey is not available', async () => {
// Temporarily replace processKey with undefined
const originalProcessKey = vi.mocked(app.canvas).processKey
vi.mocked(app.canvas).processKey = undefined as any
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
})
it('should not forward Delete key when canvas is not available', async () => {
// Temporarily set canvas to null
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null as any
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
})
it('should not forward non-canvas keys', async () => {
const event = createTestKeyboardEvent('Enter')
await keybindingService.keybindHandler(event)
// Should not forward Enter key
expect(mockProcessKey).not.toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should not forward when modifier keys are pressed', async () => {
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
await keybindingService.keybindHandler(event)
// Should not forward when modifiers are pressed
expect(mockProcessKey).not.toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
})
})

View File

@@ -38,7 +38,9 @@ function enableMocks(useAssetAPI = false) {
return false
})
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useSettingStore, { partial: true }).mockReturnValue(
mockSettingStore
)
// Mock experimental API - returns objects with name and folders properties
vi.mocked(api.getModels).mockResolvedValue([

View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2023", "ES2023.Array", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"incremental": true,
"sourceMap": true,

View File

@@ -20,11 +20,19 @@ export default defineConfig({
retry: process.env.CI ? 2 : 0,
include: [
'tests-ui/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'src/components/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
],
coverage: {
reporter: ['text', 'json', 'html']
}
},
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
'src/lib/litegraph/test/**'
]
},
resolve: {
alias: {