Compare commits

..

34 Commits

Author SHA1 Message Date
Chenlei Hu
39eeda8430 1.2.62 (#922) 2024-09-22 16:40:00 +09:00
Chenlei Hu
2878952b1d Makes forceInput node input slot correctly reflect option/required state (#921)
* Correctly style optional force input input slot

* Add force input playwright test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-22 16:35:56 +09:00
pythongosssss
223a1f677b Fix links being lost after manage group node (#916)
* Fix links being lost after manage group node

* Change to use groupnodebuilder

* Make test more reliable
2024-09-22 16:17:39 +09:00
Chenlei Hu
7b4b40db5b Update litegraph (Slot style) (#919)
* Update litegraph (Slot style)

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-22 16:15:39 +09:00
Chenlei Hu
1052603a17 Revert "Any keyboard layout for Ctrl + V, Z, Y... (#763)" (#920)
This reverts commit 23796d9040.
2024-09-22 16:14:59 +09:00
pythongosssss
4ee1b23e9b Exclude litegraph from being cached (#918) 2024-09-22 15:25:52 +09:00
bymyself
326e0748c0 Add node opacity setting (#909)
* Add node opacity setting

* Add colorUtil unit test

* Add playwright test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-22 15:18:38 +09:00
ArtificialLab
ea0f74a9f6 Cleanup (#915)
* (update) cleanup:
- move reflect to main.ts
- add config.ts with comfy frontend version
- cleanup index.html and App.vue

* (fix) lint doesn't like branch assignments

* (fix) properly add __COMFYUI_FRONTEND_VERSION__ to ts globals
2024-09-22 10:12:54 +04:00
Chenlei Hu
cdaac0d9bb 1.2.61 (#913) 2024-09-22 12:14:13 +09:00
Chenlei Hu
f749734863 Make optional node input's slot hollow circle (#912)
* Use hollow circle for optional input

* nit

* Show hollow shape for optional input

* Add playwright tests

* Update litegraph

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-22 12:12:48 +09:00
bymyself
a15c4d1612 Fix audio widget serialize option (#910) 2024-09-22 11:54:07 +09:00
bymyself
290bf52fc5 Fix frontend node tooltip error (#911) 2024-09-22 11:52:35 +09:00
dmx
529e889d0e (move) treenode style to style.css 2024-09-22 06:09:56 +04:00
dmx
5a5a69de17 (UI) NodeTree 2024-09-22 06:04:45 +04:00
dmx
194549a4b0 (clean) CSS in App.vue 2024-09-22 06:03:54 +04:00
bymyself
4052fc55f3 Fix node preview styles (#903)
* Use colorPalette to style node previews

* Use widget text secondary color for description

* Remove unused css

* nit

---------

Co-authored-by: huchenlei <chenlei.hu@mail.utoronto.ca>
2024-09-22 09:05:06 +09:00
Chenlei Hu
82d03b5c1b Add colorPalette cleanup for playwright test (#907) 2024-09-22 08:53:01 +09:00
filtered
c7f123766e Add TS types / merge ComfyLGraphNode (#902)
* Add TS type for LGraphNodeConstructor

* Add TS type & move shared prop to parent

* Add TS types - Comfy augmentations

* nit - TS type

* Merge ComfyLGNode into existing augmentations

* nit - fix missed explicit type on import
2024-09-21 18:18:27 +09:00
filtered
88acabb355 Fix TS type on InputSpec (#901) 2024-09-21 14:12:39 +09:00
Chenlei Hu
e5f1eb8609 Update browser tests README (#900) 2024-09-21 10:49:29 +09:00
Chenlei Hu
eb7ab0860d 1.2.60 (#896) 2024-09-20 19:52:09 +09:00
Chenlei Hu
9ed3545b95 Fix frontend node conflicting with node badge (#895) 2024-09-20 19:45:34 +09:00
Chenlei Hu
d223f3865b 1.2.59 (#892) 2024-09-20 09:25:50 +09:00
Chenlei Hu
4538db86cf Add node execution progress to browser title (#891)
* Add node execution progress to browser title

* nit

* nit
2024-09-20 09:22:09 +09:00
dependabot[bot]
3931cae044 Bump vite from 5.3.3 to 5.4.6 (#889)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.3.3 to 5.4.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 20:11:34 +09:00
Chenlei Hu
810a63f808 Support async hooks in TreeExplorerNode (#888)
* Support async hooks in TreeExplorerNode

* rebase

* nit

* Fix component test failure

* Add edit vitest

* Add more tests

* Add component test
2024-09-19 20:10:43 +09:00
Chenlei Hu
609984d400 No selection on tree node if selectionKeys prop is not set (#887) 2024-09-19 16:48:56 +09:00
Chenlei Hu
a57c958058 Bind extra context menu items on TreeExplorerNode interface (#886) 2024-09-19 14:51:07 +09:00
Chenlei Hu
b6dbe8f07b Shorten node source package name by remove ComfyUI prefix/suffix (#883)
* Shorten node source package name by remove ComfyUI prefix/suffix

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-19 12:40:05 +09:00
Chenlei Hu
29d69338ef 1.2.58 (#882) 2024-09-19 12:00:45 +09:00
Chenlei Hu
98de010811 Fix node searchbox filter removal (#881) 2024-09-19 11:58:29 +09:00
Chenlei Hu
63302a6634 Fix sorting on type filter + empty query (#880)
* Fix sorting on type filter + empty query

* nit

* nit
2024-09-19 11:22:23 +09:00
Chenlei Hu
8568e037bf Sort search result by node frequency (#879)
* Sort search result by node frequency

* Fix jest test
2024-09-19 10:09:54 +09:00
Chenlei Hu
6c4143ca94 Show node by frequency on empty query (#878) 2024-09-19 09:35:22 +09:00
81 changed files with 4259 additions and 425 deletions

View File

@@ -4,6 +4,8 @@ import dotenv from 'dotenv'
dotenv.config()
import * as fs from 'fs'
import { NodeBadgeMode } from '../src/types/nodeSource'
import { NodeId } from '../src/types/comfyWorkflow'
import { ManageGroupNode } from './helpers/manageGroupNode'
interface Position {
x: number
@@ -15,9 +17,37 @@ interface Size {
height: number
}
class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
async selectFilterType(filterType: string) {
await this.page
.locator(
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
)
.click()
}
async selectFilterValue(filterValue: string) {
await this.page.locator('.filter-value-select .p-select-dropdown').click()
await this.page
.locator(
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue)
await this.page.locator('.p-button-label:has-text("Add")').click()
}
}
class ComfyNodeSearchBox {
public readonly input: Locator
public readonly dropdown: Locator
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
constructor(public readonly page: Page) {
this.input = page.locator(
@@ -26,6 +56,11 @@ class ComfyNodeSearchBox {
this.dropdown = page.locator(
'.comfy-vue-node-search-container .p-autocomplete-list'
)
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
}
get filterButton() {
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
}
async fillAndSelectFirstNode(
@@ -43,6 +78,21 @@ class ComfyNodeSearchBox {
.nth(options?.suggestionIndex || 0)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.filterButton.click()
await this.filterSelectionPanel.addFilter(filterValue, filterType)
}
get filterChips() {
return this.page.locator(
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
)
}
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
}
}
class NodeLibrarySidebarTab {
@@ -659,6 +709,177 @@ export class ComfyPage {
await this.page.getByText('Convert to Group Node').click()
await this.nextFrame()
}
async convertOffsetToCanvas(pos: [number, number]) {
return this.page.evaluate((pos) => {
return window['app'].canvas.ds.convertOffsetToCanvas(pos)
}, pos)
}
async getNodeRefById(id: NodeId) {
return new NodeReference(id, this)
}
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
return (
await this.page.evaluate((type) => {
return window['app'].graph._nodes
.filter((n) => n.type === type)
.map((n) => n.id)
}, type)
).map((id: NodeId) => this.getNodeRefById(id))
}
}
class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
readonly node: NodeReference
) {}
async getPosition() {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
)
},
[this.type, this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
}
return node.outputs[index].links?.length ?? 0
},
[this.type, this.node.id, this.index] as const
)
}
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
} else {
node.disconnectOutput(index)
}
},
[this.type, this.node.id, this.index] as const
)
}
}
class NodeReference {
constructor(
readonly id: NodeId,
readonly comfyPage: ComfyPage
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return !!node
}, this.id)
}
getType(): Promise<string> {
return this.getProperty('type')
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
)
return {
x: pos[0],
y: pos[1]
}
}
async getSize(): Promise<Size> {
const size = await this.getProperty<[number, number]>('size')
return {
width: size[0],
height: size[1]
}
}
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
[this.id, prop] as const
)
}
async getOutput(index: number) {
return new NodeSlotReference('output', index, this)
}
async getInput(index: number) {
return new NodeSlotReference('input', index, this)
}
async click(position: 'title', options?: Parameters<Page['click']>[1]) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y + 15 }
break
default:
throw new Error(`Invalid click position ${position}`)
}
await this.comfyPage.canvas.click({
...options,
position: clickPos
})
await this.comfyPage.nextFrame()
}
async connectOutput(
originSlotIndex: number,
targetNode: NodeReference,
targetSlotIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetSlot = await targetNode.getInput(targetSlotIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetSlot.getPosition()
)
return originSlot
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
this.comfyPage.page.once('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.getNodeRefsByType(
`workflow/${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
return new ManageGroupNode(
this.comfyPage.page,
this.comfyPage.page.locator('.comfy-group-manage')
)
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({

View File

@@ -2,8 +2,16 @@
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
## WARNING
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
Please backup your ComfyUI data before running the tests locally.
## Setup
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing.
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
```bash

View File

@@ -0,0 +1,62 @@
{
"last_node_id": 5,
"last_link_id": 0,
"nodes": [
{
"id": 5,
"type": "DevToolsNodeWithForceInput",
"pos": {
"0": 9,
"1": 39
},
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "int_input",
"type": "INT",
"link": null,
"widget": {
"name": "int_input"
}
},
{
"name": "float_input",
"type": "FLOAT",
"link": null,
"widget": {
"name": "float_input"
},
"shape": 7
}
],
"outputs": [],
"properties": {
"Node name for S&R": "DevToolsNodeWithForceInput"
},
"widgets_values": [
0,
1,
0
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,57 @@
{
"last_node_id": 5,
"last_link_id": 0,
"nodes": [
{
"id": 5,
"type": "DevToolsNodeWithOptionalInput",
"pos": {
"0": 19,
"1": 46
},
"size": {
"0": 302.4000244140625,
"1": 46
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "required_input",
"type": "IMAGE",
"link": null
},
{
"name": "optional_input",
"type": "IMAGE",
"link": null,
"shape": 7
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsNodeWithOptionalInput"
}
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,56 @@
{
"last_node_id": 5,
"last_link_id": 0,
"nodes": [
{
"id": 5,
"type": "DevToolsNodeWithOptionalInput",
"pos": {
"0": 19,
"1": 46
},
"size": {
"0": 302.4000244140625,
"1": 46
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "required_input",
"type": "IMAGE",
"link": null
},
{
"name": "optional_input",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsNodeWithOptionalInput"
}
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,57 @@
{
"last_node_id": 5,
"last_link_id": 0,
"nodes": [
{
"id": 5,
"type": "DevToolsNodeWithOptionalInput",
"pos": {
"0": 19,
"1": 46
},
"size": {
"0": 302.4000244140625,
"1": 46
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "required_input",
"type": "IMAGE",
"link": null
},
{
"name": "optional_input",
"type": "IMAGE",
"link": null,
"shape": 6
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsNodeWithOptionalInput"
}
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -135,15 +135,35 @@ test.describe('Color Palette', () => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', {})
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
})
test('Can show custom color palette', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
// Reset to default color palette for other tests
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
})
test('Can change node opacity setting', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.page.waitForTimeout(128)
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.waitForTimeout(128)
await comfyPage.page.mouse.move(8, 8)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -53,4 +53,40 @@ test.describe('Group Node', () => {
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
}
const latent = await expectSingleNode('EmptyLatentImage')
const sampler = await expectSingleNode('KSampler')
// Remove existing link
const samplerInput = await sampler.getInput(0)
await samplerInput.removeLinks()
// Group latent + sampler
await latent.click('title', {
modifiers: ['Shift']
})
await sampler.click('title', {
modifiers: ['Shift']
})
const groupNode = await sampler.convertToGroupNode()
// Connect node to group
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
const input = await ckpt.connectOutput(0, groupNode, 0)
expect(await input.getLinkCount()).toBe(1)
// Modify the group node via manage dialog
const manage = await groupNode.manageGroupNode()
await manage.selectNode('KSampler')
await manage.changeTab('Inputs')
await manage.setLabel('model', 'test')
await manage.save()
await manage.close()
// Ensure the link is still present
expect(await input.getLinkCount()).toBe(1)
})
})

View File

@@ -0,0 +1,37 @@
import { Locator, Page } from '@playwright/test'
export class ManageGroupNode {
footer: Locator
constructor(
readonly page: Page,
readonly root: Locator
) {
this.footer = root.locator('footer')
}
async setLabel(name: string, label: string) {
const active = this.root.locator('.comfy-group-manage-node-page.active')
const input = active.getByPlaceholder(name)
await input.fill(label)
}
async save() {
await this.footer.getByText('Save').click()
}
async close() {
await this.footer.getByText('Close').click()
}
async selectNode(name: string) {
const list = this.root.locator('.comfy-group-manage-list-items')
const item = list.getByText(name)
await item.click()
}
async changeTab(name: 'Inputs' | 'Widgets' | 'Outputs') {
const header = this.root.locator('.comfy-group-manage-node header')
const tab = header.getByText(name)
await tab.click()
}
}

View File

@@ -9,7 +9,6 @@ test.describe('Node Badge', () => {
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph.nodes
for (const node of nodes) {
@@ -27,7 +26,6 @@ test.describe('Node Badge', () => {
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph.nodes
for (const node of nodes) {
@@ -48,7 +46,6 @@ test.describe('Node Badge', () => {
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph.nodes
for (const node of nodes) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,26 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.
test.describe('Optional input', () => {
test('No shape specified', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('optional_input_no_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Wrong shape specified', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('optional_input_wrong_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Correct shape specified', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('optional_input_correct_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Force input', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('force_input')
await expect(comfyPage.canvas).toHaveScreenshot('force_input.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -103,6 +103,30 @@ test.describe('Node search box', () => {
await comfyPage.page.waitForTimeout(256)
await expect(comfyPage.searchBox.input).not.toHaveCount(0)
})
test.describe('Filtering', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
})
test('Can add filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await expect(comfyPage.searchBox.filterChips).toHaveCount(1)
})
test('Can add multiple filters', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
await expect(comfyPage.searchBox.filterChips).toHaveCount(2)
})
test('Can remove filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
await comfyPage.searchBox.removeFilter(0)
await expect(comfyPage.searchBox.filterChips).toHaveCount(1)
})
})
})
test.describe('Release context menu', () => {

View File

@@ -14,7 +14,14 @@ export default [
'src/types/vue-shim.d.ts'
]
},
{ languageOptions: { globals: globals.browser } },
{
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
}
}
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/essential'],

1
global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const __COMFYUI_FRONTEND_VERSION__: string

View File

@@ -4,21 +4,6 @@
<meta charset="UTF-8">
<title>ComfyUI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<!-- Browser Test Fonts -->
<!-- <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}
</style> -->
<script type="module" src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module">
import 'reflect-metadata';
window["__COMFYUI_FRONTEND_VERSION__"] = __COMFYUI_FRONTEND_VERSION__;
console.log("ComfyUI Front-end version:", __COMFYUI_FRONTEND_VERSION__);
</script>
<script type="module" src="src/main.ts"></script>
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
</head>
@@ -51,5 +36,7 @@
</form>
</main>
</div>
<script type="module" src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module" src="src/main.ts"></script>
</body>
</html>

246
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "comfyui-frontend",
"version": "1.2.57",
"version": "1.2.62",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.2.57",
"version": "1.2.62",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.75",
"@comfyorg/litegraph": "^0.7.77",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",
@@ -33,6 +33,7 @@
"@babel/preset-env": "^7.22.20",
"@eslint/js": "^9.8.0",
"@iconify/json": "^2.2.245",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
@@ -63,7 +64,7 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.2.0",
"vite": "^5.4.6",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.0.5",
"zip-dir": "^2.0.0"
@@ -1909,9 +1910,9 @@
"dev": true
},
"node_modules/@comfyorg/litegraph": {
"version": "0.7.75",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.75.tgz",
"integrity": "sha512-RNYZVMoJ/a5btwP+S124FnrIVlwOdv6uNsTdYfwv7L8teDpwvf/TQa66QfCePqUlypBKEhKw+avTncLAu2FYUw==",
"version": "0.7.77",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.77.tgz",
"integrity": "sha512-R86ZK/7pvqPqNw6XrCx1dn00Fj9QDrPCfDQNHL+QiQ8BP3jH6pfBQW0ZBhaX4Mj/ALqoHRZ2VB6RZaxZ7sJ5Lg==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -3350,6 +3351,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/@pinia/testing": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-0.1.5.tgz",
"integrity": "sha512-AcGzuotkzhRoF00htuxLfIPBBHVE6HjjB3YC5Y3os8vRgKu6ipknK5GBQq9+pduwYQhZ+BcCZDC9TyLAUlUpoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"pinia": ">=2.2.1"
}
},
"node_modules/@pinia/testing/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3460,9 +3504,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
"integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.0.tgz",
"integrity": "sha512-/IZQvg6ZR0tAkEi4tdXOraQoWeJy9gbQ/cx4I7k9dJaCk9qrXEcdouxRVz5kZXt5C2bQ9pILoAA+KB4C/d3pfw==",
"cpu": [
"arm"
],
@@ -3472,9 +3516,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz",
"integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.0.tgz",
"integrity": "sha512-ETHi4bxrYnvOtXeM7d4V4kZWixib2jddFacJjsOjwbgYSRsyXYtZHC4ht134OsslPIcnkqT+TKV4eU8rNBKyyQ==",
"cpu": [
"arm64"
],
@@ -3484,9 +3528,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz",
"integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.0.tgz",
"integrity": "sha512-ZWgARzhSKE+gVUX7QWaECoRQsPwaD8ZR0Oxb3aUpzdErTvlEadfQpORPXkKSdKbFci9v8MJfkTtoEHnnW9Ulng==",
"cpu": [
"arm64"
],
@@ -3496,9 +3540,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz",
"integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.0.tgz",
"integrity": "sha512-h0ZAtOfHyio8Az6cwIGS+nHUfRMWBDO5jXB8PQCARVF6Na/G6XS2SFxDl8Oem+S5ZsHQgtsI7RT4JQnI1qrlaw==",
"cpu": [
"x64"
],
@@ -3508,9 +3552,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz",
"integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.0.tgz",
"integrity": "sha512-9pxQJSPwFsVi0ttOmqLY4JJ9pg9t1gKhK0JDbV1yUEETSx55fdyCjt39eBQ54OQCzAF0nVGO6LfEH1KnCPvelA==",
"cpu": [
"arm"
],
@@ -3520,9 +3564,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz",
"integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.0.tgz",
"integrity": "sha512-YJ5Ku5BmNJZb58A4qSEo3JlIG4d3G2lWyBi13ABlXzO41SsdnUKi3HQHe83VpwBVG4jHFTW65jOQb8qyoR+qzg==",
"cpu": [
"arm"
],
@@ -3532,9 +3576,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz",
"integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.0.tgz",
"integrity": "sha512-U4G4u7f+QCqHlVg1Nlx+qapZy+QoG+NV6ux+upo/T7arNGwKvKP2kmGM4W5QTbdewWFgudQxi3kDNST9GT1/mg==",
"cpu": [
"arm64"
],
@@ -3544,9 +3588,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz",
"integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.0.tgz",
"integrity": "sha512-aQpNlKmx3amwkA3a5J6nlXSahE1ijl0L9KuIjVOUhfOh7uw2S4piR3mtpxpRtbnK809SBtyPsM9q15CPTsY7HQ==",
"cpu": [
"arm64"
],
@@ -3556,9 +3600,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz",
"integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.0.tgz",
"integrity": "sha512-9fx6Zj/7vve/Fp4iexUFRKb5+RjLCff6YTRQl4CoDhdMfDoobWmhAxQWV3NfShMzQk1Q/iCnageFyGfqnsmeqQ==",
"cpu": [
"ppc64"
],
@@ -3568,9 +3612,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz",
"integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.0.tgz",
"integrity": "sha512-VWQiCcN7zBgZYLjndIEh5tamtnKg5TGxyZPWcN9zBtXBwfcGSZ5cHSdQZfQH/GB4uRxk0D3VYbOEe/chJhPGLQ==",
"cpu": [
"riscv64"
],
@@ -3580,9 +3624,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz",
"integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.0.tgz",
"integrity": "sha512-EHmPnPWvyYqncObwqrosb/CpH3GOjE76vWVs0g4hWsDRUVhg61hBmlVg5TPXqF+g+PvIbqkC7i3h8wbn4Gp2Fg==",
"cpu": [
"s390x"
],
@@ -3592,9 +3636,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz",
"integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.0.tgz",
"integrity": "sha512-tsSWy3YQzmpjDKnQ1Vcpy3p9Z+kMFbSIesCdMNgLizDWFhrLZIoN21JSq01g+MZMDFF+Y1+4zxgrlqPjid5ohg==",
"cpu": [
"x64"
],
@@ -3604,9 +3648,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz",
"integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.0.tgz",
"integrity": "sha512-anr1Y11uPOQrpuU8XOikY5lH4Qu94oS6j0xrulHk3NkLDq19MlX8Ng/pVipjxBJ9a2l3+F39REZYyWQFkZ4/fw==",
"cpu": [
"x64"
],
@@ -3616,9 +3660,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz",
"integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.0.tgz",
"integrity": "sha512-7LB+Bh+Ut7cfmO0m244/asvtIGQr5pG5Rvjz/l1Rnz1kDzM02pSX9jPaS0p+90H5I1x4d1FkCew+B7MOnoatNw==",
"cpu": [
"arm64"
],
@@ -3628,9 +3672,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz",
"integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.0.tgz",
"integrity": "sha512-+3qZ4rer7t/QsC5JwMpcvCVPRcJt1cJrYS/TMJZzXIJbxWFQEVhrIc26IhB+5Z9fT9umfVc+Es2mOZgl+7jdJQ==",
"cpu": [
"ia32"
],
@@ -3640,9 +3684,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz",
"integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.0.tgz",
"integrity": "sha512-YdicNOSJONVx/vuPkgPTyRoAPx3GbknBZRCOUkK84FJ/YTfs/F0vl/YsMscrB6Y177d+yDRcj+JWMPMCgshwrA==",
"cpu": [
"x64"
],
@@ -10036,9 +10080,9 @@
}
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -10074,12 +10118,13 @@
}
},
"node_modules/pinia": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz",
"integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.2.2.tgz",
"integrity": "sha512-ja2XqFWZC36mupU4z1ZzxeTApV7DOw44cV4dhQ9sGwun+N89v/XP7+j7q6TanS1u1tdbK4r+1BUx7heMaIdagA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.5.0",
"vue-demi": ">=0.14.5"
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
@@ -10099,10 +10144,11 @@
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
@@ -10200,9 +10246,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"funding": [
{
"type": "opencollective",
@@ -10219,8 +10265,8 @@
],
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
"picocolors": "^1.1.0",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -10722,9 +10768,9 @@
"dev": true
},
"node_modules/rollup": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz",
"integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.0.tgz",
"integrity": "sha512-W21MUIFPZ4+O2Je/EU+GP3iz7PH4pVPUXSbEZdatQnxo29+3rsUjgrJmzuAZU24z7yRAnFN6ukxeAhZh/c7hzg==",
"dependencies": {
"@types/estree": "1.0.5"
},
@@ -10736,22 +10782,22 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.18.0",
"@rollup/rollup-android-arm64": "4.18.0",
"@rollup/rollup-darwin-arm64": "4.18.0",
"@rollup/rollup-darwin-x64": "4.18.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.18.0",
"@rollup/rollup-linux-arm-musleabihf": "4.18.0",
"@rollup/rollup-linux-arm64-gnu": "4.18.0",
"@rollup/rollup-linux-arm64-musl": "4.18.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.18.0",
"@rollup/rollup-linux-riscv64-gnu": "4.18.0",
"@rollup/rollup-linux-s390x-gnu": "4.18.0",
"@rollup/rollup-linux-x64-gnu": "4.18.0",
"@rollup/rollup-linux-x64-musl": "4.18.0",
"@rollup/rollup-win32-arm64-msvc": "4.18.0",
"@rollup/rollup-win32-ia32-msvc": "4.18.0",
"@rollup/rollup-win32-x64-msvc": "4.18.0",
"@rollup/rollup-android-arm-eabi": "4.22.0",
"@rollup/rollup-android-arm64": "4.22.0",
"@rollup/rollup-darwin-arm64": "4.22.0",
"@rollup/rollup-darwin-x64": "4.22.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.22.0",
"@rollup/rollup-linux-arm-musleabihf": "4.22.0",
"@rollup/rollup-linux-arm64-gnu": "4.22.0",
"@rollup/rollup-linux-arm64-musl": "4.22.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.22.0",
"@rollup/rollup-linux-riscv64-gnu": "4.22.0",
"@rollup/rollup-linux-s390x-gnu": "4.22.0",
"@rollup/rollup-linux-x64-gnu": "4.22.0",
"@rollup/rollup-linux-x64-musl": "4.22.0",
"@rollup/rollup-win32-arm64-msvc": "4.22.0",
"@rollup/rollup-win32-ia32-msvc": "4.22.0",
"@rollup/rollup-win32-x64-msvc": "4.22.0",
"fsevents": "~2.3.2"
}
},
@@ -10904,9 +10950,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
@@ -11945,13 +11991,13 @@
}
},
"node_modules/vite": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz",
"integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==",
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz",
"integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.39",
"rollup": "^4.13.0"
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
@@ -11970,6 +12016,7 @@
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
@@ -11987,6 +12034,9 @@
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.2.57",
"version": "1.2.62",
"type": "module",
"scripts": {
"dev": "vite",
@@ -25,6 +25,7 @@
"@babel/preset-env": "^7.22.20",
"@eslint/js": "^9.8.0",
"@iconify/json": "^2.2.245",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
@@ -55,14 +56,14 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.2.0",
"vite": "^5.4.6",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.0.5",
"zip-dir": "^2.0.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.75",
"@comfyorg/litegraph": "^0.7.77",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",

1
public/assets/CREDIT.txt Normal file
View File

@@ -0,0 +1 @@
Thanks to OpenArt (https://openart.ai) for providing the sorted-custom-node-map data, captured in September 2024.

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,9 @@
<template>
<router-view />
<ProgressSpinner v-if="isLoading" class="spinner"></ProgressSpinner>
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex justify-center items-center h-screen"
/>
<BlockUI full-screen :blocked="isLoading" />
<GlobalDialog />
<GlobalToast />
@@ -17,31 +20,39 @@ import {
watch,
watchEffect
} from 'vue'
import config from '@/config'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { api } from '@/scripts/api'
import { StatusWsMessageStatus } from '@/types/apiTypes'
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { i18n } from '@/i18n'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
import { app } from './scripts/app'
import { useSettingStore } from './stores/settingStore'
import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from './stores/workspaceStateStore'
import NodeLibrarySidebarTab from './components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import GlobalDialog from './components/dialog/GlobalDialog.vue'
import GlobalToast from './components/toast/GlobalToast.vue'
import UnloadWindowConfirmDialog from './components/dialog/UnloadWindowConfirmDialog.vue'
import BrowserTabTitle from './components/BrowserTabTitle.vue'
import { api } from './scripts/api'
import { StatusWsMessageStatus } from './types/apiTypes'
import { useQueuePendingTaskCountStore } from './stores/queueStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { i18n } from './i18n'
import { useExecutionStore } from './stores/executionStore'
import { useWorkflowStore } from './stores/workflowStore'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
const theme = computed<string>(() =>
useSettingStore().get('Comfy.ColorPalette')
)
const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const executionStore = useExecutionStore()
const workflowStore = useWorkflowStore()
const theme = computed<string>(() => settingStore.get('Comfy.ColorPalette'))
watch(
theme,
(newTheme) => {
@@ -57,7 +68,7 @@ watch(
)
watchEffect(() => {
const fontSize = useSettingStore().get('Comfy.TextareaWidget.FontSize')
const fontSize = settingStore.get('Comfy.TextareaWidget.FontSize')
document.documentElement.style.setProperty(
'--comfy-textarea-font-size',
`${fontSize}px`
@@ -65,7 +76,7 @@ watchEffect(() => {
})
watchEffect(() => {
const padding = useSettingStore().get('Comfy.TreeExplorer.ItemPadding')
const padding = settingStore.get('Comfy.TreeExplorer.ItemPadding')
document.documentElement.style.setProperty(
'--comfy-tree-explorer-item-padding',
`${padding}px`
@@ -73,15 +84,14 @@ watchEffect(() => {
})
watchEffect(() => {
const locale = useSettingStore().get('Comfy.Locale')
const locale = settingStore.get('Comfy.Locale')
if (locale) {
i18n.global.locale.value = locale
i18n.global.locale.value = locale as 'en' | 'zh'
}
})
const { t } = useI18n()
const init = () => {
useSettingStore().addSettings(app.ui.settings)
settingStore.addSettings(app.ui.settings)
app.extensionManager = useWorkspaceStore()
app.extensionManager.registerSidebarTab({
id: 'queue',
@@ -105,19 +115,20 @@ const init = () => {
})
}
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const onStatus = (e: CustomEvent<StatusWsMessageStatus>) =>
const onStatus = (e: CustomEvent<StatusWsMessageStatus>) => {
queuePendingTaskCountStore.update(e)
}
const toast = useToast()
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('reconnecting')
}
const onReconnecting = () => {
toast.remove(reconnectingMessage)
toast.add(reconnectingMessage)
}
const onReconnected = () => {
toast.remove(reconnectingMessage)
toast.add({
@@ -127,23 +138,25 @@ const onReconnected = () => {
})
}
const executionStore = useExecutionStore()
app.workflowManager.executionStore = executionStore
watchEffect(() => {
app.menu.workflows.buttonProgress.style.width = `${executionStore.executionProgress}%`
})
const workflowStore = useWorkflowStore()
app.workflowManager.workflowStore = workflowStore
onMounted(() => {
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
console.log('ComfyUI Front-end version:', config.app_version)
api.addEventListener('status', onStatus)
api.addEventListener('reconnecting', onReconnecting)
api.addEventListener('reconnected', onReconnected)
executionStore.bindExecutionEvents()
try {
init()
} catch (e) {
console.error('Failed to init Vue app', e)
console.error('Failed to init ComfyUI frontend', e)
}
})
@@ -154,15 +167,3 @@ onUnmounted(() => {
executionStore.unbindExecutionEvents()
})
</script>
<style>
.p-tree-node-content {
padding: var(--comfy-tree-explorer-item-padding) !important;
}
</style>
<style scoped>
.spinner {
@apply absolute inset-0 flex justify-center items-center h-screen;
}
</style>

View File

@@ -708,3 +708,7 @@ audio.comfy-audio.empty-audio-widget {
.p-autocomplete-overlay {
max-width: 25vw;
}
.p-tree-node-content {
padding: var(--comfy-tree-explorer-item-padding) !important;
}

View File

@@ -31,10 +31,18 @@ const workflowNameText = computed(() => {
return workflowName ? isUnsavedText.value + workflowName : DEFAULT_TITLE
})
const title = computed(
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${executionStore.executingNodeProgress}%] ${executionStore.executingNode.type}`
: ''
)
const workflowTitle = computed(
() =>
executionText.value +
(betaMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
)
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
useTitle(title)
</script>

View File

@@ -60,7 +60,7 @@ watch(
const start = 0
const end = fileName.length
const inputElement = inputRef.value.$el
inputElement.setSelectionRange(start, end)
inputElement.setSelectionRange?.(start, end)
})
}
},

View File

@@ -49,12 +49,12 @@ const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys')
provide('expandedKeys', expandedKeys)
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
provide('selectionKeys', selectionKeys)
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
roots: TreeExplorerNode[]
class?: string
extraMenuItems?:
| MenuItem[]
| ((targetNode: RenderedTreeExplorerNode) => MenuItem[])
}>()
const emit = defineEmits<{
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
@@ -92,15 +92,28 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
: children.reduce((acc, child) => acc + child.totalLeaves, 0)
}
}
const onNodeContentClick = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
const onNodeContentClick = async (
e: MouseEvent,
node: RenderedTreeExplorerNode
) => {
if (!storeSelectionKeys) {
selectionKeys.value = {}
}
if (node.handleClick) {
node.handleClick(node, e)
await node.handleClick(node, e)
}
emit('nodeClick', node, e)
}
const menu = ref(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
provide('menuTargetNode', menuTargetNode)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
: menuTargetNode.value.contextMenuItems
: []
})
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
provide('renameEditingNode', renameEditingNode)
@@ -108,8 +121,8 @@ const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
renameEditingNode.value = node
}
const deleteCommand = (node: RenderedTreeExplorerNode) => {
node.handleDelete?.(node)
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
await node.handleDelete?.(node)
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() =>
@@ -124,16 +137,15 @@ const menuItems = computed<MenuItem[]>(() =>
label: t('delete'),
icon: 'pi pi-trash',
command: () => deleteCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleDelete !== undefined
visible: menuTargetNode.value?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...(props.extraMenuItems
? typeof props.extraMenuItems === 'function'
? props.extraMenuItems(menuTargetNode.value)
: props.extraMenuItems
: [])
...extraMenuItems.value
].map((menuItem) => ({
...menuItem,
command: wrapCommandWithErrorHandler(menuItem.command)
command: wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
}))
)
@@ -147,12 +159,18 @@ const handleContextMenu = (node: RenderedTreeExplorerNode, e: MouseEvent) => {
const errorHandling = useErrorHandling()
const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
) => {
return errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (...args: any[]) => Promise<any>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
}
defineExpose({

View File

@@ -10,8 +10,8 @@
]"
ref="container"
>
<div class="node-content">
<span class="node-label">
<div class="node-content truncate">
<span class="node-label text-sm">
<slot name="before-label" :node="props.node"></slot>
<EditableText
:modelValue="node.label"
@@ -46,6 +46,7 @@ import type {
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/hooks/errorHooks'
const props = defineProps<{
node: RenderedTreeExplorerNode
@@ -67,10 +68,14 @@ const renameEditingNode =
const isEditing = computed(
() => labelEditable.value && renameEditingNode.value?.key === props.node.key
)
const handleRename = (newName: string) => {
props.node.handleRename(props.node, newName)
renameEditingNode.value = null
}
const errorHandling = useErrorHandling()
const handleRename = errorHandling.wrapWithErrorHandlingAsync(
async (newName: string) => {
await props.node.handleRename(props.node, newName)
renameEditingNode.value = null
},
props.node.handleError
)
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)
const treeNodeElement = ref<HTMLElement | null>(null)
@@ -83,10 +88,10 @@ onMounted(() => {
if (props.node.droppable) {
dropTargetCleanup = dropTargetForElements({
element: treeNodeElement.value,
onDrop: (event) => {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
props.node.handleDrop?.(props.node, dndData)
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}

View File

@@ -3,7 +3,20 @@ import { mount } from '@vue/test-utils'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import EditableText from '@/components/common/EditableText.vue'
import Badge from 'primevue/badge'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { createTestingPinia } from '@pinia/testing'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { createI18n } from 'vue-i18n'
import { createApp } from 'vue'
import { useToastStore } from '@/stores/toastStore'
// Create a mock i18n instance
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {}
})
describe('TreeExplorerTreeNode', () => {
const mockNode = {
@@ -12,15 +25,28 @@ describe('TreeExplorerTreeNode', () => {
leaf: false,
totalLeaves: 3,
icon: 'pi pi-folder',
type: 'folder'
type: 'folder',
handleRename: () => {}
} as RenderedTreeExplorerNode
beforeAll(() => {
// Create a Vue app instance for PrimeVuePrimeVue
const app = createApp({})
app.use(PrimeVue)
vi.useFakeTimers()
})
afterAll(() => {
vi.useRealTimers()
})
it('renders correctly', () => {
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
provide: { renameEditingNode: { value: null } }
provide: { renameEditingNode: { value: null } },
plugins: [createTestingPinia(), i18n]
}
})
@@ -32,4 +58,78 @@ describe('TreeExplorerTreeNode', () => {
)
expect(wrapper.findComponent(Badge).props()['value']).toBe(3)
})
it('makes node label editable when renamingEditingNode matches', async () => {
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: mockNode },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const editableText = wrapper.findComponent(EditableText)
expect(editableText.props('isEditing')).toBe(true)
})
it('triggers handleRename callback when editing is finished', async () => {
const handleRenameMock = vi.fn()
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
expect(handleRenameMock).toHaveBeenCalledOnce()
})
it('shows error toast when handleRename promise rejects', async () => {
const handleRenameMock = vi
.fn()
.mockRejectedValue(new Error('Rename failed'))
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const toastStore = useToastStore()
const addToastSpy = vi.spyOn(toastStore, 'add')
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
// Wait for the promise to reject and the toast to be added
vi.runAllTimers()
// Wait for any pending promises to resolve
await new Promise(process.nextTick)
expect(handleRenameMock).toHaveBeenCalledOnce()
expect(addToastSpy).toHaveBeenCalledWith({
severity: 'error',
summary: 'error',
detail: 'Rename failed',
life: 3000
})
})
})

View File

@@ -22,7 +22,11 @@ import { ref, computed, onUnmounted, onMounted, watchEffect } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import {
ComfyNodeDefImpl,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import {
LiteGraph,
@@ -38,6 +42,9 @@ import {
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useCanvasStore } from '@/stores/graphStore'
import { applyOpacity } from '@/utils/colorUtil'
import { getColorPalette } from '@/extensions/core/colorPalette'
import { debounce } from 'lodash'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -86,6 +93,24 @@ watchEffect(() => {
})
})
const updateNodeOpacity = (nodeOpacity: number) => {
const colorPalette = getColorPalette()
if (!canvasStore.canvas) return
const nodeBgColor = colorPalette?.colors?.litegraph_base?.NODE_DEFAULT_BGCOLOR
if (nodeBgColor) {
LiteGraph.NODE_DEFAULT_BGCOLOR = applyOpacity(nodeBgColor, nodeOpacity)
}
}
const debouncedUpdateNodeOpacity = debounce(updateNodeOpacity, 128)
watchEffect(() => {
const nodeOpacity = settingStore.get('Comfy.Node.Opacity')
debouncedUpdateNodeOpacity(nodeOpacity)
})
let dropTargetCleanup = () => {}
onMounted(async () => {
@@ -140,6 +165,8 @@ onMounted(async () => {
// node search is triggered
useNodeDefStore().nodeSearchService.endsWithFilterStartSequence('')
// Non-blocking load of node frequencies
useNodeFrequencyStore().loadNodeFrequencies()
emit('ready')
})

View File

@@ -109,7 +109,7 @@ const onIdle = () => {
[0, 0]
)
if (outputSlot !== -1) {
return showTooltip(nodeDef.output.all?.[outputSlot].tooltip)
return showTooltip(nodeDef.output.all?.[outputSlot]?.tooltip)
}
const widget = getHoveredWidget()

View File

@@ -5,7 +5,13 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<template>
<div class="_sb_node_preview">
<div class="_sb_table">
<div class="node_header">
<div
class="node_header"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
color: litegraphColors.NODE_TITLE_COLOR
}"
>
<div class="_sb_dot headdot"></div>
{{ nodeDef.display_name }}
</div>
@@ -22,7 +28,12 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
</div>
<div class="_sb_col">{{ slotInput ? slotInput.name : '' }}</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">
<div
class="_sb_col _sb_inherit"
:style="{
color: litegraphColors.NODE_TEXT_COLOR
}"
>
{{ slotOutput ? slotOutput.name : '' }}
</div>
<div class="_sb_col">
@@ -37,15 +48,32 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
:key="widgetInput.name"
>
<div class="_sb_col _sb_arrow">&#x25C0;</div>
<div class="_sb_col">{{ widgetInput.name }}</div>
<div
class="_sb_col"
:style="{
color: litegraphColors.WIDGET_SECONDARY_TEXT_COLOR
}"
>
{{ widgetInput.name }}
</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">
<div
class="_sb_col _sb_inherit"
:style="{ color: litegraphColors.WIDGET_TEXT_COLOR }"
>
{{ truncateDefaultValue(widgetInput.default) }}
</div>
<div class="_sb_col _sb_arrow">&#x25B6;</div>
</div>
</div>
<div class="_sb_description" v-if="nodeDef.description">
<div
class="_sb_description"
v-if="nodeDef.description"
:style="{
color: litegraphColors.WIDGET_SECONDARY_TEXT_COLOR,
backgroundColor: litegraphColors.WIDGET_BGCOLOR
}"
>
{{ nodeDef.description }}
</div>
</div>
@@ -53,6 +81,10 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<script setup lang="ts">
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import {
getColorPalette,
defaultColorPalette
} from '@/extensions/core/colorPalette'
import _ from 'lodash'
const props = defineProps({
@@ -62,6 +94,13 @@ const props = defineProps({
}
})
// Node preview currently is recreated every time something is hovered.
// So not reactive to the color palette changes after setup is fine.
// If later we want NodePreview to be shown more persistently, then we should
// make the getColorPalette() call reactive.
const colors = getColorPalette()?.colors?.litegraph_base
const litegraphColors = colors ?? defaultColorPalette.colors.litegraph_base
const nodeDefStore = useNodeDefStore()
const nodeDef = props.nodeDef
@@ -106,7 +145,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
.node_header {
line-height: 1;
padding: 8px 13px 7px;
background: var(--comfy-input-bg);
margin-bottom: 5px;
font-size: 15px;
text-wrap: nowrap;

View File

@@ -1,29 +0,0 @@
<template>
<Chip :class="nodeSource.className">
{{ nodeSource.displayText }}
</Chip>
</template>
<script setup lang="ts">
import { getNodeSource } from '@/types/nodeSource'
import Chip from 'primevue/chip'
import { computed } from 'vue'
const props = defineProps({
python_module: {
type: String,
required: true
}
})
const nodeSource = computed(() => getNodeSource(props.python_module))
</script>
<style scoped>
.comfy-core,
.comfy-custom-nodes,
.comfy-unknown {
font-size: small;
font-weight: lighter;
}
</style>

View File

@@ -33,6 +33,7 @@
:suggestions="suggestions"
:min-length="0"
:delay="100"
:loading="!nodeFrequencyStore.isLoaded"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@focused-option-changed="setHoverSuggestion($event)"
@@ -67,7 +68,11 @@ import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { type FilterAndValue } from '@/services/nodeSearchService'
import NodePreview from '@/components/node/NodePreview.vue'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import {
ComfyNodeDefImpl,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useI18n } from 'vue-i18n'
import SearchFilterChip from '../common/SearchFilterChip.vue'
@@ -98,13 +103,18 @@ const placeholder = computed(() => {
return props.filters.length === 0 ? t('searchNodes') + '...' : ''
})
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const search = (query: string) => {
const queryIsEmpty = query === '' && props.filters.length === 0
currentQuery.value = query
suggestions.value = [
...useNodeDefStore().nodeSearchService.searchNode(query, props.filters, {
limit: props.searchLimit
})
]
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
...nodeDefStore.nodeSearchService.searchNode(query, props.filters, {
limit: props.searchLimit
})
]
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])

View File

@@ -34,7 +34,7 @@
<script setup lang="ts">
import { app } from '@/scripts/app'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { computed, onMounted, onUnmounted, ref, toRaw, watchEffect } from 'vue'
import NodeSearchBox from './NodeSearchBox.vue'
import Dialog from 'primevue/dialog'
import { ConnectingLink } from '@comfyorg/litegraph'
@@ -66,7 +66,9 @@ const addFilter = (filter: FilterAndValue) => {
nodeFilters.value.push(filter)
}
const removeFilter = (filter: FilterAndValue) => {
nodeFilters.value = nodeFilters.value.filter((f) => f !== filter)
nodeFilters.value = nodeFilters.value.filter(
(f) => toRaw(f) !== toRaw(filter)
)
}
const clearFilters = () => {
nodeFilters.value = []

View File

@@ -1,13 +1,19 @@
<template>
<div class="_content">
<SelectButton
class="filter-type-select"
v-model="selectedFilter"
:options="filters"
:allowEmpty="false"
optionLabel="name"
@change="updateSelectedFilterValue"
/>
<Select v-model="selectedFilterValue" :options="filterValues" filter />
<Select
class="filter-value-select"
v-model="selectedFilterValue"
:options="filterValues"
filter
/>
</div>
<div class="_footer">
<Button type="button" :label="$t('add')" @click="submit"></Button>

View File

@@ -33,22 +33,31 @@
:value="$t('deprecated')"
severity="danger"
/>
<NodeSourceChip
v-if="nodeDef.python_module !== undefined"
:python_module="nodeDef.python_module"
<Tag
v-if="showNodeFrequency && nodeFrequency > 0"
:value="formatNumberWithSuffix(nodeFrequency, { roundToInt: true })"
severity="secondary"
/>
<Chip
v-if="nodeDef.nodeSource.type !== NodeSourceType.Unknown"
class="text-sm font-light"
>
{{ nodeDef.nodeSource.displayText }}
</Chip>
</div>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import NodeSourceChip from '@/components/node/NodeSourceChip.vue'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import Chip from 'primevue/chip'
import { NodeSourceType } from '@/types/nodeSource'
import { ComfyNodeDefImpl, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { highlightQuery } from '@/utils/formatUtil'
import { computed } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { formatNumberWithSuffix } from '@/utils/formatUtil'
const settingStore = useSettingStore()
const showCategory = computed(() =>
@@ -57,6 +66,13 @@ const showCategory = computed(() =>
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
)
const showNodeFrequency = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')
)
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeFrequency = computed(() =>
nodeFrequencyStore.getNodeFrequency(props.nodeDef)
)
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() =>

View File

@@ -49,7 +49,6 @@
class="node-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />

View File

@@ -4,7 +4,6 @@
ref="treeExplorerRef"
:roots="renderedBookmarkedRoot.children"
:expandedKeys="expandedKeys"
:extraMenuItems="extraMenuItems"
>
<template #folder="{ node }">
<NodeTreeFolder :node="node" />
@@ -89,6 +88,37 @@ watch(
}
}
)
const { t } = useI18n()
const extraMenuItems = (
menuTargetNode: RenderedTreeExplorerNode<ComfyNodeDefImpl>
) => [
{
label: t('newFolder'),
icon: 'pi pi-folder-plus',
command: () => {
addNewBookmarkFolder(menuTargetNode)
},
visible: !menuTargetNode?.leaf
},
{
label: t('customize'),
icon: 'pi pi-palette',
command: () => {
const customization =
nodeBookmarkStore.bookmarksCustomization[menuTargetNode.data.nodePath]
initialIcon.value =
customization?.icon || nodeBookmarkStore.defaultBookmarkIcon
initialColor.value =
customization?.color || nodeBookmarkStore.defaultBookmarkColor
showCustomizationDialog.value = true
customizationTargetNodePath.value = menuTargetNode.data.nodePath
},
visible: !menuTargetNode?.leaf
}
]
const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
() => {
const fillNodeInfo = (
@@ -145,6 +175,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
toggleNodeOnEvent(e, node)
}
},
contextMenuItems: extraMenuItems,
...(node.leaf
? {}
: {
@@ -201,34 +232,4 @@ const updateCustomization = (icon: string, color: string) => {
)
}
}
const { t } = useI18n()
const extraMenuItems = computed(
() => (menuTargetNode: RenderedTreeExplorerNode<ComfyNodeDefImpl>) => [
{
label: t('newFolder'),
icon: 'pi pi-folder-plus',
command: () => {
addNewBookmarkFolder(menuTargetNode)
},
visible: !menuTargetNode?.leaf
},
{
label: t('customize'),
icon: 'pi pi-palette',
command: () => {
const customization =
nodeBookmarkStore.bookmarksCustomization[menuTargetNode.data.nodePath]
initialIcon.value =
customization?.icon || nodeBookmarkStore.defaultBookmarkIcon
initialColor.value =
customization?.color || nodeBookmarkStore.defaultBookmarkColor
showCustomizationDialog.value = true
customizationTargetNodePath.value = menuTargetNode.data.nodePath
},
visible: !menuTargetNode?.leaf
}
]
)
</script>

View File

@@ -125,9 +125,4 @@ onUnmounted(() => {
.node-lib-node-container {
@apply h-full w-full;
}
.bookmark-button {
width: unset;
padding: 0.25rem;
}
</style>

4
src/config.ts Normal file
View File

@@ -0,0 +1,4 @@
export default {
app_title: 'ComfyUI',
app_version: __COMFYUI_FRONTEND_VERSION__
}

View File

@@ -2,6 +2,8 @@ import { app } from '../../scripts/app'
import { $el } from '../../scripts/ui'
import type { ColorPalettes, Palette } from '@/types/colorPalette'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { applyOpacity } from '@/utils/colorUtil'
import { useSettingStore } from '@/stores/settingStore'
// Manage color palettes
@@ -682,7 +684,13 @@ app.registerExtension({
colorPalette.colors.litegraph_base.hasOwnProperty(key) &&
LiteGraph.hasOwnProperty(key)
) {
LiteGraph[key] = colorPalette.colors.litegraph_base[key]
LiteGraph[key] =
key === 'NODE_DEFAULT_BGCOLOR'
? applyOpacity(
colorPalette.colors.litegraph_base[key],
useSettingStore().get('Comfy.Node.Opacity')
)
: colorPalette.colors.litegraph_base[key]
}
}
}

View File

@@ -828,6 +828,9 @@ export class GroupNodeHandler {
]
// Remove all converted nodes and relink them
const builder = new GroupNodeBuilder(nodes)
const nodeData = builder.getNodeData()
groupNode[GROUP].groupData.nodeData.links = nodeData.links
groupNode[GROUP].replaceNodes(nodes)
return groupNode
}

View File

@@ -1,47 +1,44 @@
import { app, type ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
import type { ComfyLGraphNode } from '@/types/comfyLGraphNode'
import type { LGraphNode } from '@comfyorg/litegraph'
import { LGraphBadge } from '@comfyorg/litegraph'
import { useSettingStore } from '@/stores/settingStore'
import { computed, ComputedRef, watch } from 'vue'
import {
getNodeSource as getNodeSourceFromPythonModule,
NodeBadgeMode
} from '@/types/nodeSource'
import { NodeBadgeMode, NodeSource, NodeSourceType } from '@/types/nodeSource'
import _ from 'lodash'
import { getColorPalette, defaultColorPalette } from './colorPalette'
import { BadgePosition } from '@comfyorg/litegraph'
import type { Palette } from '@/types/colorPalette'
import type { ComfyNodeDef } from '@/types/apiTypes'
import { useNodeDefStore } from '@/stores/nodeDefStore'
function getNodeSource(node: ComfyLGraphNode) {
const pythonModule = (node.constructor as typeof ComfyLGraphNode).nodeData
?.python_module
return pythonModule ? getNodeSourceFromPythonModule(pythonModule) : null
function getNodeSource(node: LGraphNode): NodeSource | null {
const nodeDef = node.constructor.nodeData
// Frontend-only nodes don't have nodeDef
if (!nodeDef) {
return null
}
const nodeDefStore = useNodeDefStore()
return nodeDefStore.nodeDefsByName[nodeDef.name]?.nodeSource ?? null
}
function isCoreNode(node: ComfyLGraphNode) {
return getNodeSource(node)?.type === 'core'
function isCoreNode(node: LGraphNode) {
return getNodeSource(node)?.type === NodeSourceType.Core
}
function badgeTextVisible(
node: ComfyLGraphNode,
badgeMode: NodeBadgeMode
): boolean {
function badgeTextVisible(node: LGraphNode, badgeMode: NodeBadgeMode): boolean {
return (
badgeMode === NodeBadgeMode.None ||
(isCoreNode(node) && badgeMode === NodeBadgeMode.HideBuiltIn)
)
}
function getNodeIdBadgeText(
node: ComfyLGraphNode,
nodeIdBadgeMode: NodeBadgeMode
) {
function getNodeIdBadgeText(node: LGraphNode, nodeIdBadgeMode: NodeBadgeMode) {
return badgeTextVisible(node, nodeIdBadgeMode) ? '' : `#${node.id}`
}
function getNodeSourceBadgeText(
node: ComfyLGraphNode,
node: LGraphNode,
nodeSourceBadgeMode: NodeBadgeMode
) {
const nodeSource = getNodeSource(node)
@@ -51,11 +48,11 @@ function getNodeSourceBadgeText(
}
function getNodeLifeCycleBadgeText(
node: ComfyLGraphNode,
node: LGraphNode,
nodeLifeCycleBadgeMode: NodeBadgeMode
) {
let text = ''
const nodeDef = (node.constructor as typeof ComfyLGraphNode).nodeData
const nodeDef = node.constructor.nodeData
// Frontend-only nodes don't have nodeDef
if (!nodeDef) {
@@ -114,7 +111,7 @@ class NodeBadgeExtension implements ComfyExtension {
})
}
nodeCreated(node: ComfyLGraphNode, app: ComfyApp) {
nodeCreated(node: LGraphNode, app: ComfyApp) {
node.badgePosition = BadgePosition.TopRight
// @ts-expect-error Disable ComfyUI-Manager's badge drawing by setting badge_enabled to true. Remove this when ComfyUI-Manager's badge drawing is removed.
node.badge_enabled = true

View File

@@ -378,7 +378,6 @@ app.registerExtension({
const nodeIds = Object.keys(app.canvas.selected_nodes)
for (let i = 0; i < nodeIds.length; i++) {
const node = app.graph.getNodeById(nodeIds[i])
// @ts-expect-error
const nodeData = node?.constructor.nodeData
let groupData = GroupNodeHandler.getGroupData(node)

View File

@@ -26,7 +26,6 @@ app.registerExtension({
// Should we extends LGraphNode? Yesss
this,
'',
// @ts-expect-error
['', { default: this.properties.text, multiline: true }],
app
)

View File

@@ -74,7 +74,6 @@ app.registerExtension({
const link = app.graph.links[linkId]
if (!link) return
const node = app.graph.getNodeById(link.origin_id)
// @ts-expect-error Nodes that extend LGraphNode will not have a static type property
const type = node.constructor.type
if (type === 'Reroute') {
if (node === this) {
@@ -113,7 +112,6 @@ app.registerExtension({
if (!link) continue
const node = app.graph.getNodeById(link.target_id)
// @ts-expect-error Nodes that extend LGraphNode will not have a static type property
const type = node.constructor.type
if (type === 'Reroute') {
@@ -179,7 +177,6 @@ app.registerExtension({
}
if (!targetWidget) {
targetWidget = targetNode.widgets?.find(
// @ts-expect-error fix widget types
(w) => w.name === targetInput.widget.name
)
}

View File

@@ -95,11 +95,9 @@ app.registerExtension({
const audioUIWidget: DOMWidget<HTMLAudioElement> = node.addDOMWidget(
inputName,
/* name=*/ 'audioUI',
audio
audio,
{ serialize: false }
)
// @ts-expect-error
// TODO: Sort out the DOMWidget type.
audioUIWidget.serialize = false
const isOutputNode = node.constructor.nodeData.output_node
if (isOutputNode) {
@@ -193,10 +191,10 @@ app.registerExtension({
/* value=*/ '',
() => {
fileInput.click()
}
},
{ serialize: false }
)
uploadWidget.label = 'choose file to upload'
uploadWidget.serialize = false
return { widget: uploadWidget }
}

View File

@@ -3,6 +3,7 @@ import { app } from '../../scripts/app'
import { applyTextReplacements } from '../../scripts/utils'
import { LiteGraph, LGraphNode } from '@comfyorg/litegraph'
import type { INodeInputSlot, IWidget } from '@comfyorg/litegraph'
import type { InputSpec } from '@/types/apiTypes'
const CONVERTED_TYPE = 'converted-widget'
const VALID_TYPES = ['STRING', 'combo', 'number', 'toggle', 'BOOLEAN']
@@ -447,15 +448,19 @@ function showWidget(widget) {
}
}
function convertToInput(node, widget, config) {
function convertToInput(node: LGraphNode, widget: IWidget, config: InputSpec) {
hideWidget(node, widget)
const { type } = getWidgetType(config)
// Add input and store widget config for creating on primitive node
const sz = node.size
const inputIsOptional = !!widget.options?.inputIsOptional
node.addInput(widget.name, type, {
widget: { name: widget.name, [GET_CONFIG]: () => config }
// @ts-expect-error GET_CONFIG is not defined in LiteGraph
widget: { name: widget.name, [GET_CONFIG]: () => config },
// @ts-expect-error LiteGraph.SlotShape is not typed.
...(inputIsOptional ? { shape: LiteGraph.SlotShape.HollowCircle } : {})
})
for (const widget of node.widgets) {
@@ -479,7 +484,7 @@ function convertToWidget(node, widget) {
node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])])
}
function getWidgetType(config) {
function getWidgetType(config: InputSpec) {
// Special handling for COMBO so we restrict links based on the entries
let type = config[0]
if (type instanceof Array) {

View File

@@ -1,28 +1,41 @@
import { useToast } from 'primevue/usetoast'
import { useToastStore } from '@/stores/toastStore'
import { useI18n } from 'vue-i18n'
export function useErrorHandling() {
const toast = useToast()
const toast = useToastStore()
const { t } = useI18n()
const toastErrorHandler = (error: any) => {
toast.add({
severity: 'error',
summary: t('error'),
detail: error.message,
life: 3000
})
}
const wrapWithErrorHandling =
(action: (...args: any[]) => any, errorHandler?: (error: any) => void) =>
(...args: any[]) => {
try {
return action(...args)
} catch (e) {
if (errorHandler) {
errorHandler(e)
} else {
toast.add({
severity: 'error',
summary: t('error'),
detail: e.message,
life: 3000
})
}
;(errorHandler ?? toastErrorHandler)(e)
}
}
return { wrapWithErrorHandling }
const wrapWithErrorHandlingAsync =
(
action: (...args: any[]) => Promise<any>,
errorHandler?: (error: any) => void
) =>
async (...args: any[]) => {
try {
return await action(...args)
} catch (e) {
;(errorHandler ?? toastErrorHandler)(e)
}
}
return { wrapWithErrorHandling, wrapWithErrorHandlingAsync }
}

View File

@@ -3,12 +3,13 @@ import router from '@/router'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { i18n } from './i18n'
import { definePreset } from '@primevue/themes'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import { definePreset } from '@primevue/themes'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import 'reflect-metadata'
import '@comfyorg/litegraph/style.css'
import '@/assets/css/style.css'

View File

@@ -51,7 +51,6 @@ import { useToastStore } from '@/stores/toastStore'
import { ModelStore, useModelStore } from '@/stores/modelStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { ComfyLGraphNode } from '@/types/comfyLGraphNode'
import { useExecutionStore } from '@/stores/executionStore'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -1286,10 +1285,8 @@ export class ComfyApp {
}
if (e.type == 'keydown' && !e.repeat) {
const key = e.code
// Ctrl + M mute/unmute
if (key === 'KeyM' && (e.metaKey || e.ctrlKey)) {
if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 2) {
@@ -1304,7 +1301,7 @@ export class ComfyApp {
}
// Ctrl + B bypass
if (key === 'KeyB' && (e.metaKey || e.ctrlKey)) {
if (e.key === 'b' && (e.metaKey || e.ctrlKey)) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 4) {
@@ -1319,7 +1316,7 @@ export class ComfyApp {
}
// p pin/unpin
if (key === 'KeyP') {
if (e.key === 'p') {
if (this.selected_nodes) {
for (const i in this.selected_nodes) {
const node = this.selected_nodes[i]
@@ -1330,7 +1327,7 @@ export class ComfyApp {
}
// Alt + C collapse/uncollapse
if (key === 'KeyC' && e.altKey) {
if (e.key === 'c' && e.altKey) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
this.selected_nodes[i].collapse()
@@ -1340,18 +1337,22 @@ export class ComfyApp {
}
// Ctrl+C Copy
if (key === 'KeyC' && (e.metaKey || e.ctrlKey)) {
if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
// Trigger onCopy
return true
}
// Ctrl+V Paste
if (key === 'KeyV' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (
(e.key === 'v' || e.key == 'V') &&
(e.metaKey || e.ctrlKey) &&
!e.shiftKey
) {
// Trigger onPaste
return true
}
if ((key === 'NumpadAdd' || key === 'Equal') && e.altKey) {
if (e.key === '+' && e.altKey) {
block_default = true
let scale = this.ds.scale * 1.1
this.ds.changeScale(scale, [
@@ -1361,9 +1362,9 @@ export class ComfyApp {
this.graph.change()
}
if ((key === 'NumpadSubtract' || key === 'Minus') && e.altKey) {
if (e.key === '-' && e.altKey) {
block_default = true
let scale = this.ds.scale / 1.1
let scale = (this.ds.scale * 1) / 1.1
this.ds.changeScale(scale, [
this.ds.element.width / 2,
this.ds.element.height / 2
@@ -1935,7 +1936,6 @@ export class ComfyApp {
{
name,
display_name: name,
// @ts-expect-error
category: node.category || '__frontend_only__',
input: { required: {}, optional: {} },
output: [],
@@ -1989,7 +1989,7 @@ export class ComfyApp {
async registerNodeDef(nodeId: string, nodeData: ComfyNodeDef) {
const self = this
const node: new () => ComfyLGraphNode = class ComfyNode extends LGraphNode {
const node = class ComfyNode extends LGraphNode {
static comfyClass? = nodeData.name
// TODO: change to "title?" once litegraph.d.ts has been updated
static title = nodeData.display_name || nodeData.name
@@ -1998,6 +1998,8 @@ export class ComfyApp {
constructor(title?: string) {
super(title)
const requiredInputs = nodeData.input.required
var inputs = nodeData['input']['required']
if (nodeData['input']['optional'] != undefined) {
inputs = Object.assign(
@@ -2010,6 +2012,7 @@ export class ComfyApp {
for (const inputName in inputs) {
const inputData = inputs[inputName]
const type = inputData[0]
const inputIsRequired = inputName in requiredInputs
let widgetCreated = true
const widgetType = self.getWidgetType(inputData, inputName)
@@ -2027,9 +2030,22 @@ export class ComfyApp {
}
} else {
// Node connection inputs
this.addInput(inputName, type)
const inputOptions = inputIsRequired
? {}
: // @ts-expect-error LiteGraph.SlotShape is not typed.
{ shape: LiteGraph.SlotShape.HollowCircle }
this.addInput(inputName, type, inputOptions)
widgetCreated = false
}
// @ts-expect-error
if (widgetCreated && !inputIsRequired && config?.widget) {
// @ts-expect-error
if (!config.widget.options) config.widget.options = {}
// @ts-expect-error
config.widget.options.inputIsOptional = true
}
// @ts-expect-error
if (widgetCreated && inputData[1]?.forceInput && config?.widget) {
// @ts-expect-error
@@ -2050,10 +2066,11 @@ export class ComfyApp {
let output = nodeData['output'][o]
if (output instanceof Array) output = 'COMBO'
const outputName = nodeData['output_name'][o] || output
const outputShape = nodeData['output_is_list'][o]
? LiteGraph.GRID_SHAPE
: LiteGraph.CIRCLE_SHAPE
this.addOutput(outputName, output, { shape: outputShape })
const outputIsList = nodeData['output_is_list'][o]
const outputOptions = outputIsList
? { shape: LiteGraph.GRID_SHAPE }
: {}
this.addOutput(outputName, output, outputOptions)
}
const s = this.computeSize()
@@ -2064,6 +2081,29 @@ export class ComfyApp {
app.#invokeExtensionsAsync('nodeCreated', this)
}
configure(data: any) {
// Keep 'name', 'type', and 'shape' information from the original node definition.
const merge = (
current: Record<string, any>,
incoming: Record<string, any>
) => {
const result = { ...incoming }
for (const key of ['name', 'type', 'shape']) {
if (current[key] !== undefined) {
result[key] = current[key]
}
}
return result
}
for (const field of ['inputs', 'outputs']) {
const slots = data[field] ?? []
data[field] = slots.map((slot, i) =>
merge(this[field][i] ?? {}, slot)
)
}
super.configure(data)
}
}
node.prototype.comfyClass = nodeData.name
@@ -2074,7 +2114,6 @@ export class ComfyApp {
await this.#invokeExtensionsAsync('beforeRegisterNodeDef', node, nodeData)
LiteGraph.registerNodeType(nodeId, node)
// Note: Do not move this to the class definition, it will be overwritten
// @ts-expect-error
node.category = nodeData.category
}

View File

@@ -82,10 +82,10 @@ export class ChangeTracker {
async undoRedo(e) {
if (e.ctrlKey || e.metaKey) {
if (e.code === 'KeyY') {
if (e.key === 'y') {
this.updateState(this.redo, this.undo)
return true
} else if (e.code === 'KeyZ') {
} else if (e.key === 'z') {
this.updateState(this.undo, this.redo)
return true
}

View File

@@ -2,13 +2,13 @@ import { api } from './api'
import './domWidget'
import type { ComfyApp } from './app'
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
import { ComfyNodeDef } from '@/types/apiTypes'
import { InputSpec } from '@/types/apiTypes'
import { useSettingStore } from '@/stores/settingStore'
export type ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: ComfyNodeDef,
inputData: InputSpec,
app?: ComfyApp,
widgetName?: string
) => { widget: IWidget; minWidth?: number; minHeight?: number }
@@ -27,7 +27,7 @@ const IS_CONTROL_WIDGET = Symbol()
const HAS_EXECUTED = Symbol()
function getNumberDefaults(
inputData: ComfyNodeDef,
inputData: InputSpec,
defaultStep,
precision,
enable_rounding
@@ -62,7 +62,7 @@ export function addValueControlWidget(
defaultValue = 'randomize',
values,
widgetName,
inputData: ComfyNodeDef
inputData: InputSpec
) {
let name = inputData[1]?.control_after_generate
if (typeof name !== 'string') {
@@ -86,7 +86,7 @@ export function addValueControlWidgets(
targetWidget,
defaultValue = 'randomize',
options,
inputData: ComfyNodeDef
inputData: InputSpec
) {
if (!defaultValue) defaultValue = 'randomize'
if (!options) options = {}
@@ -259,7 +259,7 @@ export function addValueControlWidgets(
return widgets
}
function seedWidget(node, inputName, inputData: ComfyNodeDef, app, widgetName) {
function seedWidget(node, inputName, inputData: InputSpec, app, widgetName) {
const seed = createIntWidget(node, inputName, inputData, app, true)
const seedControl = addValueControlWidget(
node,
@@ -277,7 +277,7 @@ function seedWidget(node, inputName, inputData: ComfyNodeDef, app, widgetName) {
function createIntWidget(
node,
inputName,
inputData: ComfyNodeDef,
inputData: InputSpec,
app,
isSeedInput: boolean = false
) {
@@ -382,7 +382,7 @@ export function initWidgets(app) {
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
'INT:seed': seedWidget,
'INT:noise_seed': seedWidget,
FLOAT(node, inputName, inputData: ComfyNodeDef, app) {
FLOAT(node, inputName, inputData: InputSpec, app) {
let widgetType: 'number' | 'slider' = isSlider(inputData[1]['display'], app)
let precision = app.ui.settings.getSettingValue(
'Comfy.FloatRoundingPrecision'
@@ -416,7 +416,7 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
)
}
},
INT(node, inputName, inputData: ComfyNodeDef, app) {
INT(node, inputName, inputData: InputSpec, app) {
return createIntWidget(node, inputName, inputData, app)
},
BOOLEAN(node, inputName, inputData) {
@@ -431,7 +431,7 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
widget: node.addWidget('toggle', inputName, defaultVal, () => {}, options)
}
},
STRING(node, inputName, inputData: ComfyNodeDef, app) {
STRING(node, inputName, inputData: InputSpec, app) {
const defaultVal = inputData[1].default || ''
const multiline = !!inputData[1].multiline
@@ -454,7 +454,7 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
return res
},
COMBO(node, inputName, inputData: ComfyNodeDef) {
COMBO(node, inputName, inputData: InputSpec) {
const type = inputData[0]
let defaultValue = type[0]
if (inputData[1] && inputData[1].default) {
@@ -477,12 +477,7 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
}
return res
},
IMAGEUPLOAD(
node: LGraphNode,
inputName: string,
inputData: ComfyNodeDef,
app
) {
IMAGEUPLOAD(node: LGraphNode, inputName: string, inputData: InputSpec, app) {
// TODO make image upload handle a custom node type?
const imageWidget = node.widgets.find(
(w) => w.name === (inputData[1]?.widget ?? 'image')

View File

@@ -1,9 +1,8 @@
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { getNodeSource } from '@/types/nodeSource'
import Fuse, { IFuseOptions, FuseSearchOptions } from 'fuse.js'
import _ from 'lodash'
type SearchAuxScore = [number, number, number, number]
export type SearchAuxScore = number[]
interface ExtraSearchOptions {
matchWildcards?: boolean
@@ -32,11 +31,10 @@ export class FuseSearch<T> {
}
public search(query: string, options?: FuseSearchOptions): T[] {
if (!query || query === '') {
return [...this.data]
}
const fuseResult = !query
? this.data.map((x) => ({ item: x, score: 0 }))
: this.fuse.search(query, options)
const fuseResult = this.fuse.search(query, options)
if (!this.advancedScoring) {
return fuseResult.map((x) => x.item)
}
@@ -51,17 +49,20 @@ export class FuseSearch<T> {
return aux.map((x) => x.item)
}
public calcAuxScores(query: string, entry: T, score: number) {
public calcAuxScores(query: string, entry: T, score: number): SearchAuxScore {
let values: string[] = []
if (!this.keys.length) values = [entry as string]
else values = this.keys.map((x) => entry[x])
const scores = values.map((x) => this.calcAuxSingle(query, x, score))
const result = scores.sort(this.compareAux)[0]
let result = scores.sort(this.compareAux)[0]
const deprecated = values.some((x) =>
x.toLocaleLowerCase().includes('deprecated')
)
result[0] += deprecated && result[0] != 0 ? 5 : 0
if (entry['postProcessSearchScores']) {
result = entry['postProcessSearchScores'](result) as SearchAuxScore
}
return result
}
@@ -117,7 +118,12 @@ export class FuseSearch<T> {
}
public compareAux(a: SearchAuxScore, b: SearchAuxScore) {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3]
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] !== b[i]) {
return a[i] - b[i]
}
}
return a.length - b.length
}
}
@@ -195,7 +201,7 @@ export class NodeSourceFilter extends NodeFilter<string> {
public readonly longInvokeSequence = 'source'
public override getNodeOptions(node: ComfyNodeDefImpl): string[] {
return [getNodeSource(node.python_module).displayText]
return [node.nodeSource.displayText]
}
}

View File

@@ -69,6 +69,14 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.NodeSearchBoxImpl.ShowNodeFrequency',
category: ['Comfy', 'Node Search Box', 'ShowNodeFrequency'],
name: 'Show node frequency in search results',
tooltip: 'Only applies to the default implementation',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.Sidebar.Location',
category: ['Comfy', 'Sidebar', 'Location'],
@@ -131,6 +139,17 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Node.Opacity',
name: 'Node opacity',
type: 'slider',
defaultValue: 1,
attrs: {
min: 0.01,
max: 1,
step: 0.01
}
},
{
id: 'Comfy.Workflow.ShowMissingNodesWarning',
name: 'Show missing nodes warning',

View File

@@ -1,16 +1,50 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { api } from '../scripts/api'
import { ComfyWorkflow } from '@/scripts/workflows'
import type { ComfyNode, ComfyWorkflowJSON } from '@/types/comfyWorkflow'
export interface QueuedPrompt {
nodes: Record<string, boolean>
workflow?: any // TODO: Replace 'any' with the actual type of workflow
workflow?: ComfyWorkflow
}
interface NodeProgress {
value: number
max: number
}
export const useExecutionStore = defineStore('execution', () => {
const activePromptId = ref<string | null>(null)
const queuedPrompts = ref<Record<string, QueuedPrompt>>({})
const executingNodeId = ref<string | null>(null)
const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null
const workflow: ComfyWorkflow | null = activePrompt.value?.workflow
if (!workflow) return null
const canvasState: ComfyWorkflowJSON | null =
workflow.changeTracker?.activeState
if (!canvasState) return null
return (
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
null
)
})
// This is the progress of the currently executing node, if any
const _executingNodeProgress = ref<NodeProgress | null>(null)
const executingNodeProgress = computed(() =>
_executingNodeProgress.value
? Math.round(
(_executingNodeProgress.value.value /
_executingNodeProgress.value.max) *
100
)
: null
)
const activePrompt = computed(() => queuedPrompts.value[activePromptId.value])
@@ -38,6 +72,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.addEventListener('execution_cached', handleExecutionCached)
api.addEventListener('executed', handleExecuted)
api.addEventListener('executing', handleExecuting)
api.addEventListener('progress', handleProgress)
}
function unbindExecutionEvents() {
@@ -45,6 +80,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('execution_cached', handleExecutionCached)
api.removeEventListener('executed', handleExecuted)
api.removeEventListener('executing', handleExecuting)
api.removeEventListener('progress', handleProgress)
}
function handleExecutionStart(e: CustomEvent) {
@@ -65,19 +101,26 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecuting(e: CustomEvent) {
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
if (!activePrompt.value) return
if (executingNodeId.value) {
// Seems sometimes nodes that are cached fire executing but not executed
activePrompt.value.nodes[executingNodeId.value] = true
}
executingNodeId.value = e.detail
executingNodeId.value = e.detail ? String(e.detail) : null
if (!executingNodeId.value) {
delete queuedPrompts.value[activePromptId.value]
activePromptId.value = null
}
}
function handleProgress(e: CustomEvent) {
_executingNodeProgress.value = e.detail
}
function storePrompt({
nodes,
id,
@@ -112,6 +155,8 @@ export const useExecutionStore = defineStore('execution', () => {
totalNodesToExecute,
nodesExecuted,
executionProgress,
executingNode,
executingNodeProgress,
bindExecutionEvents,
unbindExecutionEvents,
storePrompt

View File

@@ -1,10 +1,16 @@
import { NodeSearchService } from '@/services/nodeSearchService'
import {
NodeSearchService,
type SearchAuxScore
} from '@/services/nodeSearchService'
import { ComfyNodeDef } from '@/types/apiTypes'
import { defineStore } from 'pinia'
import { Type, Transform, plainToClass, Expose } from 'class-transformer'
import { ComfyWidgetConstructor } from '@/scripts/widgets'
import { TreeNode } from 'primevue/treenode'
import { buildTree } from '@/utils/treeUtil'
import { computed, ref } from 'vue'
import axios from 'axios'
import { type NodeSource, getNodeSource } from '@/types/nodeSource'
export class BaseInputSpec<T = any> {
name: string
@@ -189,6 +195,12 @@ export class ComfyNodeDefImpl {
@Transform(({ obj }) => ComfyNodeDefImpl.transformOutputSpec(obj))
output: ComfyOutputsSpec
@Transform(({ obj }) => getNodeSource(obj.python_module), {
toClassOnly: true
})
@Expose()
nodeSource: NodeSource
private static transformOutputSpec(obj: any): ComfyOutputsSpec {
const { output, output_is_list, output_name, output_tooltips } = obj
const result = output.map((type: string | any[], index: number) => {
@@ -213,6 +225,12 @@ export class ComfyNodeDefImpl {
get isDummyFolder(): boolean {
return this.name === ''
}
postProcessSearchScores(scores: SearchAuxScore): SearchAuxScore {
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeFrequency = nodeFrequencyStore.getNodeFrequencyByName(this.name)
return [scores[0], -nodeFrequency, ...scores.slice(1)]
}
}
export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDef> = {
@@ -338,3 +356,49 @@ export const useNodeDefStore = defineStore('nodeDef', {
}
}
})
export const useNodeFrequencyStore = defineStore('nodeFrequency', () => {
const topNodeDefLimit = ref(64)
const nodeFrequencyLookup = ref<Record<string, number>>({})
const nodeNamesByFrequency = computed(() =>
Object.keys(nodeFrequencyLookup.value)
)
const isLoaded = ref(false)
const loadNodeFrequencies = async () => {
if (!isLoaded.value) {
try {
const response = await axios.get('/assets/sorted-custom-node-map.json')
nodeFrequencyLookup.value = response.data
isLoaded.value = true
} catch (error) {
console.error('Error loading node frequencies:', error)
}
}
}
const getNodeFrequency = (nodeDef: ComfyNodeDefImpl) => {
return getNodeFrequencyByName(nodeDef.name)
}
const getNodeFrequencyByName = (nodeName: string) => {
return nodeFrequencyLookup.value[nodeName] ?? 0
}
const nodeDefStore = useNodeDefStore()
const topNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
return nodeNamesByFrequency.value
.map((nodeName: string) => nodeDefStore.nodeDefsByName[nodeName])
.filter((nodeDef: ComfyNodeDefImpl) => nodeDef !== undefined)
.slice(0, topNodeDefLimit.value)
})
return {
nodeNamesByFrequency,
topNodeDefs,
isLoaded,
loadNodeFrequencies,
getNodeFrequency,
getNodeFrequencyByName
}
})

View File

@@ -227,13 +227,13 @@ export function validateTaskItem(taskItem: unknown) {
return result
}
function inputSpec(
spec: [ZodType, ZodType],
function inputSpec<TType extends ZodType, TSpec extends ZodType>(
spec: [TType, TSpec],
allowUpcast: boolean = true
): ZodType {
) {
const [inputType, inputSpec] = spec
// e.g. "INT" => ["INT", {}]
const upcastTypes: ZodType[] = allowUpcast
const upcastTypes = allowUpcast
? [inputType.transform((type) => [type, {}])]
: []
@@ -361,6 +361,7 @@ const zComfyNodeDef = z.object({
})
// `/object_info`
export type InputSpec = z.infer<typeof zInputSpec>
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>

View File

@@ -1,11 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { ComfyNodeDef } from './apiTypes'
export declare class ComfyLGraphNode extends LGraphNode {
static comfyClass: string
static title: string
static nodeData?: ComfyNodeDef
static category?: string
constructor(title?: string)
}

View File

@@ -1,14 +1,33 @@
import '@comfyorg/litegraph'
import type { ComfyNodeDef } from '@/types/apiTypes'
import type { LLink } from '@comfyorg/litegraph'
/**
* ComfyUI extensions of litegraph
*/
declare module '@comfyorg/litegraph' {
interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
type?: string
comfyClass: string
title: string
nodeData?: ComfyNodeDef
category?: string
new (): T
}
interface LGraphNode {
constructor: LGraphNodeConstructor
/**
* Callback fired on each node after the graph is configured
*/
onAfterGraphConfigured?(): void
onNodeCreated?(this: LGraphNode): void
setInnerNodes?(nodes: LGraphNode[]): void
applyToGraph?(extraLinks?: LLink[]): void
updateLink?(link: LLink): LLink | null
comfyClass?: string
/**
* If the node is a frontend only node and should not be serialized into the prompt.
@@ -24,25 +43,30 @@ declare module '@comfyorg/litegraph' {
}
interface IWidget<TValue = any, TOptions = any> {
type?: string
/**
* Allows for additional cleanup when removing a widget when converting to input.
*/
onRemove?(): void
serializeValue?(node?: LGraphNode, i?: string)
beforeQueued?(): void
/**
* DOM element used for the widget
*/
element?: HTMLElement
tooltip?: string
origType?: IWidget['type']
origComputeSize?: IWidget['computeSize']
origSerializeValue?: IWidget['serializeValue']
}
interface INodeOutputSlot {
widget?: unknown
}
interface INodeInputSlot {
widget?: unknown
interface INodeSlot {
widget?: unknown & { name?: string }
}
}

View File

@@ -6,6 +6,7 @@ declare module '@comfyorg/litegraph' {
interface LiteGraphExtended {
search_filter_enabled: boolean
middle_click_slot_add_default_node: boolean
registered_node_types: Record<string, LGraphNodeConstructor>
registered_slot_out_types: Record<string, { nodes: string[] }>
registered_slot_in_types: Record<string, { nodes: string[] }>
slot_types_out: string[]

View File

@@ -1,4 +1,9 @@
export type NodeSourceType = 'core' | 'custom_nodes'
export enum NodeSourceType {
Core = 'core',
CustomNodes = 'custom_nodes',
Unknown = 'unknown'
}
export type NodeSource = {
type: NodeSourceType
className: string
@@ -6,24 +11,41 @@ export type NodeSource = {
badgeText: string
}
export const getNodeSource = (python_module: string): NodeSource => {
const UNKNOWN_NODE_SOURCE: NodeSource = {
type: NodeSourceType.Unknown,
className: 'comfy-unknown',
displayText: 'Unknown',
badgeText: '?'
}
const shortenNodeName = (name: string) => {
return name
.replace(/^(ComfyUI-|ComfyUI_|Comfy-|Comfy_)/, '')
.replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
}
export const getNodeSource = (python_module?: string): NodeSource => {
if (!python_module) {
return UNKNOWN_NODE_SOURCE
}
const modules = python_module.split('.')
if (['nodes', 'comfy_extras'].includes(modules[0])) {
return {
type: 'core',
type: NodeSourceType.Core,
className: 'comfy-core',
displayText: 'Comfy Core',
badgeText: '🦊'
}
} else if (modules[0] === 'custom_nodes') {
const displayName = shortenNodeName(modules[1])
return {
type: 'custom_nodes',
type: NodeSourceType.CustomNodes,
className: 'comfy-custom-nodes',
displayText: modules[1],
badgeText: modules[1]
displayText: displayName,
badgeText: displayName
}
} else {
throw new Error(`Unknown node source: ${python_module}`)
return UNKNOWN_NODE_SOURCE
}
}

View File

@@ -1,3 +1,5 @@
import type { MenuItem } from 'primevue/menuitem'
export interface TreeExplorerNode<T = any> {
key: string
label: string
@@ -7,14 +9,17 @@ export interface TreeExplorerNode<T = any> {
icon?: string
getIcon?: (node: TreeExplorerNode<T>) => string
// Function to handle renaming the node
handleRename?: (node: TreeExplorerNode<T>, newName: string) => void
handleRename?: (
node: TreeExplorerNode<T>,
newName: string
) => void | Promise<void>
// Function to handle deleting the node
handleDelete?: (node: TreeExplorerNode<T>) => void
handleDelete?: (node: TreeExplorerNode<T>) => void | Promise<void>
// Function to handle adding a child node
handleAddChild?: (
node: TreeExplorerNode<T>,
child: TreeExplorerNode<T>
) => void
) => void | Promise<void>
// Whether the node is draggable
draggable?: boolean
// Whether the node is droppable
@@ -23,11 +28,18 @@ export interface TreeExplorerNode<T = any> {
handleDrop?: (
node: TreeExplorerNode<T>,
data: TreeExplorerDragAndDropData
) => void
) => void | Promise<void>
// Function to handle clicking a node
handleClick?: (node: TreeExplorerNode<T>, event: MouseEvent) => void
handleClick?: (
node: TreeExplorerNode<T>,
event: MouseEvent
) => void | Promise<void>
// Function to handle errors
handleError?: (error: Error) => void
// Extra context menu items
contextMenuItems?:
| MenuItem[]
| ((targetNode: RenderedTreeExplorerNode) => MenuItem[])
}
export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {

View File

@@ -1,5 +1,6 @@
type RGB = { r: number; g: number; b: number }
type HSL = { h: number; s: number; l: number }
type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
function rgbToHsl({ r, g, b }: RGB): HSL {
r /= 255
@@ -92,6 +93,16 @@ function rgbToHex({ r, g, b }: RGB): string {
)
}
function identifyColorFormat(color: string): ColorFormat | null {
if (!color) return null
if (color.startsWith('#')) return 'hex'
if (/^rgba?\(\d+,\s*\d+,\s*\d+/.test(color))
return color.includes('rgba') ? 'rgba' : 'rgb'
if (/^hsla?\(\d+(\.\d+)?,\s*\d+(\.\d+)?%,\s*\d+(\.\d+)?%/.test(color))
return color.includes('hsla') ? 'hsla' : 'hsl'
return null
}
export function lightenColor(hex: string, amount: number): string {
let rgb = hexToRgb(hex)
const hsl = rgbToHsl(rgb)
@@ -99,3 +110,46 @@ export function lightenColor(hex: string, amount: number): string {
rgb = hslToRgb(hsl)
return rgbToHex(rgb)
}
export function applyOpacity(color: string, opacity: number): string {
const colorFormat = identifyColorFormat(color)
if (!colorFormat) {
console.warn(
`Unsupported color format in user color palette for color: ${color}`
)
return color
}
const clampedOpacity = Math.max(0, Math.min(1, opacity))
switch (colorFormat) {
case 'hex': {
const { r, g, b } = hexToRgb(color)
if (isNaN(r) || isNaN(g) || isNaN(b)) {
return color
}
return `rgba(${r}, ${g}, ${b}, ${clampedOpacity})`
}
case 'rgb': {
return color.replace('rgb', 'rgba').replace(')', `, ${clampedOpacity})`)
}
case 'rgba': {
return color.replace(
/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,[^)]+\)/,
`rgba($1, $2, $3, ${clampedOpacity})`
)
}
case 'hsl': {
return color.replace('hsl', 'hsla').replace(')', `, ${clampedOpacity})`)
}
case 'hsla': {
return color.replace(
/hsla\(\s*(\d+)\s*,\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*,[^)]+\)/,
`hsla($1, $2%, $3%, ${clampedOpacity})`
)
}
default:
return color
}
}

View File

@@ -39,3 +39,23 @@ export function highlightQuery(text: string, query: string) {
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}
export function formatNumberWithSuffix(
num: number,
{
precision = 1,
roundToInt = false
}: { precision?: number; roundToInt?: boolean } = {}
): string {
const suffixes = ['', 'k', 'm', 'b', 't']
const absNum = Math.abs(num)
if (absNum < 1000) {
return roundToInt ? Math.round(num).toString() : num.toFixed(precision)
}
const exp = Math.min(Math.floor(Math.log10(absNum) / 3), suffixes.length - 1)
const formattedNum = (num / Math.pow(1000, exp)).toFixed(precision)
return `${formattedNum}${suffixes[exp]}`
}

6
src/vite-env.d.ts vendored
View File

@@ -1 +1,7 @@
/// <reference types="vite/client" />
declare global {
interface Window {
__COMFYUI_FRONTEND_VERSION__: string
}
}

View File

@@ -0,0 +1,50 @@
import { applyOpacity } from '@/utils/colorUtil'
describe('colorUtil - applyOpacity', () => {
// Same color in various formats
const solarized = {
hex: '#073642',
rgb: 'rgb(7, 54, 66)',
rgba: 'rgba(7, 54, 66, 1)',
hsl: 'hsl(192, 80.80%, 14.30%)',
hsla: 'hsla(192, 80.80%, 14.30%, 1)'
}
const opacity = 0.5
it('applies opacity consistently to hex, rgb, and rgba formats', () => {
const hexResult = applyOpacity(solarized.hex, opacity)
const rgbResult = applyOpacity(solarized.rgb, opacity)
const rgbaResult = applyOpacity(solarized.rgba, opacity)
expect(hexResult).toBe(rgbResult)
expect(rgbResult).toBe(rgbaResult)
})
it('applies opacity consistently to hsl and hsla formats', () => {
const hslResult = applyOpacity(solarized.hsl, opacity)
const hslaResult = applyOpacity(solarized.hsla, opacity)
expect(hslResult).toBe(hslaResult)
})
it('returns the original value for invalid color formats', () => {
const invalidColors = [
'#GGGGGG', // Invalid hex code (non-hex characters)
'rgb(300, -10, 256)', // Invalid RGB values (out of range)
'xyz(255, 255, 255)', // Unsupported format
'rgba(255, 255, 255)', // Missing alpha in RGBA
'hsl(100, 50, 50%)' // Missing percentage sign for saturation
]
invalidColors.forEach((color) => {
const result = applyOpacity(color, opacity)
expect(result).toBe(color)
})
})
it('returns the original value for null or undefined inputs', () => {
expect(applyOpacity(null, opacity)).toBe(null)
expect(applyOpacity(undefined, opacity)).toBe(undefined)
})
})

View File

@@ -51,7 +51,11 @@ const EXAMPLE_NODE_DEFS: ComfyNodeDefImpl[] = [
category: 'latent/batch',
output_node: false
}
].map((nodeDef) => plainToClass(ComfyNodeDefImpl, nodeDef))
].map((nodeDef) => {
const def = plainToClass(ComfyNodeDefImpl, nodeDef)
def['postProcessSearchScores'] = (s) => s
return def
})
describe('nodeSearchService', () => {
it('searches with input filter', () => {

View File

@@ -132,6 +132,9 @@ export const mockNodeDefStore = () => {
}
jest.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: jest.fn(() => mockedNodeDefStore)
useNodeDefStore: jest.fn(() => mockedNodeDefStore),
useNodeFrequencyStore: jest.fn(() => ({
getNodeFrequencyByName: jest.fn(() => 0)
}))
}))
}

View File

@@ -33,6 +33,7 @@
"src/**/*",
"src/**/*.vue",
"src/types/**/*.d.ts",
"tests-ui/**/*"
"tests-ui/**/*",
"global.d.ts"
]
}

View File

@@ -167,5 +167,9 @@ export default defineConfig({
alias: {
'@': '/src'
}
},
optimizeDeps: {
exclude: ['@comfyorg/litegraph']
}
}) as UserConfigExport