Compare commits

...

45 Commits

Author SHA1 Message Date
Chenlei Hu
efa2fa269d 1.2.57 (#868) 2024-09-18 09:38:27 +09:00
Chenlei Hu
a2cf6a7be2 Refactor TreeExplorer (Add handleClick hook) (#867)
* Refactor TreeExplorer (Add handleClick hook)

* nit
2024-09-18 09:36:21 +09:00
bymyself
e493473c35 Add tests on using group nodes in library sidebar (#864)
* Add tests on adding group node from library sidebar

* Improve test name clarity
2024-09-18 09:09:35 +09:00
MaraScott
415a2e7fa5 add square pointer (#848)
* add square pointer

* create enum + refactorize to create init_shape and draw_shape methods
2024-09-17 17:24:07 +09:00
Chenlei Hu
ba9a3b4a9b Move workflows management to pinia (#862) 2024-09-17 17:15:20 +09:00
bymyself
174c52958f Add test on mobile canvas panning (#863)
* Add test on mobile canvas panning

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-17 17:15:05 +09:00
Chenlei Hu
4e41db2d6a [Beta Menu] Shows unsaved state on browser tab title (#860)
* [Beta Menu] Shows unsaved state on browser tab title

* Proper state management

* Add playwright test

* Fix browser tests
2024-09-17 16:14:06 +09:00
bymyself
e8daebdc0c Add group nodes to search and node library (#861)
* Register group nodes in nodeDefStore

* Add playwright tests

* Update test expectations [skip ci]

* Mock nodeDefStore in group node unit test

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-17 16:06:58 +09:00
Chenlei Hu
582acd7bd1 1.2.56 (#859) 2024-09-17 11:17:26 +09:00
Chenlei Hu
48fe14e263 [Beta Menu] Show active workflow name on browser tab title (#857) 2024-09-17 11:11:52 +09:00
bymyself
f9fd0f59ff Add nullcheck to snap-to-grid setting (#858) 2024-09-17 11:11:39 +09:00
Chenlei Hu
3fe4b4b856 Add generation progress to browser tab title (#855) 2024-09-17 10:31:29 +09:00
bymyself
c510b344af Allow zero as input slider min/max (#854) 2024-09-17 09:43:32 +09:00
Chenlei Hu
980dd285ad Revert move floating menu to Vue (#853) 2024-09-17 09:33:25 +09:00
Chenlei Hu
2b60244e4a Move setting declarations from ui to coreSettings (#847)
* Move setting declarations from ui to coreSettings

* nit

* nit

* Move effect to vue component
2024-09-16 17:47:47 +09:00
Chenlei Hu
45a866f194 Fix ComfyUI class setup procedure (#846) 2024-09-16 17:14:11 +09:00
Chenlei Hu
091b8a74fb Mock settingStore (#845) 2024-09-16 16:25:32 +09:00
Chenlei Hu
74fa4a2c2d Move inlined settings in settingsStore to a separate file (#844) 2024-09-16 14:46:06 +09:00
Chenlei Hu
327b67a022 Move floating menu to a Vue component (#843)
* Move floating menu to a Vue component

* nit

* Fix jest tests
2024-09-16 14:26:46 +09:00
Chenlei Hu
d0a4db5f4f Update litegraph (Copy connection by shift drag from path) (#841)
* Add playwright tests

* Update lg

* nit

* nit

* Skip tests

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-16 12:02:48 +09:00
Chenlei Hu
861eaa155f Refactor test fixture dnd (#840)
* Refactor test fixture dnd

* nit
2024-09-16 10:13:25 +09:00
Chenlei Hu
3550e7f7f1 Show bookmark icon on booked mark nodes in node search box (#839) 2024-09-15 17:31:44 +09:00
Chenlei Hu
7d25d976d1 Extract search option as a Vue component (#838) 2024-09-15 17:15:19 +09:00
Chenlei Hu
7025e321de 1.2.55 (#836) 2024-09-15 11:22:27 +09:00
huchenlei
429fa75fcc Update litegraph (Fix group right click) 2024-09-15 11:03:34 +09:00
Chenlei Hu
347563adf9 Move json format functions to formatUtil (#834) 2024-09-15 09:46:34 +09:00
Chenlei Hu
9bdb3c0332 1.2.54 (#830) 2024-09-14 17:12:50 +09:00
Chenlei Hu
12c699cc87 Update litegraph (Getters) (#829)
* Update litegraph (Getters)

* Update
2024-09-14 17:10:57 +09:00
Chenlei Hu
588cfeca4b Replace ComfyApp.runningNodeId with executionStore.executingNodeId (#828)
* Replace ComfyApp.runningNodeId with executionStore.executingNodeId

* nit
2024-09-14 16:01:37 +09:00
Chenlei Hu
f983f42c45 Add executionStore (#827)
* Extract execution store

* Fix executing nodes highlight

* nit

* nit

* nit
2024-09-14 15:15:15 +09:00
Chenlei Hu
fef780a72f Make useTreeExpansion hook accept expandedKeys as param (#826) 2024-09-14 11:27:38 +09:00
Chenlei Hu
ebdcd92977 Extract error handling with toast message as hook (#825) 2024-09-14 11:25:08 +09:00
filtered
c98ea5ba01 Use LiteGraph validation for node search->create (#822)
Adds LiteGraph type to augmentation until LG types are auto-generated
Removes @ts-expect-error
2024-09-14 08:36:48 +09:00
filtered
48f84a46cd Add apiTypes present in docs but missing in zod (#821)
* Add apiTypes present in docs but missing in zod

* Fix prettier check
2024-09-14 08:35:32 +09:00
filtered
9483cfe915 Add graceful correction when widgets undef. (#820)
Fixes crash on load of workflow where `node.widgets` has no `.find()`
2024-09-14 08:33:43 +09:00
Chenlei Hu
862e2c2607 1.2.53 (#818) 2024-09-13 20:59:16 +09:00
Chenlei Hu
a08ec196c7 Fix frontend-only node freezing litegraph (#817) 2024-09-13 20:58:19 +09:00
Chenlei Hu
17db1e6074 Rework userFileStore (#815)
* Rework userFileStore

* nit

* Add back unittests
2024-09-13 17:40:08 +09:00
Chenlei Hu
65a8dbb7e0 1.2.52 (#814) 2024-09-13 16:25:01 +09:00
Chenlei Hu
efd8b5c19d Add playwright test for custom color palette (#812)
* Add playwright test for custom color palette

* nit

* Fix leaked side effect

* nit

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-13 13:52:16 +09:00
Chenlei Hu
0a188aaf72 Disable zoom when editing titles (#813) 2024-09-13 11:42:24 +09:00
Chenlei Hu
eb45cca031 Pin searchbox at top when node library scrolls (#811)
* minor style fix

* nit

* Pin searchbox at top when node library scrolls
2024-09-13 10:50:06 +09:00
Chenlei Hu
d8d6fa86e4 Add button to clear pending tasks (#810) 2024-09-13 10:23:28 +09:00
Chenlei Hu
880ac4fa5a Add node lifecycle badge text (#809) 2024-09-13 10:04:36 +09:00
Chenlei Hu
7d3b8dc44c Make \n correctly displayed on error message (#805) 2024-09-13 09:04:21 +09:00
69 changed files with 1887 additions and 1148 deletions

View File

@@ -108,10 +108,29 @@ class NodeLibrarySidebarTab {
class ComfyMenu {
public readonly sideToolbar: Locator
public readonly themeToggleButton: Locator
public readonly saveButton: Locator
constructor(public readonly page: Page) {
this.sideToolbar = page.locator('.side-tool-bar-container')
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
this.saveButton = page
.locator('button[title="Save the current workflow"]')
.nth(0)
}
async saveWorkflow(name: string) {
const acceptDialog = async (dialog) => {
await dialog.accept(name)
}
this.page.on('dialog', acceptDialog)
await this.saveButton.click()
// Wait a moment to ensure the dialog has been handled
await this.page.waitForTimeout(300)
// Remove the dialog listener
this.page.off('dialog', acceptDialog)
}
get nodeLibraryTab() {
@@ -172,12 +191,16 @@ export class ComfyPage {
async getGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return window['app']?.graph?._nodes?.length || 0
return window['app']?.graph?.nodes?.length || 0
})
}
async setup() {
await this.goto()
await this.page.evaluate(() => {
localStorage.clear()
sessionStorage.clear()
})
// Unify font for consistent screenshots.
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?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'
@@ -366,25 +389,40 @@ export class ComfyPage {
await this.nextFrame()
}
// Default graph positions
get clipTextEncodeNode1InputSlot(): Position {
return { x: 427, y: 198 }
}
get clipTextEncodeNode2InputSlot(): Position {
return { x: 422, y: 402 }
}
// A point on input edge.
get clipTextEncodeNode2InputLinkPath(): Position {
return {
x: 395,
y: 422
}
}
get loadCheckpointNodeClipOutputSlot(): Position {
return { x: 332, y: 509 }
}
get emptySpace(): Position {
return { x: 427, y: 98 }
}
async disconnectEdge() {
// CLIP input anchor
await this.page.mouse.move(427, 198)
await this.page.mouse.down()
await this.page.mouse.move(427, 98)
await this.page.mouse.up()
// Move out the way to avoid highlight of menu item.
await this.page.mouse.move(10, 10)
await this.nextFrame()
await this.dragAndDrop(this.clipTextEncodeNode1InputSlot, this.emptySpace)
}
async connectEdge() {
// CLIP output anchor on Load Checkpoint Node.
await this.page.mouse.move(332, 509)
await this.page.mouse.down()
// CLIP input anchor on CLIP Text Encode Node.
await this.page.mouse.move(427, 198)
await this.page.mouse.up()
await this.nextFrame()
await this.dragAndDrop(
this.loadCheckpointNodeClipOutputSlot,
this.clipTextEncodeNode1InputSlot
)
}
async adjustWidgetValue() {
@@ -422,6 +460,24 @@ export class ComfyPage {
await this.nextFrame()
}
async panWithTouch(offset: Position, safeSpot?: Position) {
safeSpot = safeSpot || { x: 10, y: 10 }
const client = await this.page.context().newCDPSession(this.page)
await client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [safeSpot]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: offset.x + safeSpot.x, y: offset.y + safeSpot.y }]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: []
})
await this.nextFrame()
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
@@ -593,6 +649,16 @@ export class ComfyPage {
revertAfter
)
}
async convertAllNodesToGroupNode(groupNodeName: string) {
this.page.on('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.canvas.press('Control+a')
await this.rightClickEmptyLatentNode()
await this.page.getByText('Convert to Group Node').click()
await this.nextFrame()
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({

View File

@@ -0,0 +1,55 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Browser tab title', () => {
test.describe('Beta Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can display workflow name', async ({ comfyPage }) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].workflowManager.activeWorkflow.name
})
// Note: unsaved workflow name is always prepended with "*".
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
})
test('Can display workflow name with unsaved changes', async ({
comfyPage
}) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].workflowManager.activeWorkflow.name
})
// Note: unsaved workflow name is always prepended with "*".
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
await comfyPage.menu.saveWorkflow('test')
expect(await comfyPage.page.title()).toBe('test')
const textBox = comfyPage.widgetTextBox
await textBox.fill('Hello World')
await comfyPage.clickEmptySpace()
expect(await comfyPage.page.title()).toBe(`*test`)
// Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => {
window['app'].workflowManager.activeWorkflow.delete()
})
})
})
test.describe('Legacy Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can display default title', async ({ comfyPage }) => {
expect(await comfyPage.page.title()).toBe('ComfyUI')
})
})
})

View File

@@ -0,0 +1,149 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
const customColorPalettes = {
obsidian: {
version: 102,
id: 'obsidian',
name: 'Obsidian',
colors: {
node_slot: {
CLIP: '#FFD500',
CLIP_VISION: '#A8DADC',
CLIP_VISION_OUTPUT: '#ad7452',
CONDITIONING: '#FFA931',
CONTROL_NET: '#6EE7B7',
IMAGE: '#64B5F6',
LATENT: '#FF9CF9',
MASK: '#81C784',
MODEL: '#B39DDB',
STYLE_MODEL: '#C2FFAE',
VAE: '#FF6E6E',
TAESD: '#DCC274',
PIPE_LINE: '#7737AA',
PIPE_LINE_SDXL: '#7737AA',
INT: '#29699C',
XYPLOT: '#74DA5D',
X_Y: '#38291f'
},
litegraph_base: {
BACKGROUND_IMAGE:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=',
CLEAR_BACKGROUND_COLOR: '#222222',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',
WIDGET_SECONDARY_TEXT_COLOR: '#97979c',
LINK_COLOR: '#9A9',
EVENT_LINK_COLOR: '#A86',
CONNECTING_LINK_COLOR: '#AFA'
},
comfy_base: {
'fg-color': '#fff',
'bg-color': '#242424',
'comfy-menu-bg': 'rgba(24,24,24,.9)',
'comfy-input-bg': '#262626',
'input-text': '#ddd',
'descrip-text': '#999',
'drag-text': '#ccc',
'error-text': '#ff4444',
'border-color': '#29292c',
'tr-even-bg-color': 'rgba(28,28,28,.9)',
'tr-odd-bg-color': 'rgba(19,19,19,.9)'
}
}
},
obsidian_dark: {
version: 102,
id: 'obsidian_dark',
name: 'Obsidian Dark',
colors: {
node_slot: {
CLIP: '#FFD500',
CLIP_VISION: '#A8DADC',
CLIP_VISION_OUTPUT: '#ad7452',
CONDITIONING: '#FFA931',
CONTROL_NET: '#6EE7B7',
IMAGE: '#64B5F6',
LATENT: '#FF9CF9',
MASK: '#81C784',
MODEL: '#B39DDB',
STYLE_MODEL: '#C2FFAE',
VAE: '#FF6E6E',
TAESD: '#DCC274',
PIPE_LINE: '#7737AA',
PIPE_LINE_SDXL: '#7737AA',
INT: '#29699C',
XYPLOT: '#74DA5D',
X_Y: '#38291f'
},
litegraph_base: {
BACKGROUND_IMAGE:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGlmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDEgNzkuMTQ2Mjg5OSwgMjAyMy8wNi8yNS0yMDowMTo1NSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmIyYzRhNjA5LWJmYTctYTg0MC1iOGFlLTk3MzE2ZjM1ZGIyNyIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjk0ZmNlZGU4LTE1MTctZmQ0MC04ZGU3LWYzOTgxM2E3ODk5ZiIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjIzMWIxMGIwLWI0ZmItMDI0ZS1iMTJlLTMwNTMwM2NkMDdjOCI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MjMxYjEwYjAtYjRmYi0wMjRlLWIxMmUtMzA1MzAzY2QwN2M4IiBzdEV2dDp3aGVuPSIyMDIzLTExLTEzVDAwOjE4OjAyKzAxOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjUuMSAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjQ4OWY1NzlmLTJkNjUtZWQ0Zi04OTg0LTA4NGE2MGE1ZTMzNSIgc3RFdnQ6d2hlbj0iMjAyMy0xMS0xNVQwMjowNDo1OSswMTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpiMmM0YTYwOS1iZmE3LWE4NDAtYjhhZS05NzMxNmYzNWRiMjciIHN0RXZ0OndoZW49IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyNS4xIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4OTe6GAAAAx0lEQVR42u3WMQoAIQxFwRzJys77X8vSLiRgITif7bYbgrwYc/mKXyBoY4VVBgsWLFiwYFmOlTv+9jfDOjHmr8u6eVkGCxYsWLBgmc5S8ApewXvgYRksWLBgKXidpeBdloL3wMOCBctgwVLwCl7BuyyDBQsWLFiwTGcpeAWv4D3wsAwWLFiwFLzOUvAuS8F74GHBgmWwYCl4Ba/gXZbBggULFixYprMUvIJX8B54WAYLFixYCl5nKXiXpeA98LBgwTJYsGC9tg1o8f4TTtqzNQAAAABJRU5ErkJggg==',
CLEAR_BACKGROUND_COLOR: '#000',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',
WIDGET_SECONDARY_TEXT_COLOR: '#97979c',
LINK_COLOR: '#9A9',
EVENT_LINK_COLOR: '#A86',
CONNECTING_LINK_COLOR: '#AFA'
},
comfy_base: {
'fg-color': '#fff',
'bg-color': '#242424',
'comfy-menu-bg': 'rgba(24,24,24,.9)',
'comfy-input-bg': '#262626',
'input-text': '#ddd',
'descrip-text': '#999',
'drag-text': '#ccc',
'error-text': '#ff4444',
'border-color': '#29292c',
'tr-even-bg-color': 'rgba(28,28,28,.9)',
'tr-odd-bg-color': 'rgba(19,19,19,.9)'
}
}
}
}
test.describe('Color Palette', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
})
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')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,56 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Group Node', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
const tab = comfyPage.menu.nodeLibraryTab
await tab.open()
expect(await tab.getFolder('group nodes').count()).toBe(1)
})
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
const initialNodeCount = await comfyPage.getGraphNodesCount()
// Add group node from node library sidebar
const tab = comfyPage.menu.nodeLibraryTab
await tab.open()
await tab.getFolder('group nodes').click()
await tab.getFolder('workflow').click()
await tab.getFolder('workflow').last().click()
await tab.getNode(groupNodeName).click()
// Verify the node is added to the canvas
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test('Can be added to canvas using search', async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.doubleClickCanvas()
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
const tooltipTimeout = 500
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -24,16 +24,56 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'no action')
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot(
'disconnected-edge-with-menu.png'
)
await comfyPage.connectEdge()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
test.describe('Edge Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'no action')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'no action')
})
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
})
})
// Chromium 2x cannot move link.
// See https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/10876381315/job/30176211513
test.skip('Can move link', async ({ comfyPage }) => {
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode1InputSlot,
comfyPage.emptySpace
)
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode2InputSlot,
comfyPage.clipTextEncodeNode1InputSlot
)
await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png')
})
// Copy link is not working on CI at all
// Chromium 2x recognize it as dragging canvas.
// Chromium triggers search box after link release. The link is indeed copied.
// See https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/10876381315/job/30176211513
test.skip('Can copy link by shift-drag existing link', async ({
comfyPage
}) => {
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode1InputSlot,
comfyPage.emptySpace
)
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode2InputLinkPath,
comfyPage.clipTextEncodeNode1InputSlot
)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot('copied-link.png')
})
})
@@ -310,6 +350,12 @@ test.describe('Canvas Interaction', () => {
await comfyPage.pan({ x: 800, y: 300 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-one.png')
})
test('@mobile Can pan with touch', async ({ comfyPage }) => {
await comfyPage.closeMenu()
await comfyPage.panWithTouch({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-touch.png')
})
})
test.describe('Widget Interaction', () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -10,7 +10,7 @@ test.describe('Node Badge', () => {
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph._nodes
const nodes = graph.nodes
for (const node of nodes) {
node.badges = [new LGraphBadge({ text: 'Test Badge' })]
@@ -28,7 +28,7 @@ test.describe('Node Badge', () => {
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph._nodes
const nodes = graph.nodes
for (const node of nodes) {
node.badges = [
@@ -49,7 +49,7 @@ test.describe('Node Badge', () => {
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph._nodes
const nodes = graph.nodes
for (const node of nodes) {
node.badges = [new LGraphBadge({ text: 'Test Badge' })]

12
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "comfyui-frontend",
"version": "1.2.51",
"version": "1.2.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.2.51",
"version": "1.2.57",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.71",
"@comfyorg/litegraph": "^0.7.75",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",
@@ -1909,9 +1909,9 @@
"dev": true
},
"node_modules/@comfyorg/litegraph": {
"version": "0.7.71",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.71.tgz",
"integrity": "sha512-WjV5ZY+yfNfu9n99bdfeUTdeFvCkOW/8KIFsCFu6aqGGUbsuRRwTbXk+qOvcDquzPGzrnDmo4z7UQpaMqCT9nA==",
"version": "0.7.75",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.75.tgz",
"integrity": "sha512-RNYZVMoJ/a5btwP+S124FnrIVlwOdv6uNsTdYfwv7L8teDpwvf/TQa66QfCePqUlypBKEhKw+avTncLAu2FYUw==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.2.51",
"version": "1.2.57",
"type": "module",
"scripts": {
"dev": "vite",
@@ -62,7 +62,7 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.71",
"@comfyorg/litegraph": "^0.7.75",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",

View File

@@ -5,6 +5,7 @@
<GlobalDialog />
<GlobalToast />
<UnloadWindowConfirmDialog />
<BrowserTabTitle />
</template>
<script setup lang="ts">
@@ -27,12 +28,15 @@ import NodeLibrarySidebarTab from './components/sidebar/tabs/NodeLibrarySidebarT
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'
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
const theme = computed<string>(() =>
@@ -123,10 +127,19 @@ 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(() => {
api.addEventListener('status', onStatus)
api.addEventListener('reconnecting', onReconnecting)
api.addEventListener('reconnected', onReconnected)
executionStore.bindExecutionEvents()
try {
init()
} catch (e) {
@@ -138,6 +151,7 @@ onUnmounted(() => {
api.removeEventListener('status', onStatus)
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
executionStore.unbindExecutionEvents()
})
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div>
<!-- This component does not render anything visible. -->
</div>
</template>
<script setup lang="ts">
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
const DEFAULT_TITLE = 'ComfyUI'
const executionStore = useExecutionStore()
const executionText = computed(() =>
executionStore.isIdle ? '' : `[${executionStore.executionProgress}%]`
)
const settingStore = useSettingStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const workflowStore = useWorkflowStore()
const isUnsavedText = computed(() =>
workflowStore.previousWorkflowUnsaved ? ' *' : ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.name
return workflowName ? isUnsavedText.value + workflowName : DEFAULT_TITLE
})
const title = computed(
() =>
executionText.value +
(betaMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
)
useTitle(title)
</script>

View File

@@ -54,8 +54,8 @@ const updateValue = (newValue: number | null) => {
newValue = Number(props.min) || 0
}
const min = Number(props.min) || Number.NEGATIVE_INFINITY
const max = Number(props.max) || Number.POSITIVE_INFINITY
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
const step = Number(props.step) || 1
// Ensure the value is within the allowed range

View File

@@ -5,7 +5,7 @@
<div class="flex flex-col items-center">
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem"></i>
<h3>{{ title }}</h3>
<p>{{ message }}</p>
<p class="whitespace-pre-line text-center">{{ message }}</p>
<Button
v-if="buttonLabel"
:label="buttonLabel"

View File

@@ -18,7 +18,10 @@
@click="$emit('showFilter', $event)"
/>
</IconField>
<div class="search-filters" v-if="filters">
<div
class="search-filters pt-2 flex flex-wrap gap-2"
v-if="filters?.length"
>
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
@@ -92,8 +95,4 @@ const handleInput = (event: Event) => {
width: auto;
border: none !important;
}
.search-filters {
@apply pt-2 flex flex-wrap gap-2;
}
</style>

View File

@@ -3,6 +3,7 @@
class="tree-explorer"
:class="props.class"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
:value="renderedRoots"
selectionMode="single"
:pt="{
@@ -40,11 +41,14 @@ import type {
RenderedTreeExplorerNode,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import type { MenuItem } from 'primevue/menuitem'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/hooks/errorHooks'
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys')
provide('expandedKeys', expandedKeys)
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
provide('selectionKeys', selectionKeys)
const props = defineProps<{
roots: TreeExplorerNode[]
class?: string
@@ -89,6 +93,9 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
}
}
const onNodeContentClick = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
if (node.handleClick) {
node.handleClick(node, e)
}
emit('nodeClick', node, e)
}
const menu = ref(null)
@@ -105,25 +112,31 @@ const deleteCommand = (node: RenderedTreeExplorerNode) => {
node.handleDelete?.(node)
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() => [
{
label: t('rename'),
icon: 'pi pi-file-edit',
command: () => renameCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleRename !== undefined
},
{
label: t('delete'),
icon: 'pi pi-trash',
command: () => deleteCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleDelete !== undefined
},
...(props.extraMenuItems
? typeof props.extraMenuItems === 'function'
? props.extraMenuItems(menuTargetNode.value)
: props.extraMenuItems
: [])
])
const menuItems = computed<MenuItem[]>(() =>
[
{
label: t('rename'),
icon: 'pi pi-file-edit',
command: () => renameCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleRename !== undefined
},
{
label: t('delete'),
icon: 'pi pi-trash',
command: () => deleteCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleDelete !== undefined
},
...(props.extraMenuItems
? typeof props.extraMenuItems === 'function'
? props.extraMenuItems(menuTargetNode.value)
: props.extraMenuItems
: [])
].map((menuItem) => ({
...menuItem,
command: wrapCommandWithErrorHandler(menuItem.command)
}))
)
const handleContextMenu = (node: RenderedTreeExplorerNode, e: MouseEvent) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
@@ -131,6 +144,17 @@ const handleContextMenu = (node: RenderedTreeExplorerNode, e: MouseEvent) => {
menu.value?.show(e)
}
}
const errorHandling = useErrorHandling()
const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void
) => {
return errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
}
defineExpose({
renameCommand,
deleteCommand
@@ -145,6 +169,7 @@ defineExpose({
margin-left: var(--p-tree-node-gap);
flex-grow: 1;
}
/*
* The following styles are necessary to avoid layout shift when dragging nodes over folders.
* By setting the position to relative on the parent and using an absolutely positioned pseudo-element,
@@ -153,6 +178,7 @@ defineExpose({
:deep(.p-tree-node-content:has(.tree-folder)) {
position: relative;
}
:deep(.p-tree-node-content:has(.tree-folder.can-drop))::after {
content: '';
position: absolute;

View File

@@ -1,3 +1,11 @@
<template>
<div>
<!-- This component does not render anything visible. It is used to confirm
the user wants to close the window, and if they do, it will call the
beforeunload event. -->
</div>
</template>
<script setup lang="ts">
import { useSettingStore } from '@/stores/settingStore'
import { onMounted, onUnmounted } from 'vue'

View File

@@ -20,7 +20,7 @@ import EditableText from '@/components/common/EditableText.vue'
import { ComfyExtension } from '@/types/comfy'
import { useSettingStore } from '@/stores/settingStore'
import type { LiteGraphCanvasEvent } from '@comfyorg/litegraph'
import { useTitleEditorStore } from '@/stores/graphStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
const settingStore = useSettingStore()
@@ -36,6 +36,8 @@ const inputStyle = ref<CSSProperties>({
})
const titleEditorStore = useTitleEditorStore()
const canvasStore = useCanvasStore()
const previousCanvasDraggable = ref(true)
const onEdit = (newValue: string) => {
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
@@ -44,6 +46,7 @@ const onEdit = (newValue: string) => {
}
showInput.value = false
titleEditorStore.titleEditorTarget = null
canvasStore.canvas!.allow_dragcanvas = previousCanvasDraggable.value
}
watch(
@@ -54,6 +57,8 @@ watch(
}
editedTitle.value = target.title
showInput.value = true
previousCanvasDraggable.value = canvasStore.canvas!.allow_dragcanvas
canvasStore.canvas!.allow_dragcanvas = false
if (target instanceof LGraphGroup) {
const group = target

View File

@@ -43,38 +43,7 @@
:optionLabel="'display_name'"
>
<template v-slot:option="{ option }">
<div class="option-container">
<div class="option-display-name">
<div>
<span
v-html="highlightQuery(option.display_name, currentQuery)"
></span>
<span>&nbsp;</span>
<Tag v-if="showIdName" severity="secondary">
<span v-html="highlightQuery(option.name, currentQuery)"></span>
</Tag>
</div>
<div v-if="showCategory" class="option-category">
{{ option.category.replaceAll('/', ' > ') }}
</div>
</div>
<div class="option-badges">
<Tag
v-if="option.experimental"
:value="$t('experimental')"
severity="primary"
/>
<Tag
v-if="option.deprecated"
:value="$t('deprecated')"
severity="danger"
/>
<NodeSourceChip
v-if="option.python_module !== undefined"
:python_module="option.python_module"
/>
</div>
</div>
<NodeSearchItem :nodeDef="option" :currentQuery="currentQuery" />
</template>
<!-- FilterAndValue -->
<template v-slot:chip="{ value }">
@@ -92,11 +61,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSourceChip from '@/components/node/NodeSourceChip.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'
@@ -110,12 +78,6 @@ const { t } = useI18n()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
)
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
)
const props = withDefaults(
defineProps<{
@@ -145,12 +107,6 @@ const search = (query: string) => {
]
}
const highlightQuery = (text: string, query: string) => {
if (!query) return text
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
const reFocusInput = () => {
@@ -202,29 +158,6 @@ const setHoverSuggestion = (index: number) => {
@apply z-10 flex-grow;
}
.option-container {
@apply flex justify-between items-center px-2 py-0 cursor-pointer overflow-hidden w-full;
}
.option-display-name {
@apply font-semibold flex flex-col;
}
.option-category {
@apply font-light text-sm text-gray-400 overflow-hidden text-ellipsis;
/* Keeps the text on a single line by default */
white-space: nowrap;
}
:deep(.highlight) {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0rem 0.125rem;
margin: -0.125rem 0.125rem;
}
._filter-button {
z-index: 10;
}

View File

@@ -0,0 +1,81 @@
<template>
<div
class="option-container flex justify-between items-center px-2 py-0 cursor-pointer overflow-hidden w-full"
>
<div class="option-display-name font-semibold flex flex-col">
<div>
<span v-if="isBookmarked">
<i class="pi pi-bookmark-fill text-sm mr-1"></i>
</span>
<span
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
></span>
<span>&nbsp;</span>
<Tag v-if="showIdName" severity="secondary">
<span v-html="highlightQuery(nodeDef.name, currentQuery)"></span>
</Tag>
</div>
<div
v-if="showCategory"
class="option-category font-light text-sm text-gray-400 overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</div>
</div>
<div class="option-badges">
<Tag
v-if="nodeDef.experimental"
:value="$t('experimental')"
severity="primary"
/>
<Tag
v-if="nodeDef.deprecated"
:value="$t('deprecated')"
severity="danger"
/>
<NodeSourceChip
v-if="nodeDef.python_module !== undefined"
:python_module="nodeDef.python_module"
/>
</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 { highlightQuery } from '@/utils/formatUtil'
import { computed } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
)
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() =>
nodeBookmarkStore.isBookmarked(props.nodeDef)
)
const props = defineProps<{
nodeDef: ComfyNodeDefImpl
currentQuery: string
}>()
</script>
<style scoped>
:deep(.highlight) {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0rem 0.125rem;
margin: -0.125rem 0.125rem;
}
</style>

View File

@@ -19,35 +19,44 @@
/>
</template>
<template #body>
<SearchBox
class="node-lib-search-box"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/>
<div class="flex flex-col h-full">
<div class="flex-shrink-0">
<SearchBox
class="node-lib-search-box mx-4 mt-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/>
<Popover ref="searchFilter" class="node-lib-filter-popup">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider v-if="nodeBookmarkStore.bookmarks.length > 0" type="dashed" />
<TreeExplorer
class="node-lib-tree-explorer"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
<Popover ref="searchFilter" class="node-lib-filter-popup">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
</div>
<div class="flex-grow overflow-y-auto">
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-if="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
/>
<TreeExplorer
class="node-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</div>
</template>
</SidebarTabTemplate>
<div id="node-library-node-preview-container" />
@@ -60,7 +69,7 @@ import {
ComfyNodeDefImpl,
useNodeDefStore
} from '@/stores/nodeDefStore'
import { computed, nextTick, onMounted, ref, Ref } from 'vue'
import { computed, nextTick, ref, Ref } from 'vue'
import type { TreeNode } from 'primevue/treenode'
import Popover from 'primevue/popover'
import Divider from 'primevue/divider'
@@ -83,7 +92,8 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const { expandedKeys, expandNode, toggleNodeOnEvent } = useTreeExpansion()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const nodeBookmarkTreeExplorerRef = ref<InstanceType<
typeof NodeBookmarkTreeExplorer
@@ -113,7 +123,17 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
}
},
children,
draggable: node.leaf
draggable: node.leaf,
handleClick: (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => {
if (node.leaf) {
app.addNodeOnGraph(node.data, { pos: app.getCanvasCenter() })
} else {
toggleNodeOnEvent(e, node)
}
}
}
}
return fillNodeInfo(root.value)
@@ -154,17 +174,6 @@ const handleSearch = (query: string) => {
})
}
const handleNodeClick = (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => {
if (node.leaf) {
app.addNodeOnGraph(node.data, { pos: app.getCanvasCenter() })
} else {
toggleNodeOnEvent(e, node)
}
}
const onAddFilter = (filterAndValue: FilterAndValue) => {
filters.value.push({
filter: filterAndValue,
@@ -193,10 +202,6 @@ const onRemoveFilter = (filterAndValue) => {
</style>
<style scoped>
:deep(.node-lib-search-box) {
@apply mx-4 mt-4;
}
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
@@ -205,10 +210,6 @@ const onRemoveFilter = (filterAndValue) => {
padding-bottom: 2px;
}
:deep(.node-lib-tree-explorer) {
padding-top: 2px;
}
:deep(.p-divider) {
margin: var(--comfy-tree-explorer-item-padding) 0px;
}

View File

@@ -31,6 +31,15 @@
class="toggle-expanded-button"
v-tooltip="$t('sideToolbar.queueTab.showFlatList')"
/>
<Button
v-if="queueStore.hasPendingTasks"
icon="pi pi-stop"
text
severity="danger"
@click="clearPendingTasks"
class="clear-pending-button"
v-tooltip="$t('sideToolbar.queueTab.clearPendingTasks')"
/>
<Button
icon="pi pi-trash"
text
@@ -221,6 +230,16 @@ const confirmRemoveAll = (event: Event) => {
})
}
const clearPendingTasks = async () => {
await queueStore.clear(['queue'])
toast.add({
severity: 'info',
summary: 'Confirmed',
detail: 'Pending tasks deleted',
life: 3000
})
}
const onStatus = async () => {
await queueStore.update()
updateVisibleTasks()

View File

@@ -5,7 +5,6 @@
:roots="renderedBookmarkedRoot.children"
:expandedKeys="expandedKeys"
:extraMenuItems="extraMenuItems"
@nodeClick="handleNodeClick"
>
<template #folder="{ node }">
<NodeTreeFolder :node="node" />
@@ -36,29 +35,19 @@ import type {
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import type { TreeNode } from 'primevue/treenode'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTreeExpansion } from '@/hooks/treeHooks'
import { app } from '@/scripts/app'
import { findNodeByKey } from '@/utils/treeUtil'
import { useErrorHandling } from '@/hooks/errorHooks'
const props = defineProps<{
filteredNodeDefs: ComfyNodeDefImpl[]
}>()
const { expandedKeys, expandNode, toggleNodeOnEvent } = useTreeExpansion()
const handleNodeClick = (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => {
if (node.leaf) {
app.addNodeOnGraph(node.data, { pos: app.getCanvasCenter() })
} else {
toggleNodeOnEvent(e, node)
}
}
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const nodeBookmarkStore = useNodeBookmarkStore()
const bookmarkedRoot = computed<TreeNode>(() => {
@@ -146,6 +135,16 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
const nodePath = folderNodeDef.category + '/' + nodeDefToAdd.name
nodeBookmarkStore.addBookmark(nodePath)
},
handleClick: (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => {
if (node.leaf) {
app.addNodeOnGraph(node.data, { pos: app.getCanvasCenter() })
} else {
toggleNodeOnEvent(e, node)
}
},
...(node.leaf
? {}
: {
@@ -182,22 +181,13 @@ defineExpose({
addNewBookmarkFolder
})
const toast = useToast()
const { t } = useI18n()
const handleRename = (node: TreeNode, newName: string) => {
if (node.data && node.data.isDummyFolder) {
try {
const handleRename = useErrorHandling().wrapWithErrorHandling(
(node: TreeNode, newName: string) => {
if (node.data && node.data.isDummyFolder) {
nodeBookmarkStore.renameBookmarkFolder(node.data, newName)
} catch (e) {
toast.add({
severity: 'error',
summary: t('error'),
detail: e.message,
life: 3000
})
}
}
}
)
const showCustomizationDialog = ref(false)
const initialIcon = ref(nodeBookmarkStore.defaultBookmarkIcon)
@@ -212,6 +202,7 @@ const updateCustomization = (icon: string, color: string) => {
}
}
const { t } = useI18n()
const extraMenuItems = computed(
() => (menuTargetNode: RenderedTreeExplorerNode<ComfyNodeDefImpl>) => [
{

View File

@@ -4,6 +4,7 @@ import { mergeIfValid } from './widgetInputs'
import { ManageGroupDialog } from './groupNodeManage'
import type { LGraphNode } from '@comfyorg/litegraph'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const GROUP = Symbol()
@@ -17,8 +18,7 @@ const Workflow = {
const id = `workflow/${name}`
// Check if lready registered/in use in this workflow
if (app.graph.extra?.groupNodes?.[name]) {
// @ts-expect-error
if (app.graph._nodes.find((n) => n.type === id)) {
if (app.graph.nodes.find((n) => n.type === id)) {
return Workflow.InUse.InWorkflow
} else {
return Workflow.InUse.Registered
@@ -195,6 +195,10 @@ export class GroupNodeConfig {
display_name: this.name,
category: 'group nodes' + ('/' + source),
input: { required: {} },
description: `Group node combining ${this.nodeData.nodes
.map((n) => n.type)
.join(', ')}`,
python_module: 'custom_nodes.' + this.name,
[GROUP]: this
}
@@ -213,6 +217,7 @@ export class GroupNodeConfig {
}
this.#convertedToProcess = null
await app.registerNodeDef('workflow/' + this.name, this.nodeDef)
useNodeDefStore().addNodeDef(this.nodeDef)
}
getLinks() {

View File

@@ -388,8 +388,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
'button.comfy-btn',
{
onclick: (e) => {
// @ts-expect-error
const node = app.graph._nodes.find(
const node = app.graph.nodes.find(
(n) => n.type === 'workflow/' + this.selectedGroup
)
if (node) {
@@ -470,8 +469,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
types[g] = type
if (!nodesByType) {
// @ts-expect-error
nodesByType = app.graph._nodes.reduce((p, n) => {
nodesByType = app.graph.nodes.reduce((p, n) => {
p[n.type] ??= []
p[n.type].push(n)
return p

View File

@@ -15,7 +15,7 @@ function addNodesToGroup(group, nodes = []) {
x1 = y1 = x2 = y2 = -1
nx1 = ny1 = nx2 = ny2 = -1
for (var n of [group._nodes, nodes]) {
for (var n of [group.nodes, nodes]) {
for (var i in n) {
node = n[i]
@@ -90,7 +90,7 @@ app.registerExtension({
// Group nodes aren't recomputed until the group is moved, this ensures the nodes are up-to-date
group.recomputeInsideNodes()
const nodesInGroup = group._nodes
const nodesInGroup = group.nodes
options.push({
content: 'Add Selected Nodes To Group',

View File

@@ -94,6 +94,17 @@ function prepare_mask(image, maskCanvas, maskCtx, maskColor) {
maskCtx.putImageData(maskData, 0, 0)
}
// Define the PointerType enum
enum PointerType {
Arc = 'arc',
Rect = 'rect'
}
enum CompositionOperation {
SourceOver = 'source-over',
DestinationOut = 'destination-out'
}
class MaskEditorDialog extends ComfyDialog {
static instance = null
static mousedown_x: number | null = null
@@ -120,6 +131,8 @@ class MaskEditorDialog extends ComfyDialog {
mousedown_pan_x: number
mousedown_pan_y: number
last_pressure: number
pointer_type: PointerType
brush_pointer_type_select: HTMLDivElement
static getInstance() {
if (!MaskEditorDialog.instance) {
@@ -176,7 +189,7 @@ class MaskEditorDialog extends ComfyDialog {
divElement.style.borderColor = 'var(--border-color)'
divElement.style.borderStyle = 'solid'
divElement.style.fontSize = '15px'
divElement.style.height = '21px'
divElement.style.height = '25px'
divElement.style.padding = '1px 6px'
divElement.style.display = 'flex'
divElement.style.position = 'relative'
@@ -210,7 +223,7 @@ class MaskEditorDialog extends ComfyDialog {
divElement.style.borderColor = 'var(--border-color)'
divElement.style.borderStyle = 'solid'
divElement.style.fontSize = '15px'
divElement.style.height = '21px'
divElement.style.height = '25px'
divElement.style.padding = '1px 6px'
divElement.style.display = 'flex'
divElement.style.position = 'relative'
@@ -233,8 +246,77 @@ class MaskEditorDialog extends ComfyDialog {
return divElement
}
createPointerTypeSelect(self: any): HTMLDivElement {
const divElement = document.createElement('div')
divElement.id = 'maskeditor-pointer-type'
divElement.style.cssFloat = 'left'
divElement.style.fontFamily = 'sans-serif'
divElement.style.marginRight = '4px'
divElement.style.color = 'var(--input-text)'
divElement.style.backgroundColor = 'var(--comfy-input-bg)'
divElement.style.borderRadius = '8px'
divElement.style.borderColor = 'var(--border-color)'
divElement.style.borderStyle = 'solid'
divElement.style.fontSize = '15px'
divElement.style.height = '25px'
divElement.style.padding = '1px 6px'
divElement.style.display = 'flex'
divElement.style.position = 'relative'
divElement.style.top = '2px'
divElement.style.pointerEvents = 'auto'
const labelElement = document.createElement('label')
labelElement.textContent = 'Pointer Type:'
const selectElement = document.createElement('select')
selectElement.style.borderRadius = '0'
selectElement.style.borderColor = 'transparent'
selectElement.style.borderStyle = 'unset'
selectElement.style.fontSize = '0.9em'
const optionArc = document.createElement('option')
optionArc.value = 'arc'
optionArc.text = 'Circle'
optionArc.selected = true // Fix for TypeScript, "selected" should be boolean
const optionRect = document.createElement('option')
optionRect.value = 'rect'
optionRect.text = 'Square'
selectElement.appendChild(optionArc)
selectElement.appendChild(optionRect)
selectElement.addEventListener('change', (event: Event) => {
const target = event.target as HTMLSelectElement
self.pointer_type = target.value
this.setBrushBorderRadius(self)
})
divElement.appendChild(labelElement)
divElement.appendChild(selectElement)
return divElement
}
setBrushBorderRadius(self: any): void {
if (self.pointer_type === PointerType.Rect) {
this.brush.style.borderRadius = '0%'
// @ts-expect-error
this.brush.style.MozBorderRadius = '0%'
// @ts-expect-error
this.brush.style.WebkitBorderRadius = '0%'
} else {
this.brush.style.borderRadius = '50%'
// @ts-expect-error
this.brush.style.MozBorderRadius = '50%'
// @ts-expect-error
this.brush.style.WebkitBorderRadius = '50%'
}
}
setlayout(imgCanvas: HTMLCanvasElement, maskCanvas: HTMLCanvasElement) {
const self = this
self.pointer_type = PointerType.Arc
// If it is specified as relative, using it only as a hidden placeholder for padding is recommended
// to prevent anomalies where it exceeds a certain size and goes outside of the window.
@@ -251,15 +333,11 @@ class MaskEditorDialog extends ComfyDialog {
brush.style.backgroundColor = 'transparent'
brush.style.outline = '1px dashed black'
brush.style.boxShadow = '0 0 0 1px white'
brush.style.borderRadius = '50%'
// @ts-expect-error
brush.style.MozBorderRadius = '50%'
// @ts-expect-error
brush.style.WebkitBorderRadius = '50%'
brush.style.position = 'absolute'
brush.style.zIndex = '8889'
brush.style.pointerEvents = 'none'
this.brush = brush
this.setBrushBorderRadius(self)
this.element.appendChild(imgCanvas)
this.element.appendChild(maskCanvas)
this.element.appendChild(bottom_panel)
@@ -294,6 +372,7 @@ class MaskEditorDialog extends ComfyDialog {
}
)
this.brush_pointer_type_select = this.createPointerTypeSelect(self)
this.colorButton = this.createLeftButton(this.getColorButtonText(), () => {
if (self.brush_color_mode === 'black') {
self.brush_color_mode = 'white'
@@ -325,6 +404,7 @@ class MaskEditorDialog extends ComfyDialog {
bottom_panel.appendChild(cancelButton)
bottom_panel.appendChild(this.brush_size_slider)
bottom_panel.appendChild(this.brush_opacity_slider)
bottom_panel.appendChild(this.brush_pointer_type_select)
bottom_panel.appendChild(this.colorButton)
imgCanvas.style.position = 'absolute'
@@ -815,19 +895,14 @@ class MaskEditorDialog extends ComfyDialog {
if (diff > 20 && !this.drawing_mode)
requestAnimationFrame(() => {
self.maskCtx.beginPath()
self.maskCtx.fillStyle = this.getMaskFillStyle()
self.maskCtx.globalCompositeOperation = 'source-over'
self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false)
self.maskCtx.fill()
self.init_shape(self, CompositionOperation.SourceOver)
self.draw_shape(self, x, y, brush_size)
self.lastx = x
self.lasty = y
})
else
requestAnimationFrame(() => {
self.maskCtx.beginPath()
self.maskCtx.fillStyle = this.getMaskFillStyle()
self.maskCtx.globalCompositeOperation = 'source-over'
self.init_shape(self, CompositionOperation.SourceOver)
var dx = x - self.lastx
var dy = y - self.lasty
@@ -839,8 +914,7 @@ class MaskEditorDialog extends ComfyDialog {
for (var i = 0; i < distance; i += 5) {
var px = self.lastx + directionX * i
var py = self.lasty + directionY * i
self.maskCtx.arc(px, py, brush_size, 0, Math.PI * 2, false)
self.maskCtx.fill()
self.draw_shape(self, px, py, brush_size)
}
self.lastx = x
self.lasty = y
@@ -873,17 +947,14 @@ class MaskEditorDialog extends ComfyDialog {
if (diff > 20 && !this.drawing_mode)
// cannot tracking drawing_mode for touch event
requestAnimationFrame(() => {
self.maskCtx.beginPath()
self.maskCtx.globalCompositeOperation = 'destination-out'
self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false)
self.maskCtx.fill()
self.init_shape(self, CompositionOperation.DestinationOut)
self.draw_shape(self, x, y, brush_size)
self.lastx = x
self.lasty = y
})
else
requestAnimationFrame(() => {
self.maskCtx.beginPath()
self.maskCtx.globalCompositeOperation = 'destination-out'
self.init_shape(self, CompositionOperation.DestinationOut)
var dx = x - self.lastx
var dy = y - self.lasty
@@ -895,8 +966,7 @@ class MaskEditorDialog extends ComfyDialog {
for (var i = 0; i < distance; i += 5) {
var px = self.lastx + directionX * i
var py = self.lasty + directionY * i
self.maskCtx.arc(px, py, brush_size, 0, Math.PI * 2, false)
self.maskCtx.fill()
self.draw_shape(self, px, py, brush_size)
}
self.lastx = x
self.lasty = y
@@ -943,21 +1013,43 @@ class MaskEditorDialog extends ComfyDialog {
(event.offsetY || event.targetTouches[0].clientY - maskRect.top) /
self.zoom_ratio
self.maskCtx.beginPath()
if (!event.altKey && event.button == 0) {
self.maskCtx.fillStyle = this.getMaskFillStyle()
self.maskCtx.globalCompositeOperation = 'source-over'
self.init_shape(self, CompositionOperation.SourceOver)
} else {
self.maskCtx.globalCompositeOperation = 'destination-out'
self.init_shape(self, CompositionOperation.DestinationOut)
}
self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false)
self.maskCtx.fill()
self.draw_shape(self, x, y, brush_size)
self.lastx = x
self.lasty = y
self.lasttime = performance.now()
}
}
init_shape(self, compositionOperation) {
self.maskCtx.beginPath()
if (compositionOperation == CompositionOperation.SourceOver) {
self.maskCtx.fillStyle = this.getMaskFillStyle()
self.maskCtx.globalCompositeOperation = CompositionOperation.SourceOver
} else if (compositionOperation == CompositionOperation.DestinationOut) {
self.maskCtx.globalCompositeOperation =
CompositionOperation.DestinationOut
}
}
draw_shape(self, x, y, brush_size) {
if (self.pointer_type === PointerType.Rect) {
self.maskCtx.rect(
x - brush_size,
y - brush_size,
brush_size * 2,
brush_size * 2
)
} else {
self.maskCtx.arc(x, y, brush_size, 0, Math.PI * 2, false)
}
self.maskCtx.fill()
}
async save() {
const backupCanvas = document.createElement('canvas')
const backupCtx = backupCanvas.getContext('2d', {
@@ -997,7 +1089,7 @@ class MaskEditorDialog extends ComfyDialog {
backupData.data[i + 2] = 0
}
backupCtx.globalCompositeOperation = 'source-over'
backupCtx.globalCompositeOperation = CompositionOperation.SourceOver
backupCtx.putImageData(backupData, 0, 0)
const formData = new FormData()

View File

@@ -23,38 +23,67 @@ function isCoreNode(node: ComfyLGraphNode) {
return getNodeSource(node)?.type === 'core'
}
function getNodeIdBadge(node: ComfyLGraphNode, nodeIdBadgeMode: NodeBadgeMode) {
return nodeIdBadgeMode === NodeBadgeMode.None ||
(isCoreNode(node) && nodeIdBadgeMode === NodeBadgeMode.HideBuiltIn)
? ''
: `#${node.id}`
function badgeTextVisible(
node: ComfyLGraphNode,
badgeMode: NodeBadgeMode
): boolean {
return (
badgeMode === NodeBadgeMode.None ||
(isCoreNode(node) && badgeMode === NodeBadgeMode.HideBuiltIn)
)
}
function getNodeSourceBadge(
function getNodeIdBadgeText(
node: ComfyLGraphNode,
nodeIdBadgeMode: NodeBadgeMode
) {
return badgeTextVisible(node, nodeIdBadgeMode) ? '' : `#${node.id}`
}
function getNodeSourceBadgeText(
node: ComfyLGraphNode,
nodeSourceBadgeMode: NodeBadgeMode
) {
const nodeSource = getNodeSource(node)
return nodeSourceBadgeMode === NodeBadgeMode.None ||
(isCoreNode(node) && nodeSourceBadgeMode === NodeBadgeMode.HideBuiltIn)
return badgeTextVisible(node, nodeSourceBadgeMode)
? ''
: nodeSource?.badgeText ?? ''
}
function getNodeLifeCycleBadgeText(
node: ComfyLGraphNode,
nodeLifeCycleBadgeMode: NodeBadgeMode
) {
let text = ''
const nodeDef = (node.constructor as typeof ComfyLGraphNode).nodeData
// Frontend-only nodes don't have nodeDef
if (!nodeDef) {
return ''
}
if (nodeDef.deprecated) {
text = '[DEPR]'
}
if (nodeDef.experimental) {
text = '[BETA]'
}
return badgeTextVisible(node, nodeLifeCycleBadgeMode) ? '' : text
}
class NodeBadgeExtension implements ComfyExtension {
name = 'Comfy.NodeBadge'
constructor(
public nodeIdBadgeMode: ComputedRef<NodeBadgeMode> | null = null,
public nodeSourceBadgeMode: ComputedRef<NodeBadgeMode> | null = null,
public nodeLifeCycleBadgeMode: ComputedRef<NodeBadgeMode> | null = null,
public colorPalette: ComputedRef<Palette> | null = null
) {}
init(app: ComfyApp) {
if (!app.vueAppReady) {
return
}
const settingStore = useSettingStore()
this.nodeSourceBadgeMode = computed(
() =>
@@ -63,6 +92,12 @@ class NodeBadgeExtension implements ComfyExtension {
this.nodeIdBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
)
this.nodeLifeCycleBadgeMode = computed(
() =>
settingStore.get(
'Comfy.NodeBadge.NodeLifeCycleBadgeMode'
) as NodeBadgeMode
)
this.colorPalette = computed(() =>
getColorPalette(settingStore.get('Comfy.ColorPalette'))
)
@@ -74,13 +109,12 @@ class NodeBadgeExtension implements ComfyExtension {
watch(this.nodeIdBadgeMode, () => {
app.graph.setDirtyCanvas(true, true)
})
watch(this.nodeLifeCycleBadgeMode, () => {
app.graph.setDirtyCanvas(true, true)
})
}
nodeCreated(node: ComfyLGraphNode, app: ComfyApp) {
if (!app.vueAppReady) {
return
}
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
@@ -90,13 +124,17 @@ class NodeBadgeExtension implements ComfyExtension {
new LGraphBadge({
text: _.truncate(
[
getNodeIdBadge(node, this.nodeIdBadgeMode.value),
getNodeSourceBadge(node, this.nodeSourceBadgeMode.value)
getNodeIdBadgeText(node, this.nodeIdBadgeMode.value),
getNodeLifeCycleBadgeText(
node,
this.nodeLifeCycleBadgeMode.value
),
getNodeSourceBadgeText(node, this.nodeSourceBadgeMode.value)
]
.filter((s) => s.length > 0)
.join(' '),
{
length: 25
length: 31
}
),
fgColor:

View File

@@ -130,7 +130,6 @@ app.registerExtension({
: null
if (
inputType &&
// @ts-expect-error Will self-resolve when LiteGraph types are generated
!LiteGraph.isValidConnection(inputType, nodeOutType)
) {
// The output doesnt match our input so disconnect it

View File

@@ -34,7 +34,7 @@ app.registerExtension({
'When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.',
defaultValue: LiteGraph.CANVAS_GRID_SIZE,
onChange(value) {
LiteGraph.CANVAS_GRID_SIZE = +value
LiteGraph.CANVAS_GRID_SIZE = +value || 10
}
})
@@ -137,7 +137,7 @@ app.registerExtension({
// After moving a group (while app.shiftDown), snap all the child nodes and, finally,
// align the group itself.
this.recomputeInsideNodes()
for (const node of this._nodes) {
for (const node of this.nodes) {
node.alignToGrid()
}
LGraphNode.prototype.alignToGrid.apply(this)
@@ -178,8 +178,7 @@ app.registerExtension({
LGraphCanvas.onGroupAdd = function () {
const v = onGroupAdd.apply(app.canvas, arguments)
if (app.shiftDown) {
// @ts-expect-error
const lastGroup = app.graph._groups[app.graph._groups.length - 1]
const lastGroup = app.graph.groups[app.graph.groups.length - 1]
if (lastGroup) {
roundVectorToGrid(lastGroup.pos)
roundVectorToGrid(lastGroup.size)

View File

@@ -751,6 +751,7 @@ app.registerExtension({
nodeType.prototype.onGraphConfigured = function () {
if (!this.inputs) return
this.widgets ??= []
for (const input of this.inputs) {
if (input.widget) {
@@ -826,8 +827,7 @@ app.registerExtension({
}
function isNodeAtPos(pos) {
// @ts-expect-error Fix litegraph types
for (const n of app.graph._nodes) {
for (const n of app.graph.nodes) {
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
return true
}

28
src/hooks/errorHooks.ts Normal file
View File

@@ -0,0 +1,28 @@
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
export function useErrorHandling() {
const toast = useToast()
const { t } = useI18n()
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
})
}
}
}
return { wrapWithErrorHandling }
}

View File

@@ -1,9 +1,7 @@
import { ref } from 'vue'
import { Ref } from 'vue'
import type { TreeNode } from 'primevue/treenode'
export function useTreeExpansion() {
const expandedKeys = ref<Record<string, boolean>>({})
export function useTreeExpansion(expandedKeys: Ref<Record<string, boolean>>) {
const toggleNode = (node: TreeNode) => {
if (node.key && typeof node.key === 'string') {
if (node.key in expandedKeys.value) {
@@ -63,7 +61,6 @@ export function useTreeExpansion() {
}
return {
expandedKeys,
toggleNode,
toggleNodeRecursive,
expandNode,

View File

@@ -54,7 +54,8 @@ const messages = {
showFlatList: 'Show Flat List',
backToAllTasks: 'Back to All Tasks',
containImagePreview: 'Fill Image Preview',
coverImagePreview: 'Fit Image Preview'
coverImagePreview: 'Fit Image Preview',
clearPendingTasks: 'Clear Pending Tasks'
}
}
},
@@ -97,7 +98,8 @@ const messages = {
},
queueTab: {
showFlatList: '平铺结果',
backToAllTasks: '返回'
backToAllTasks: '返回',
clearPendingTasks: '清除待处理任务'
}
}
}

View File

@@ -11,7 +11,8 @@ import {
PromptResponse,
SystemStats,
User,
Settings
Settings,
UserDataFullInfo
} from '@/types/apiTypes'
import axios from 'axios'
@@ -671,6 +672,19 @@ class ComfyApi extends EventTarget {
return resp.json()
}
async listUserDataFullInfo(dir: string): Promise<UserDataFullInfo[]> {
const resp = await this.fetchApi(
`/userdata?dir=${encodeURIComponent(dir)}&recurse=true&split=false&full_info=true`
)
if (resp.status === 404) return []
if (resp.status !== 200) {
throw new Error(
`Error getting user data list '${dir}': ${resp.status} ${resp.statusText}`
)
}
return resp.json()
}
async getLogs(): Promise<string> {
return (await axios.get(this.internalURL('/logs'))).data
}

View File

@@ -52,6 +52,7 @@ 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'
@@ -119,7 +120,6 @@ export class ComfyApp {
// x, y, scale
zoom_drag_start: [number, number, number] | null
lastNodeErrors: any[] | null
runningNodeId: number | null
lastExecutionError: { node_id: number } | null
progress: { value: number; max: number } | null
configuringGraph: boolean
@@ -136,6 +136,12 @@ export class ComfyApp {
canvasContainer: HTMLElement
menu: ComfyAppMenu
// @deprecated
// Use useExecutionStore().executingNodeId instead
get runningNodeId(): string | null {
return useExecutionStore().executingNodeId
}
constructor() {
this.vueAppReady = false
this.ui = new ComfyUI(this)
@@ -1390,7 +1396,7 @@ export class ComfyApp {
return
}
var groups = this.graph._groups
var groups = this.graph.groups
ctx.save()
ctx.globalAlpha = 0.7 * this.editor_alpha
@@ -1591,36 +1597,17 @@ export class ComfyApp {
)
api.addEventListener('progress', ({ detail }) => {
if (
this.workflowManager.activePrompt?.workflow &&
this.workflowManager.activePrompt.workflow !==
this.workflowManager.activeWorkflow
)
return
this.progress = detail
this.graph.setDirtyCanvas(true, false)
})
api.addEventListener('executing', ({ detail }) => {
if (
this.workflowManager.activePrompt?.workflow &&
this.workflowManager.activePrompt.workflow !==
this.workflowManager.activeWorkflow
)
return
this.progress = null
this.runningNodeId = detail
this.graph.setDirtyCanvas(true, false)
delete this.nodePreviewImages[this.runningNodeId]
})
api.addEventListener('executed', ({ detail }) => {
if (
this.workflowManager.activePrompt?.workflow &&
this.workflowManager.activePrompt.workflow !==
this.workflowManager.activeWorkflow
)
return
const output = this.nodeOutputs[detail.display_node || detail.node]
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
@@ -1644,10 +1631,8 @@ export class ComfyApp {
})
api.addEventListener('execution_start', ({ detail }) => {
this.runningNodeId = null
this.lastExecutionError = null
// @ts-expect-error
this.graph._nodes.forEach((node) => {
this.graph.nodes.forEach((node) => {
// @ts-expect-error
if (node.onExecutionStart)
// @ts-expect-error
@@ -1704,8 +1689,7 @@ export class ComfyApp {
// @ts-expect-error
app.graph.onConfigure = function () {
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
// @ts-expect-error
for (const node of app.graph._nodes) {
for (const node of app.graph.nodes) {
// @ts-expect-error
node.onGraphConfigured?.()
}
@@ -1713,8 +1697,7 @@ export class ComfyApp {
const r = onConfigure?.apply(this, arguments)
// Fire after onConfigure, used by primitives to generate widget using input nodes config
// @ts-expect-error _nodes is private.
for (const node of app.graph._nodes) {
for (const node of app.graph.nodes) {
node.onAfterGraphConfigured?.()
}
@@ -1912,9 +1895,7 @@ export class ComfyApp {
// Save current workflow automatically
setInterval(() => {
const sortNodes =
this.vueAppReady &&
useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
const workflow = JSON.stringify(this.graph.serialize({ sortNodes }))
localStorage.setItem('workflow', workflow)
if (api.clientId) {
@@ -2151,10 +2132,7 @@ export class ComfyApp {
}
showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
if (
this.vueAppReady &&
useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')
) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
showLoadWorkflowWarning({
missingNodeTypes,
hasAddedNodes,
@@ -2168,10 +2146,7 @@ export class ComfyApp {
}
showMissingModelsError(missingModels) {
if (
this.vueAppReady &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')) {
showMissingModelsWarning({
missingModels,
maximizable: true
@@ -2227,10 +2202,7 @@ export class ComfyApp {
console.error(error)
}
if (
this.vueAppReady &&
useSettingStore().get('Comfy.Validation.Workflows')
) {
if (useSettingStore().get('Comfy.Validation.Workflows')) {
// TODO: Show validation error in a dialog.
const validatedGraphData = await validateComfyWorkflow(
graphData,
@@ -2348,8 +2320,7 @@ export class ComfyApp {
return
}
// @ts-expect-error
for (const node of this.graph._nodes) {
for (const node of this.graph.nodes) {
const size = node.computeSize()
size[0] = Math.max(node.size[0], size[0])
size[1] = Math.max(node.size[1], size[1])
@@ -2434,9 +2405,8 @@ export class ComfyApp {
}
}
const sortNodes =
this.vueAppReady &&
useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
const workflow = graph.serialize({ sortNodes })
const output = {}
// Process nodes in order of execution
@@ -2907,10 +2877,8 @@ export class ComfyApp {
for (const nodeId in defs) {
this.registerNodeDef(nodeId, defs[nodeId])
}
// @ts-expect-error
for (let nodeNum in this.graph._nodes) {
// @ts-expect-error
const node = this.graph._nodes[nodeNum]
for (let nodeNum in this.graph.nodes) {
const node = this.graph.nodes[nodeNum]
const def = defs[node.type]
// @ts-expect-error
// Allow primitive nodes to handle refresh
@@ -2966,7 +2934,6 @@ export class ComfyApp {
this.nodePreviewImages = {}
this.lastNodeErrors = null
this.lastExecutionError = null
this.runningNodeId = null
}
addNodeOnGraph(

View File

@@ -204,7 +204,8 @@ export class ChangeTracker {
// Store node outputs
api.addEventListener('executed', ({ detail }) => {
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id]
const prompt =
app.workflowManager.executionStore.queuedPrompts[detail.prompt_id]
if (!prompt?.workflow) return
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {})
const output = nodeOutputs[detail.node]

View File

@@ -224,8 +224,8 @@ const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
//@ts-ignore
LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
const visibleNodes = computeVisibleNodes.apply(this, arguments)
// @ts-expect-error
for (const node of app.graph._nodes) {
for (const node of app.graph.nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets) {
@@ -354,8 +354,7 @@ LGraphNode.prototype.addDOMWidget = function (
width: `${widgetWidth - margin * 2}px`,
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
position: 'absolute',
// @ts-expect-error
zIndex: app.graph._nodes.indexOf(node)
zIndex: app.graph.nodes.indexOf(node)
})
if (enableDomClipping) {

View File

@@ -372,83 +372,10 @@ export class ComfyUI {
this.history.update()
})
const confirmClear = this.settings.addSetting({
id: 'Comfy.ConfirmClear',
category: ['Comfy', 'Workflow', 'ConfirmClear'],
name: 'Require confirmation when clearing workflow',
type: 'boolean',
defaultValue: true
})
const promptFilename = this.settings.addSetting({
id: 'Comfy.PromptFilename',
category: ['Comfy', 'Workflow', 'PromptFilename'],
name: 'Prompt for filename when saving workflow',
type: 'boolean',
defaultValue: true
})
/**
* file format for preview
*
* format;quality
*
* ex)
* webp;50 -> webp, quality 50
* jpeg;80 -> rgb, jpeg, quality 80
*
* @type {string}
*/
const previewImage = this.settings.addSetting({
id: 'Comfy.PreviewFormat',
category: ['Comfy', 'Node Widget', 'PreviewFormat'],
name: 'Preview image format',
tooltip:
'When displaying a preview in the image widget, convert it to a lightweight image, e.g. webp, jpeg, webp;50, etc.',
type: 'text',
defaultValue: ''
})
this.settings.addSetting({
id: 'Comfy.DisableSliders',
category: ['Comfy', 'Node Widget', 'DisableSliders'],
name: 'Disable node widget sliders',
type: 'boolean',
defaultValue: false
})
this.settings.addSetting({
id: 'Comfy.DisableFloatRounding',
category: ['Comfy', 'Node Widget', 'DisableFloatRounding'],
name: 'Disable default float widget rounding.',
tooltip:
'(requires page reload) Cannot disable round when round is set by the node in the backend.',
type: 'boolean',
defaultValue: false
})
this.settings.addSetting({
id: 'Comfy.FloatRoundingPrecision',
category: ['Comfy', 'Node Widget', 'FloatRoundingPrecision'],
name: 'Float widget rounding decimal places [0 = auto].',
tooltip: '(requires page reload)',
type: 'slider',
attrs: {
min: 0,
max: 6,
step: 1
},
defaultValue: 0
})
this.settings.addSetting({
id: 'Comfy.EnableTooltips',
category: ['Comfy', 'Node', 'EnableTooltips'],
name: 'Enable Tooltips',
type: 'boolean',
defaultValue: true
})
this.setup(document.body)
}
setup(containerElement: HTMLElement) {
const fileInput = $el('input', {
id: 'comfy-file-input',
type: 'file',
@@ -497,7 +424,7 @@ export class ComfyUI {
this.menuHamburger = $el(
'div.comfy-menu-hamburger',
{
parent: document.body,
parent: containerElement,
onclick: () => {
this.menuContainer.style.display = 'block'
this.menuHamburger.style.display = 'none'
@@ -506,7 +433,7 @@ export class ComfyUI {
[$el('div'), $el('div'), $el('div')]
) as HTMLDivElement
this.menuContainer = $el('div.comfy-menu', { parent: document.body }, [
this.menuContainer = $el('div.comfy-menu', { parent: containerElement }, [
$el(
'div.drag-handle.comfy-menu-header',
{
@@ -661,7 +588,7 @@ export class ComfyUI {
textContent: 'Save',
onclick: () => {
let filename = 'workflow.json'
if (promptFilename.value) {
if (useSettingStore().get('Comfy.PromptFilename')) {
filename = prompt('Save workflow as:', filename)
if (!filename) return
if (!filename.toLowerCase().endsWith('.json')) {
@@ -692,7 +619,7 @@ export class ComfyUI {
style: { width: '100%', display: 'none' },
onclick: () => {
let filename = 'workflow_api.json'
if (promptFilename.value) {
if (useSettingStore().get('Comfy.PromptFilename')) {
filename = prompt('Save workflow (API) as:', filename)
if (!filename) return
if (!filename.toLowerCase().endsWith('.json')) {
@@ -730,13 +657,17 @@ export class ComfyUI {
$el('button', {
id: 'comfy-clipspace-button',
textContent: 'Clipspace',
// @ts-expect-error Move to ComfyApp
onclick: () => app.openClipspace()
}),
$el('button', {
id: 'comfy-clear-button',
textContent: 'Clear',
onclick: () => {
if (!confirmClear.value || confirm('Clear workflow?')) {
if (
!useSettingStore().get('Comfy.ConfirmClear') ||
confirm('Clear workflow?')
) {
app.clean()
app.graph.clear()
app.resetView()
@@ -748,7 +679,10 @@ export class ComfyUI {
id: 'comfy-load-default-button',
textContent: 'Load Default',
onclick: async () => {
if (!confirmClear.value || confirm('Load default workflow?')) {
if (
!useSettingStore().get('Comfy.ConfirmClear') ||
confirm('Load default workflow?')
) {
app.resetView()
await app.loadGraphData()
}
@@ -789,17 +723,6 @@ export class ComfyUI {
})
]) as HTMLDivElement
this.settings.addSetting({
id: 'Comfy.DevMode',
name: 'Enable dev mode options (API save, etc.)',
type: 'boolean',
defaultValue: false,
onChange: function (value) {
document.getElementById('comfy-dev-save-api-button').style.display =
value ? 'flex' : 'none'
}
})
this.restoreMenuPosition = dragElement(this.menuContainer, this.settings)
this.setStatus({ exec_info: { queue_remaining: 'X' } })

View File

@@ -181,7 +181,9 @@ export class ComfyAppMenu {
resizeHandler = null
}
document.body.style.removeProperty('display')
app.ui.menuContainer.style.removeProperty('display')
if (app.ui.menuContainer) {
app.ui.menuContainer.style.removeProperty('display')
}
this.element.style.display = 'none'
app.ui.restoreMenuPosition()
}
@@ -192,7 +194,9 @@ export class ComfyAppMenu {
updatePosition(v: MenuPosition) {
document.body.style.display = 'grid'
this.app.ui.menuContainer.style.display = 'none'
if (this.app.ui.menuContainer) {
this.app.ui.menuContainer.style.display = 'none'
}
this.element.style.removeProperty('display')
this.position = v
if (v === 'Bottom') {

View File

@@ -4,10 +4,12 @@ import { $el } from '../../ui'
import { api } from '../../api'
import { ComfyPopup } from '../components/popup'
import { createSpinner } from '../spinner'
import { ComfyWorkflow, trimJsonExt } from '../../workflows'
import { ComfyWorkflow } from '../../workflows'
import { ComfyAsyncDialog } from '../components/asyncDialog'
import { trimJsonExt } from '@/utils/formatUtil'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyComponent } from '../components'
import { useWorkflowStore } from '@/stores/workflowStore'
export class ComfyWorkflowsMenu {
#first = true
@@ -67,19 +69,12 @@ export class ComfyWorkflowsMenu {
this.unsaved = prop(this, 'unsaved', classList.unsaved, (v) => {
classList.unsaved = v
this.button.classList = classList
setStorageValue('Comfy.PreviousWorkflowUnsaved', v)
})
}
setStorageValue('Comfy.PreviousWorkflowUnsaved', String(v))
#updateProgress = () => {
const prompt = this.app.workflowManager.activePrompt
let percent = 0
if (this.app.workflowManager.activeWorkflow === prompt?.workflow) {
const total = Object.values(prompt.nodes)
const done = total.filter(Boolean)
percent = (done.length / total.length) * 100
}
this.buttonProgress.style.width = percent + '%'
if (this.app.vueAppReady) {
useWorkflowStore().previousWorkflowUnsaved = v
}
})
}
#updateActive = () => {
@@ -93,8 +88,6 @@ export class ComfyWorkflowsMenu {
this.#first = false
this.content.load()
}
this.#updateProgress()
}
#bindEvents() {
@@ -109,10 +102,6 @@ export class ComfyWorkflowsMenu {
this.unsaved = this.app.workflowManager.activeWorkflow.unsaved
})
this.app.workflowManager.addEventListener('execute', (e) => {
this.#updateProgress()
})
api.addEventListener('graphChanged', () => {
this.unsaved = true
})
@@ -371,9 +360,6 @@ export class ComfyWorkflowsContent {
app.workflowManager.addEventListener(e, () => this.updateOpen())
}
this.app.workflowManager.addEventListener('rename', () => this.load())
this.app.workflowManager.addEventListener('execute', (e) =>
this.#updateActive()
)
}
async load() {

View File

@@ -56,14 +56,12 @@ export function applyTextReplacements(app: ComfyApp, value: string): string {
}
// Find node with matching S&R property name
// @ts-expect-error
let nodes = app.graph._nodes.filter(
let nodes = app.graph.nodes.filter(
(n) => n.properties?.['Node name for S&R'] === split[0]
)
// If we cant, see if there is a node with that title
if (!nodes.length) {
// @ts-expect-error
nodes = app.graph._nodes.filter((n) => n.title === split[0])
nodes = app.graph.nodes.filter((n) => n.title === split[0])
}
if (!nodes.length) {
console.warn('Unable to find node', split[0])
@@ -155,7 +153,7 @@ export function prop<T>(
return defaultValue
}
export function getStorageValue(id) {
export function getStorageValue(id: string) {
const clientId = api.clientId ?? api.initialClientId
return (
(clientId && sessionStorage.getItem(`${id}:${clientId}`)) ??
@@ -163,7 +161,7 @@ export function getStorageValue(id) {
)
}
export function setStorageValue(id, value) {
export function setStorageValue(id: string, value: string) {
const clientId = api.clientId ?? api.initialClientId
if (clientId) {
sessionStorage.setItem(`${id}:${clientId}`, value)

View File

@@ -361,7 +361,7 @@ export function initWidgets(app) {
options: ['before', 'after'],
onChange(value) {
controlValueRunBefore = value === 'before'
for (const n of app.graph._nodes) {
for (const n of app.graph.nodes) {
if (!n.widgets) continue
for (const w of n.widgets) {
if (w[IS_CONTROL_WIDGET]) {

View File

@@ -4,93 +4,56 @@ import { ChangeTracker } from './changeTracker'
import { ComfyAsyncDialog } from './ui/components/asyncDialog'
import { getStorageValue, setStorageValue } from './utils'
import { LGraphCanvas, LGraph } from '@comfyorg/litegraph'
function appendJsonExt(path: string) {
if (!path.toLowerCase().endsWith('.json')) {
path += '.json'
}
return path
}
export function trimJsonExt(path: string) {
return path?.replace(/\.json$/, '')
}
import { appendJsonExt, trimJsonExt } from '@/utils/formatUtil'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { markRaw, toRaw } from 'vue'
export class ComfyWorkflowManager extends EventTarget {
#activePromptId: string | null = null
#unsavedCount = 0
#activeWorkflow: ComfyWorkflow
executionStore: ReturnType<typeof useExecutionStore> | null
workflowStore: ReturnType<typeof useWorkflowStore> | null
workflowLookup: Record<string, ComfyWorkflow> = {}
workflows: Array<ComfyWorkflow> = []
openWorkflows: Array<ComfyWorkflow> = []
queuedPrompts: Record<
string,
{ workflow?: ComfyWorkflow; nodes?: Record<string, boolean> }
> = {}
app: ComfyApp
#unsavedCount = 0
get activeWorkflow() {
return this.#activeWorkflow ?? this.openWorkflows[0]
get workflowLookup(): Record<string, ComfyWorkflow> {
return this.workflowStore?.workflowLookup ?? {}
}
get workflows(): ComfyWorkflow[] {
return this.workflowStore?.workflows ?? []
}
get openWorkflows(): ComfyWorkflow[] {
return (this.workflowStore?.openWorkflows ?? []) as ComfyWorkflow[]
}
get _activeWorkflow(): ComfyWorkflow | null {
if (!this.app.vueAppReady) return null
return toRaw(useWorkflowStore().activeWorkflow) as ComfyWorkflow | null
}
set _activeWorkflow(workflow: ComfyWorkflow | null) {
if (!this.app.vueAppReady) return
useWorkflowStore().activeWorkflow = workflow ? markRaw(workflow) : null
}
get activeWorkflow(): ComfyWorkflow | null {
return this._activeWorkflow ?? this.openWorkflows[0]
}
get activePromptId() {
return this.#activePromptId
return this.executionStore?.activePromptId
}
get activePrompt() {
return this.queuedPrompts[this.#activePromptId]
return this.executionStore?.activePrompt
}
constructor(app: ComfyApp) {
super()
this.app = app
ChangeTracker.init(app)
this.#bindExecutionEvents()
}
#bindExecutionEvents() {
// TODO: on reload, set active prompt based on the latest ws message
const emit = () =>
this.dispatchEvent(
new CustomEvent('execute', { detail: this.activePrompt })
)
let executing = null
api.addEventListener('execution_start', (e) => {
this.#activePromptId = e.detail.prompt_id
// This event can fire before the event is stored, so put a placeholder
this.queuedPrompts[this.#activePromptId] ??= { nodes: {} }
emit()
})
api.addEventListener('execution_cached', (e) => {
if (!this.activePrompt) return
for (const n of e.detail.nodes) {
this.activePrompt.nodes[n] = true
}
emit()
})
api.addEventListener('executed', (e) => {
if (!this.activePrompt) return
this.activePrompt.nodes[e.detail.node] = true
emit()
})
api.addEventListener('executing', (e) => {
if (!this.activePrompt) return
if (executing) {
// Seems sometimes nodes that are cached fire executing but not executed
this.activePrompt.nodes[executing] = true
}
executing = e.detail
if (!executing) {
delete this.queuedPrompts[this.#activePromptId]
this.#activePromptId = null
}
emit()
})
}
async loadWorkflows() {
@@ -115,16 +78,13 @@ export class ComfyWorkflowManager extends EventTarget {
w.slice(1),
favorites.has(w[0])
)
this.workflowLookup[workflow.path] = workflow
this.workflowLookup[workflow.path] = markRaw(workflow)
}
return workflow
}
)
this.workflows = workflows
} catch (error) {
alert('Error loading workflows: ' + (error.message ?? error))
this.workflows = []
}
}
@@ -164,25 +124,21 @@ export class ComfyWorkflowManager extends EventTarget {
const index = this.openWorkflows.indexOf(workflow)
if (index === -1) {
// Opening a new workflow
this.openWorkflows.push(workflow)
this.openWorkflows.push(markRaw(workflow))
}
this.#activeWorkflow = workflow
this._activeWorkflow = workflow
setStorageValue('Comfy.PreviousWorkflow', this.activeWorkflow.path ?? '')
this.dispatchEvent(new CustomEvent('changeWorkflow'))
}
storePrompt({ nodes, id }) {
this.queuedPrompts[id] ??= {}
this.queuedPrompts[id].nodes = {
...nodes.reduce((p, n) => {
p[n] = false
return p
}, {}),
...this.queuedPrompts[id].nodes
}
this.queuedPrompts[id].workflow = this.activeWorkflow
this.executionStore?.storePrompt({
nodes,
id,
workflow: this.activeWorkflow
})
}
/**
@@ -219,8 +175,8 @@ export class ComfyWorkflowManager extends EventTarget {
workflow.changeTracker = null
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1)
if (this.openWorkflows.length) {
this.#activeWorkflow = this.openWorkflows[0]
await this.#activeWorkflow.load()
this._activeWorkflow = this.openWorkflows[0]
await this._activeWorkflow.load()
} else {
// Load default
await this.app.loadGraphData()

348
src/stores/coreSettings.ts Normal file
View File

@@ -0,0 +1,348 @@
import { NodeBadgeMode } from '@/types/nodeSource'
import {
LinkReleaseTriggerAction,
LinkReleaseTriggerMode
} from '@/types/searchBoxTypes'
import type { SettingParams } from '@/types/settingTypes'
export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.Validation.Workflows',
name: 'Validate workflows',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.NodeSearchBoxImpl',
category: ['Comfy', 'Node Search Box', 'Implementation'],
experimental: true,
name: 'Node search box implementation',
type: 'combo',
options: ['default', 'litegraph (legacy)'],
defaultValue: 'default'
},
{
id: 'Comfy.NodeSearchBoxImpl.LinkReleaseTrigger',
category: ['Comfy', 'Node Search Box', 'LinkReleaseTrigger'],
name: 'Trigger on link release',
type: 'hidden',
options: Object.values(LinkReleaseTriggerMode),
defaultValue: LinkReleaseTriggerMode.ALWAYS,
deprecated: true
},
{
id: 'Comfy.LinkRelease.Action',
name: 'Action on link release (No modifier)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU
},
{
id: 'Comfy.LinkRelease.ActionShift',
name: 'Action on link release (Shift)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX
},
{
id: 'Comfy.NodeSearchBoxImpl.NodePreview',
category: ['Comfy', 'Node Search Box', 'NodePreview'],
name: 'Node preview',
tooltip: 'Only applies to the default implementation',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.NodeSearchBoxImpl.ShowCategory',
category: ['Comfy', 'Node Search Box', 'ShowCategory'],
name: 'Show node category in search results',
tooltip: 'Only applies to the default implementation',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.NodeSearchBoxImpl.ShowIdName',
category: ['Comfy', 'Node Search Box', 'ShowIdName'],
name: 'Show node id name in search results',
tooltip: 'Only applies to the default implementation',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.Sidebar.Location',
category: ['Comfy', 'Sidebar', 'Location'],
name: 'Sidebar location',
type: 'combo',
options: ['left', 'right'],
defaultValue: 'left'
},
{
id: 'Comfy.Sidebar.Size',
category: ['Comfy', 'Sidebar', 'Size'],
name: 'Sidebar size',
type: 'combo',
options: ['normal', 'small'],
defaultValue: window.innerWidth < 1600 ? 'small' : 'normal'
},
{
id: 'Comfy.TextareaWidget.FontSize',
category: ['Comfy', 'Node Widget', 'TextareaWidget', 'FontSize'],
name: 'Textarea widget font size',
type: 'slider',
defaultValue: 10,
attrs: {
min: 8,
max: 24
}
},
{
id: 'Comfy.TextareaWidget.Spellcheck',
category: ['Comfy', 'Node Widget', 'TextareaWidget', 'Spellcheck'],
name: 'Textarea widget spellcheck',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.Workflow.SortNodeIdOnSave',
name: 'Sort node IDs when saving workflow',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.Graph.CanvasInfo',
name: 'Show canvas info (fps, etc.)',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Node.ShowDeprecated',
name: 'Show deprecated nodes in search',
tooltip:
'Deprecated nodes are hidden by default in the UI, but remain functional in existing workflows that use them.',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.Node.ShowExperimental',
name: 'Show experimental nodes in search',
tooltip:
'Experimental nodes are marked as such in the UI and may be subject to significant changes or removal in future versions. Use with caution in production workflows',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Workflow.ShowMissingNodesWarning',
name: 'Show missing nodes warning',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Workflow.ShowMissingModelsWarning',
name: 'Show missing models warning',
type: 'boolean',
defaultValue: false,
experimental: true
},
{
id: 'Comfy.Graph.ZoomSpeed',
name: 'Canvas zoom speed',
type: 'slider',
defaultValue: 1.1,
attrs: {
min: 1.01,
max: 2.5,
step: 0.01
}
},
// Bookmarks are stored in the settings store.
// Bookmarks are in format of category/display_name. e.g. "conditioning/CLIPTextEncode"
{
id: 'Comfy.NodeLibrary.Bookmarks',
name: 'Node library bookmarks with display name (deprecated)',
type: 'hidden',
defaultValue: [],
deprecated: true
},
{
id: 'Comfy.NodeLibrary.Bookmarks.V2',
name: 'Node library bookmarks v2 with unique name',
type: 'hidden',
defaultValue: []
},
// Stores mapping from bookmark folder name to its customization.
{
id: 'Comfy.NodeLibrary.BookmarksCustomization',
name: 'Node library bookmarks customization',
type: 'hidden',
defaultValue: {}
},
// Hidden setting used by the queue for how to fit images
{
id: 'Comfy.Queue.ImageFit',
name: 'Queue image fit',
type: 'hidden',
defaultValue: 'cover'
},
{
id: 'Comfy.Workflow.ModelDownload.AllowedSources',
name: 'Allowed model download sources',
type: 'hidden',
defaultValue: ['https://huggingface.co/', 'https://civitai.com/']
},
{
id: 'Comfy.Workflow.ModelDownload.AllowedSuffixes',
name: 'Allowed model download suffixes',
type: 'hidden',
defaultValue: ['.safetensors', '.sft']
},
{
id: 'Comfy.GroupSelectedNodes.Padding',
name: 'Group selected nodes padding',
type: 'slider',
defaultValue: 10,
attrs: {
min: 0,
max: 100
}
},
{
id: 'Comfy.Node.DoubleClickTitleToEdit',
name: 'Double click node title to edit',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Group.DoubleClickTitleToEdit',
name: 'Double click group title to edit',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Window.UnloadConfirmation',
name: 'Show confirmation when closing window',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.TreeExplorer.ItemPadding',
name: 'Tree explorer item padding',
type: 'slider',
defaultValue: 2,
attrs: {
min: 0,
max: 8,
step: 1
}
},
{
id: 'Comfy.Locale',
name: 'Locale',
type: 'combo',
options: ['en', 'zh'],
defaultValue: navigator.language.split('-')[0] || 'en'
},
{
id: 'Comfy.NodeBadge.NodeSourceBadgeMode',
name: 'Node source badge mode',
type: 'combo',
options: Object.values(NodeBadgeMode),
defaultValue: NodeBadgeMode.HideBuiltIn
},
{
id: 'Comfy.NodeBadge.NodeIdBadgeMode',
name: 'Node ID badge mode',
type: 'combo',
options: [NodeBadgeMode.None, NodeBadgeMode.ShowAll],
defaultValue: NodeBadgeMode.ShowAll
},
{
id: 'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
name: 'Node life cycle badge mode',
type: 'combo',
options: [NodeBadgeMode.None, NodeBadgeMode.ShowAll],
defaultValue: NodeBadgeMode.ShowAll
},
{
id: 'Comfy.ConfirmClear',
category: ['Comfy', 'Workflow', 'ConfirmClear'],
name: 'Require confirmation when clearing workflow',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.PromptFilename',
category: ['Comfy', 'Workflow', 'PromptFilename'],
name: 'Prompt for filename when saving workflow',
type: 'boolean',
defaultValue: true
},
/**
* file format for preview
*
* format;quality
*
* ex)
* webp;50 -> webp, quality 50
* jpeg;80 -> rgb, jpeg, quality 80
*
* @type {string}
*/
{
id: 'Comfy.PreviewFormat',
category: ['Comfy', 'Node Widget', 'PreviewFormat'],
name: 'Preview image format',
tooltip:
'When displaying a preview in the image widget, convert it to a lightweight image, e.g. webp, jpeg, webp;50, etc.',
type: 'text',
defaultValue: ''
},
{
id: 'Comfy.DisableSliders',
category: ['Comfy', 'Node Widget', 'DisableSliders'],
name: 'Disable node widget sliders',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.DisableFloatRounding',
category: ['Comfy', 'Node Widget', 'DisableFloatRounding'],
name: 'Disable default float widget rounding.',
tooltip:
'(requires page reload) Cannot disable round when round is set by the node in the backend.',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.FloatRoundingPrecision',
category: ['Comfy', 'Node Widget', 'FloatRoundingPrecision'],
name: 'Float widget rounding decimal places [0 = auto].',
tooltip: '(requires page reload)',
type: 'slider',
attrs: {
min: 0,
max: 6,
step: 1
},
defaultValue: 0
},
{
id: 'Comfy.EnableTooltips',
category: ['Comfy', 'Node', 'EnableTooltips'],
name: 'Enable Tooltips',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.DevMode',
name: 'Enable dev mode options (API save, etc.)',
type: 'boolean',
defaultValue: false,
onChange: (value) => {
const element = document.getElementById('comfy-dev-save-api-button')
if (element) {
element.style.display = value ? 'flex' : 'none'
}
}
}
]

View File

@@ -0,0 +1,119 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { api } from '../scripts/api'
export interface QueuedPrompt {
nodes: Record<string, boolean>
workflow?: any // TODO: Replace 'any' with the actual type of workflow
}
export const useExecutionStore = defineStore('execution', () => {
const activePromptId = ref<string | null>(null)
const queuedPrompts = ref<Record<string, QueuedPrompt>>({})
const executingNodeId = ref<string | null>(null)
const activePrompt = computed(() => queuedPrompts.value[activePromptId.value])
const totalNodesToExecute = computed(() => {
if (!activePrompt.value) return 0
return Object.values(activePrompt.value.nodes).length
})
const isIdle = computed(() => !activePromptId.value)
const nodesExecuted = computed(() => {
if (!activePrompt.value) return 0
return Object.values(activePrompt.value.nodes).filter(Boolean).length
})
const executionProgress = computed(() => {
if (!activePrompt.value) return 0
const total = totalNodesToExecute.value
const done = nodesExecuted.value
return Math.round((done / total) * 100)
})
function bindExecutionEvents() {
api.addEventListener('execution_start', handleExecutionStart)
api.addEventListener('execution_cached', handleExecutionCached)
api.addEventListener('executed', handleExecuted)
api.addEventListener('executing', handleExecuting)
}
function unbindExecutionEvents() {
api.removeEventListener('execution_start', handleExecutionStart)
api.removeEventListener('execution_cached', handleExecutionCached)
api.removeEventListener('executed', handleExecuted)
api.removeEventListener('executing', handleExecuting)
}
function handleExecutionStart(e: CustomEvent) {
activePromptId.value = e.detail.prompt_id
queuedPrompts.value[activePromptId.value] ??= { nodes: {} }
}
function handleExecutionCached(e: CustomEvent) {
if (!activePrompt.value) return
for (const n of e.detail.nodes) {
activePrompt.value.nodes[n] = true
}
}
function handleExecuted(e: CustomEvent) {
if (!activePrompt.value) return
activePrompt.value.nodes[e.detail.node] = true
}
function handleExecuting(e: CustomEvent) {
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
if (!executingNodeId.value) {
delete queuedPrompts.value[activePromptId.value]
activePromptId.value = null
}
}
function storePrompt({
nodes,
id,
workflow
}: {
nodes: string[]
id: string
workflow: any
}) {
queuedPrompts.value[id] ??= { nodes: {} }
const queuedPrompt = queuedPrompts.value[id]
queuedPrompt.nodes = {
...nodes.reduce((p, n) => {
p[n] = false
return p
}, {}),
...queuedPrompt.nodes
}
queuedPrompt.workflow = workflow
console.debug(
`queued task ${id} with ${Object.values(queuedPrompt.nodes).length} nodes`
)
}
return {
isIdle,
activePromptId,
queuedPrompts,
executingNodeId,
activePrompt,
totalNodesToExecute,
nodesExecuted,
executionProgress,
bindExecutionEvents,
unbindExecutionEvents,
storePrompt
}
})

View File

@@ -314,6 +314,11 @@ export const useNodeDefStore = defineStore('nodeDef', {
this.nodeDefsByName = newNodeDefsByName
this.nodeDefsByDisplayName = nodeDefsByDisplayName
},
addNodeDef(nodeDef: ComfyNodeDef) {
const nodeDefImpl = plainToClass(ComfyNodeDefImpl, nodeDef)
this.nodeDefsByName[nodeDef.name] = nodeDefImpl
this.nodeDefsByDisplayName[nodeDef.display_name] = nodeDefImpl
},
updateWidgets(widgets: Record<string, ComfyWidgetConstructor>) {
this.widgets = widgets
},

View File

@@ -277,6 +277,9 @@ export const useQueueStore = defineStore('queue', {
},
lastHistoryQueueIndex(state) {
return state.historyTasks.length ? state.historyTasks[0].queueIndex : -1
},
hasPendingTasks(state) {
return state.pendingTasks.length > 0
}
},
actions: {
@@ -325,10 +328,11 @@ export const useQueueStore = defineStore('queue', {
this.isLoading = false
}
},
async clear() {
await Promise.all(
['queue', 'history'].map((type) => api.clearItems(type))
)
async clear(targets: ('queue' | 'history')[] = ['queue', 'history']) {
if (targets.length === 0) {
return
}
await Promise.all(targets.map((type) => api.clearItems(type)))
await this.update()
},
async delete(task: TaskItemImpl) {

View File

@@ -9,16 +9,12 @@
import { app } from '@/scripts/app'
import { ComfySettingsDialog } from '@/scripts/ui/settings'
import { Settings } from '@/types/apiTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import {
LinkReleaseTriggerAction,
LinkReleaseTriggerMode
} from '@/types/searchBoxTypes'
import { SettingParams } from '@/types/settingTypes'
import type { Settings } from '@/types/apiTypes'
import type { SettingParams } from '@/types/settingTypes'
import { buildTree } from '@/utils/treeUtil'
import { defineStore } from 'pinia'
import type { TreeNode } from 'primevue/treenode'
import { CORE_SETTINGS } from '@/stores/coreSettings'
export interface SettingTreeNode extends TreeNode {
data?: SettingParams
@@ -66,286 +62,8 @@ export const useSettingStore = defineStore('setting', {
}
this.settings = settings.settingsParamLookup
app.ui.settings.addSetting({
id: 'Comfy.Validation.Workflows',
name: 'Validate workflows',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.NodeSearchBoxImpl',
category: ['Comfy', 'Node Search Box', 'Implementation'],
experimental: true,
name: 'Node search box implementation',
type: 'combo',
options: ['default', 'litegraph (legacy)'],
defaultValue: 'default'
})
app.ui.settings.addSetting({
id: 'Comfy.NodeSearchBoxImpl.LinkReleaseTrigger',
category: ['Comfy', 'Node Search Box', 'LinkReleaseTrigger'],
name: 'Trigger on link release',
type: 'hidden',
options: Object.values(LinkReleaseTriggerMode),
defaultValue: LinkReleaseTriggerMode.ALWAYS,
deprecated: true
})
app.ui.settings.addSetting({
id: 'Comfy.LinkRelease.Action',
name: 'Action on link release (No modifier)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU
})
app.ui.settings.addSetting({
id: 'Comfy.LinkRelease.ActionShift',
name: 'Action on link release (Shift)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX
})
app.ui.settings.addSetting({
id: 'Comfy.NodeSearchBoxImpl.NodePreview',
category: ['Comfy', 'Node Search Box', 'NodePreview'],
name: 'Node preview',
tooltip: 'Only applies to the default implementation',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.NodeSearchBoxImpl.ShowCategory',
category: ['Comfy', 'Node Search Box', 'ShowCategory'],
name: 'Show node category in search results',
tooltip: 'Only applies to the default implementation',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.NodeSearchBoxImpl.ShowIdName',
category: ['Comfy', 'Node Search Box', 'ShowIdName'],
name: 'Show node id name in search results',
tooltip: 'Only applies to the default implementation',
type: 'boolean',
defaultValue: false
})
app.ui.settings.addSetting({
id: 'Comfy.Sidebar.Location',
category: ['Comfy', 'Sidebar', 'Location'],
name: 'Sidebar location',
type: 'combo',
options: ['left', 'right'],
defaultValue: 'left'
})
app.ui.settings.addSetting({
id: 'Comfy.Sidebar.Size',
category: ['Comfy', 'Sidebar', 'Size'],
name: 'Sidebar size',
type: 'combo',
options: ['normal', 'small'],
defaultValue: window.innerWidth < 1600 ? 'small' : 'normal'
})
app.ui.settings.addSetting({
id: 'Comfy.TextareaWidget.FontSize',
category: ['Comfy', 'Node Widget', 'TextareaWidget', 'FontSize'],
name: 'Textarea widget font size',
type: 'slider',
defaultValue: 10,
attrs: {
min: 8,
max: 24
}
})
app.ui.settings.addSetting({
id: 'Comfy.TextareaWidget.Spellcheck',
category: ['Comfy', 'Node Widget', 'TextareaWidget', 'Spellcheck'],
name: 'Textarea widget spellcheck',
type: 'boolean',
defaultValue: false
})
app.ui.settings.addSetting({
id: 'Comfy.Workflow.SortNodeIdOnSave',
name: 'Sort node IDs when saving workflow',
type: 'boolean',
defaultValue: false
})
app.ui.settings.addSetting({
id: 'Comfy.Graph.CanvasInfo',
name: 'Show canvas info (fps, etc.)',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Node.ShowDeprecated',
name: 'Show deprecated nodes in search',
tooltip:
'Deprecated nodes are hidden by default in the UI, but remain functional in existing workflows that use them.',
type: 'boolean',
defaultValue: false
})
app.ui.settings.addSetting({
id: 'Comfy.Node.ShowExperimental',
name: 'Show experimental nodes in search',
tooltip:
'Experimental nodes are marked as such in the UI and may be subject to significant changes or removal in future versions. Use with caution in production workflows',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Workflow.ShowMissingNodesWarning',
name: 'Show missing nodes warning',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Workflow.ShowMissingModelsWarning',
name: 'Show missing models warning',
type: 'boolean',
defaultValue: false,
experimental: true
})
app.ui.settings.addSetting({
id: 'Comfy.Graph.ZoomSpeed',
name: 'Canvas zoom speed',
type: 'slider',
defaultValue: 1.1,
attrs: {
min: 1.01,
max: 2.5,
step: 0.01
}
})
// Bookmarks are stored in the settings store.
// Bookmarks are in format of category/display_name. e.g. "conditioning/CLIPTextEncode"
app.ui.settings.addSetting({
id: 'Comfy.NodeLibrary.Bookmarks',
name: 'Node library bookmarks with display name (deprecated)',
type: 'hidden',
defaultValue: [],
deprecated: true
})
app.ui.settings.addSetting({
id: 'Comfy.NodeLibrary.Bookmarks.V2',
name: 'Node library bookmarks v2 with unique name',
type: 'hidden',
defaultValue: []
})
// Stores mapping from bookmark folder name to its customization.
app.ui.settings.addSetting({
id: 'Comfy.NodeLibrary.BookmarksCustomization',
name: 'Node library bookmarks customization',
type: 'hidden',
defaultValue: {}
})
// Hidden setting used by the queue for how to fit images
app.ui.settings.addSetting({
id: 'Comfy.Queue.ImageFit',
name: 'Queue image fit',
type: 'hidden',
defaultValue: 'cover'
})
app.ui.settings.addSetting({
id: 'Comfy.Workflow.ModelDownload.AllowedSources',
name: 'Allowed model download sources',
type: 'hidden',
defaultValue: ['https://huggingface.co/', 'https://civitai.com/']
})
app.ui.settings.addSetting({
id: 'Comfy.Workflow.ModelDownload.AllowedSuffixes',
name: 'Allowed model download suffixes',
type: 'hidden',
defaultValue: ['.safetensors', '.sft']
})
app.ui.settings.addSetting({
id: 'Comfy.GroupSelectedNodes.Padding',
name: 'Group selected nodes padding',
type: 'slider',
defaultValue: 10,
attrs: {
min: 0,
max: 100
}
})
app.ui.settings.addSetting({
id: 'Comfy.Node.DoubleClickTitleToEdit',
name: 'Double click node title to edit',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Group.DoubleClickTitleToEdit',
name: 'Double click group title to edit',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Window.UnloadConfirmation',
name: 'Show confirmation when closing window',
type: 'boolean',
defaultValue: false
})
app.ui.settings.addSetting({
id: 'Comfy.TreeExplorer.ItemPadding',
name: 'Tree explorer item padding',
type: 'slider',
defaultValue: 2,
attrs: {
min: 0,
max: 8,
step: 1
}
})
app.ui.settings.addSetting({
id: 'Comfy.Locale',
name: 'Locale',
type: 'combo',
options: ['en', 'zh'],
defaultValue: navigator.language.split('-')[0] || 'en'
})
app.ui.settings.addSetting({
id: 'Comfy.NodeBadge.NodeSourceBadgeMode',
name: 'Node source badge mode',
type: 'combo',
options: Object.values(NodeBadgeMode),
defaultValue: NodeBadgeMode.HideBuiltIn
})
app.ui.settings.addSetting({
id: 'Comfy.NodeBadge.NodeIdBadgeMode',
name: 'Node ID badge mode',
type: 'combo',
options: [NodeBadgeMode.None, NodeBadgeMode.ShowAll],
defaultValue: NodeBadgeMode.ShowAll
CORE_SETTINGS.forEach((setting: SettingParams) => {
settings.addSetting(setting)
})
},

View File

@@ -1,173 +1,138 @@
import { defineStore } from 'pinia'
import { api } from '@/scripts/api'
import type { TreeNode } from 'primevue/treenode'
import { buildTree } from '@/utils/treeUtil'
import { computed, ref } from 'vue'
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
interface OpenFile {
path: string
content: string
isModified: boolean
originalContent: string
export class UserFile {
isLoading: boolean = false
content: string | null = null
originalContent: string | null = null
constructor(
public path: string,
public lastModified: number,
public size: number
) {}
get isOpen() {
return !!this.content
}
get isModified() {
return this.content !== this.originalContent
}
}
interface StoreState {
files: string[]
openFiles: OpenFile[]
}
export const useUserFileStore = defineStore('userFile', () => {
const userFilesByPath = ref(new Map<string, UserFile>())
interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
}
const userFiles = computed(() => Array.from(userFilesByPath.value.values()))
const modifiedFiles = computed(() =>
userFiles.value.filter((file: UserFile) => file.isModified)
)
const openedFiles = computed(() =>
userFiles.value.filter((file: UserFile) => file.isOpen)
)
export const useUserFileStore = defineStore('userFile', {
state: (): StoreState => ({
files: [],
openFiles: []
}),
const workflowsTree = computed<TreeExplorerNode<UserFile>>(
() =>
buildTree<UserFile>(userFiles.value, (userFile: UserFile) =>
userFile.path.split('/')
) as TreeExplorerNode<UserFile>
)
getters: {
getOpenFile: (state) => (path: string) =>
state.openFiles.find((file) => file.path === path),
modifiedFiles: (state) => state.openFiles.filter((file) => file.isModified),
workflowsTree: (state): TreeNode =>
buildTree(state.files, (path: string) => path.split('/'))
},
/**
* Syncs the files in the given directory with the API.
* @param dir The directory to sync.
*/
const syncFiles = async () => {
const files = await api.listUserDataFullInfo('')
actions: {
async openFile(path: string): Promise<void> {
if (this.getOpenFile(path)) return
for (const file of files) {
const existingFile = userFilesByPath.value.get(file.path)
const { success, data } = await this.getFileData(path)
if (success && data) {
this.openFiles.push({
path,
content: data,
isModified: false,
originalContent: data
})
}
},
closeFile(path: string): void {
const index = this.openFiles.findIndex((file) => file.path === path)
if (index !== -1) {
this.openFiles.splice(index, 1)
}
},
updateFileContent(path: string, newContent: string): void {
const file = this.getOpenFile(path)
if (file) {
file.content = newContent
file.isModified = file.content !== file.originalContent
}
},
async saveOpenFile(path: string): Promise<ApiResponse> {
const file = this.getOpenFile(path)
if (file?.isModified) {
const result = await this.saveFile(path, file.content)
if (result.success) {
file.isModified = false
file.originalContent = file.content
}
return result
}
return { success: true }
},
discardChanges(path: string): void {
const file = this.getOpenFile(path)
if (file) {
file.content = file.originalContent
file.isModified = false
}
},
async loadFiles(dir: string = './'): Promise<void> {
this.files = (await api.listUserData(dir, true, false)).map(
(filePath: string) => filePath.replaceAll('\\', '/')
)
this.openFiles = (
await Promise.all(
this.openFiles.map(async (openFile) => {
if (!this.files.includes(openFile.path)) return null
const { success, data } = await this.getFileData(openFile.path)
if (success && data !== openFile.originalContent) {
return {
...openFile,
content: data,
originalContent: data,
isModified: openFile.content !== data
}
}
return openFile
})
if (!existingFile) {
// New file, add it to the map
userFilesByPath.value.set(
file.path,
new UserFile(file.path, file.modified, file.size)
)
).filter((file): file is OpenFile => file !== null)
},
async renameFile(oldPath: string, newPath: string): Promise<ApiResponse> {
const resp = await api.moveUserData(oldPath, newPath)
if (resp.status !== 200) {
return { success: false, message: resp.statusText }
} else if (existingFile.lastModified !== file.modified) {
// File has been modified, update its properties
existingFile.lastModified = file.modified
existingFile.size = file.size
existingFile.originalContent = null
existingFile.content = null
existingFile.isLoading = false
}
}
const openFile = this.openFiles.find((file) => file.path === oldPath)
if (openFile) {
openFile.path = newPath
// Remove files that no longer exist
for (const [path, _] of userFilesByPath.value) {
if (!files.some((file) => file.path === path)) {
userFilesByPath.value.delete(path)
}
await this.loadFiles()
return { success: true }
},
async deleteFile(path: string): Promise<ApiResponse> {
const resp = await api.deleteUserData(path)
if (resp.status !== 204) {
return {
success: false,
message: `Error removing user data file '${path}': ${resp.status} ${resp.statusText}`
}
}
const index = this.openFiles.findIndex((file) => file.path === path)
if (index !== -1) {
this.openFiles.splice(index, 1)
}
await this.loadFiles()
return { success: true }
},
async saveFile(path: string, data: string): Promise<ApiResponse> {
const resp = await api.storeUserData(path, data, {
stringify: false,
throwOnError: false,
overwrite: true
})
if (resp.status !== 200) {
return {
success: false,
message: `Error saving user data file '${path}': ${resp.status} ${resp.statusText}`
}
}
await this.loadFiles()
return { success: true }
},
async getFileData(path: string): Promise<ApiResponse<string>> {
const resp = await api.getUserData(path)
if (resp.status !== 200) {
return { success: false, message: resp.statusText }
}
return { success: true, data: await resp.json() }
}
}
const loadFile = async (file: UserFile) => {
file.isLoading = true
const resp = await api.getUserData(file.path)
if (resp.status !== 200) {
throw new Error(
`Failed to load file '${file.path}': ${resp.status} ${resp.statusText}`
)
}
file.content = await resp.text()
file.originalContent = file.content
file.isLoading = false
}
const saveFile = async (file: UserFile) => {
if (file.isModified) {
const resp = await api.storeUserData(file.path, file.content)
if (resp.status !== 200) {
throw new Error(
`Failed to save file '${file.path}': ${resp.status} ${resp.statusText}`
)
}
}
await syncFiles()
}
const deleteFile = async (file: UserFile) => {
const resp = await api.deleteUserData(file.path)
if (resp.status !== 204) {
throw new Error(
`Failed to delete file '${file.path}': ${resp.status} ${resp.statusText}`
)
}
await syncFiles()
}
const renameFile = async (file: UserFile, newPath: string) => {
const resp = await api.moveUserData(file.path, newPath)
if (resp.status !== 200) {
throw new Error(
`Failed to rename file '${file.path}': ${resp.status} ${resp.statusText}`
)
}
file.path = newPath
userFilesByPath.value.set(newPath, file)
userFilesByPath.value.delete(file.path)
await syncFiles()
}
return {
userFiles,
modifiedFiles,
openedFiles,
workflowsTree,
syncFiles,
loadFile,
saveFile,
deleteFile,
renameFile
}
})

View File

@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ComfyWorkflow } from '@/scripts/workflows'
import { getStorageValue } from '@/scripts/utils'
export const useWorkflowStore = defineStore('workflow', () => {
const activeWorkflow = ref<ComfyWorkflow | null>(null)
const previousWorkflowUnsaved = ref<boolean>(
Boolean(getStorageValue('Comfy.PreviousWorkflowUnsaved'))
)
const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
const workflows = computed(() => Object.values(workflowLookup.value))
const openWorkflows = ref<ComfyWorkflow[]>([])
return {
activeWorkflow,
previousWorkflowUnsaved,
workflows,
openWorkflows,
workflowLookup
}
})

View File

@@ -247,7 +247,11 @@ function inputSpec(
const zBaseInputSpecValue = z
.object({
default: z.any().optional(),
forceInput: z.boolean().optional()
defaultInput: z.boolean().optional(),
forceInput: z.boolean().optional(),
lazy: z.boolean().optional(),
rawLink: z.boolean().optional(),
tooltip: z.string().optional()
})
.passthrough()
@@ -415,7 +419,11 @@ const zUser = z.object({
users: z.record(z.string(), z.unknown())
})
const zUserData = z.array(z.array(z.string(), z.string()))
const zUserDataFullInfo = z.object({
path: z.string(),
size: z.number(),
modified: z.number()
})
const zBookmarkCustomization = z.object({
icon: z.string().optional(),
color: z.string().optional()
@@ -492,7 +500,8 @@ const zSettings = z.record(z.any()).and(
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
'Comfy.Window.UnloadConfirmation': z.boolean(),
'Comfy.NodeBadge.NodeSourceBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.NodeIdBadgeMode': zNodeBadgeMode
'Comfy.NodeBadge.NodeIdBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.NodeLifeCycleBadgeMode': zNodeBadgeMode
})
.optional()
)
@@ -505,3 +514,4 @@ export type DeviceStats = z.infer<typeof zDeviceStats>
export type SystemStats = z.infer<typeof zSystemStats>
export type User = z.infer<typeof zUser>
export type UserData = z.infer<typeof zUserData>
export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo>

View File

@@ -11,6 +11,8 @@ declare module '@comfyorg/litegraph' {
slot_types_out: string[]
slot_types_default_out: Record<string, string[]>
slot_types_default_in: Record<string, string[]>
isValidConnection(type_a: ISlotType, type_b: ISlotType): boolean
}
import type { LiteGraph as LG } from '@comfyorg/litegraph/dist/litegraph'

View File

@@ -1,4 +1,4 @@
import {
import type {
ConnectingLink,
LGraphNode,
Vector2,
@@ -6,6 +6,7 @@ import {
INodeOutputSlot,
INodeSlot
} from '@comfyorg/litegraph'
import { LiteGraph } from '@comfyorg/litegraph'
export class ConnectingLinkImpl implements ConnectingLink {
node: LGraphNode
@@ -56,9 +57,8 @@ export class ConnectingLinkImpl implements ConnectingLink {
this.releaseSlotType === 'output' ? newNode.outputs : newNode.inputs
if (!newNodeSlots) return
const newNodeSlot = newNodeSlots.findIndex(
(slot: INodeSlot) =>
slot.type === this.type || slot.type === '*' || this.type === '*'
const newNodeSlot = newNodeSlots.findIndex((slot: INodeSlot) =>
LiteGraph.isValidConnection(slot.type, this.type)
)
if (newNodeSlot === -1) {

View File

@@ -24,6 +24,10 @@ export interface TreeExplorerNode<T = any> {
node: TreeExplorerNode<T>,
data: TreeExplorerDragAndDropData
) => void
// Function to handle clicking a node
handleClick?: (node: TreeExplorerNode<T>, event: MouseEvent) => void
// Function to handle errors
handleError?: (error: Error) => void
}
export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {

View File

@@ -22,3 +22,20 @@ export function formatCamelCase(str: string): string {
// Join the words with spaces
return processedWords.join(' ')
}
export function appendJsonExt(path: string) {
if (!path.toLowerCase().endsWith('.json')) {
path += '.json'
}
return path
}
export function trimJsonExt(path?: string) {
return path?.replace(/\.json$/, '')
}
export function highlightQuery(text: string, query: string) {
if (!query) return text
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}

View File

@@ -1,3 +1,4 @@
import { createPinia, setActivePinia } from 'pinia'
import {
start,
createDefaultWorkflow,
@@ -517,8 +518,8 @@ describe('group node', () => {
vaeReroute
])
group1.menu.Clone.call()
expect(app.graph._nodes).toHaveLength(4)
const group2 = graph.find(app.graph._nodes[3])
expect(app.graph.nodes).toHaveLength(4)
const group2 = graph.find(app.graph.nodes[3])
expect(group2.node.type).toEqual('workflow/test')
expect(group2.id).not.toEqual(group1.id)
@@ -572,7 +573,6 @@ describe('group node', () => {
new CustomEvent('executing', { detail: `${nodes.save.id}` })
)
// Event should be forwarded to group node id
expect(+app.runningNodeId).toEqual(group.id)
expect(group.node['imgs']).toBeFalsy()
api.dispatchEvent(
new CustomEvent('executed', {
@@ -613,7 +613,6 @@ describe('group node', () => {
api.dispatchEvent(new CustomEvent('execution_start', {}))
api.dispatchEvent(new CustomEvent('executing', { detail: `${group.id}:5` }))
// Event should be forwarded to group node id
expect(+app.runningNodeId).toEqual(group.id)
expect(group.node['imgs']).toBeFalsy()
api.dispatchEvent(
new CustomEvent('executed', {
@@ -680,8 +679,8 @@ describe('group node', () => {
// Clone the node
group1.menu.Clone.call()
expect(app.graph._nodes).toHaveLength(3)
const group2 = graph.find(app.graph._nodes[2])
expect(app.graph.nodes).toHaveLength(3)
const group2 = graph.find(app.graph.nodes[2])
expect(group2.node.type).toEqual('workflow/test')
expect(group2.id).not.toEqual(group1.id)

View File

@@ -32,7 +32,7 @@ describe('LGraph', () => {
const result1 = graph.serialize({ sortNodes: true })
expect(result1.nodes).not.toHaveLength(0)
// @ts-expect-error Access private property.
graph._nodes = swapNodes(graph._nodes)
graph._nodes = swapNodes(graph.nodes)
const result2 = graph.serialize({ sortNodes: true })
expect(result1).toEqual(result2)

View File

@@ -1,15 +1,15 @@
import { setActivePinia, createPinia } from 'pinia'
import { useUserFileStore } from '@/stores/userFileStore'
import { UserFile, useUserFileStore } from '@/stores/userFileStore'
import { api } from '@/scripts/api'
// Mock the api
jest.mock('@/scripts/api', () => ({
api: {
listUserData: jest.fn(),
moveUserData: jest.fn(),
deleteUserData: jest.fn(),
listUserDataFullInfo: jest.fn(),
getUserData: jest.fn(),
storeUserData: jest.fn(),
getUserData: jest.fn()
deleteUserData: jest.fn(),
moveUserData: jest.fn()
}
}))
@@ -21,149 +21,136 @@ describe('useUserFileStore', () => {
store = useUserFileStore()
})
it('should open a file', async () => {
const mockFileData = { success: true, data: 'file content' }
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () => mockFileData.data
it('should initialize with empty files', () => {
expect(store.userFiles).toHaveLength(0)
expect(store.modifiedFiles).toHaveLength(0)
expect(store.openedFiles).toHaveLength(0)
})
describe('syncFiles', () => {
it('should add new files', async () => {
const mockFiles = [
{ path: 'file1.txt', modified: 123, size: 100 },
{ path: 'file2.txt', modified: 456, size: 200 }
]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(mockFiles)
await store.syncFiles()
expect(store.userFiles).toHaveLength(2)
expect(store.userFiles[0].path).toBe('file1.txt')
expect(store.userFiles[1].path).toBe('file2.txt')
})
await store.openFile('test.txt')
it('should update existing files', async () => {
const initialFile = { path: 'file1.txt', modified: 123, size: 100 }
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([initialFile])
await store.syncFiles()
expect(store.openFiles).toHaveLength(1)
expect(store.openFiles[0]).toEqual({
path: 'test.txt',
content: 'file content',
isModified: false,
originalContent: 'file content'
const updatedFile = { path: 'file1.txt', modified: 456, size: 200 }
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([updatedFile])
await store.syncFiles()
expect(store.userFiles).toHaveLength(1)
expect(store.userFiles[0].lastModified).toBe(456)
expect(store.userFiles[0].size).toBe(200)
})
it('should remove non-existent files', async () => {
const initialFiles = [
{ path: 'file1.txt', modified: 123, size: 100 },
{ path: 'file2.txt', modified: 456, size: 200 }
]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(initialFiles)
await store.syncFiles()
const updatedFiles = [{ path: 'file1.txt', modified: 123, size: 100 }]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(updatedFiles)
await store.syncFiles()
expect(store.userFiles).toHaveLength(1)
expect(store.userFiles[0].path).toBe('file1.txt')
})
})
it('should close a file', () => {
store.openFiles = [
{
path: 'test.txt',
content: 'content',
isModified: false,
originalContent: 'content'
}
]
describe('loadFile', () => {
it('should load file content', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
text: () => Promise.resolve('file content')
})
store.closeFile('test.txt')
await store.loadFile(file)
expect(store.openFiles).toHaveLength(0)
})
it('should update file content', () => {
store.openFiles = [
{
path: 'test.txt',
content: 'old content',
isModified: false,
originalContent: 'old content'
}
]
store.updateFileContent('test.txt', 'new content')
expect(store.openFiles[0].content).toBe('new content')
expect(store.openFiles[0].isModified).toBe(true)
})
it('should save an open file', async () => {
store.openFiles = [
{
path: 'test.txt',
content: 'modified content',
isModified: true,
originalContent: 'original content'
}
]
;(api.storeUserData as jest.Mock).mockResolvedValue({ status: 200 })
;(api.listUserData as jest.Mock).mockResolvedValue(['test.txt'])
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () => 'modified content'
expect(file.content).toBe('file content')
expect(file.originalContent).toBe('file content')
expect(file.isLoading).toBe(false)
})
await store.saveOpenFile('test.txt')
it('should throw error on failed load', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 404,
statusText: 'Not Found'
})
expect(store.openFiles[0].isModified).toBe(false)
expect(store.openFiles[0].originalContent).toBe('modified content')
await expect(store.loadFile(file)).rejects.toThrow(
"Failed to load file 'file1.txt': 404 Not Found"
)
})
})
it('should discard changes', () => {
store.openFiles = [
{
path: 'test.txt',
content: 'modified content',
isModified: true,
originalContent: 'original content'
}
]
describe('saveFile', () => {
it('should save modified file', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'modified content'
file.originalContent = 'original content'
;(api.storeUserData as jest.Mock).mockResolvedValue({ status: 200 })
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([])
store.discardChanges('test.txt')
await store.saveFile(file)
expect(store.openFiles[0].content).toBe('original content')
expect(store.openFiles[0].isModified).toBe(false)
})
it('should load files', async () => {
;(api.listUserData as jest.Mock).mockResolvedValue([
'file1.txt',
'file2.txt'
])
await store.loadFiles()
expect(store.files).toEqual(['file1.txt', 'file2.txt'])
})
it('should rename a file', async () => {
store.openFiles = [
{
path: 'oldfile.txt',
content: 'content',
isModified: false,
originalContent: 'content'
}
]
;(api.moveUserData as jest.Mock).mockResolvedValue({ status: 200 })
;(api.listUserData as jest.Mock).mockResolvedValue(['newfile.txt'])
await store.renameFile('oldfile.txt', 'newfile.txt')
expect(store.openFiles[0].path).toBe('newfile.txt')
expect(store.files).toEqual(['newfile.txt'])
})
it('should delete a file', async () => {
store.openFiles = [
{
path: 'file.txt',
content: 'content',
isModified: false,
originalContent: 'content'
}
]
;(api.deleteUserData as jest.Mock).mockResolvedValue({ status: 204 })
;(api.listUserData as jest.Mock).mockResolvedValue([])
await store.deleteFile('file.txt')
expect(store.openFiles).toHaveLength(0)
expect(store.files).toEqual([])
})
it('should get file data', async () => {
const mockFileData = { content: 'file content' }
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () => mockFileData
expect(api.storeUserData).toHaveBeenCalledWith(
'file1.txt',
'modified content'
)
})
const result = await store.getFileData('test.txt')
it('should not save unmodified file', async () => {
const file = new UserFile('file1.txt', 123, 100)
file.content = 'content'
file.originalContent = 'content'
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([])
expect(result).toEqual({ success: true, data: mockFileData })
await store.saveFile(file)
expect(api.storeUserData).not.toHaveBeenCalled()
})
})
describe('deleteFile', () => {
it('should delete file', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.deleteUserData as jest.Mock).mockResolvedValue({ status: 204 })
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([])
await store.deleteFile(file)
expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt')
})
})
describe('renameFile', () => {
it('should rename file', async () => {
const file = new UserFile('file1.txt', 123, 100)
;(api.moveUserData as jest.Mock).mockResolvedValue({ status: 200 })
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([])
await store.renameFile(file, 'newfile.txt')
expect(api.moveUserData).toHaveBeenCalledWith('file1.txt', 'newfile.txt')
expect(file.path).toBe('newfile.txt')
})
})
})

View File

@@ -353,7 +353,7 @@ export class EzGraph {
}
get nodes() {
return this.app.graph._nodes.map((n) => new EzNode(this.app, n))
return this.app.graph.nodes.map((n) => new EzNode(this.app, n))
}
clear() {

View File

@@ -1,4 +1,4 @@
import { APIConfig, mockApi } from './setup'
import { APIConfig, mockApi, mockSettingStore, mockNodeDefStore } from './setup'
import { Ez, EzGraph, EzNameSpace } from './ezgraph'
import lg from './litegraph'
import fs from 'fs'
@@ -40,7 +40,12 @@ export async function start(config: StartConfig = {}): Promise<StartResult> {
document.body.innerHTML = html.toString()
mockApi(config)
mockSettingStore()
const { app } = await import('../../src/scripts/app')
const { useSettingStore } = await import('../../src/stores/settingStore')
useSettingStore().addSettings(app.ui.settings)
mockNodeDefStore()
const { LiteGraph, LGraphCanvas } = await import('@comfyorg/litegraph')
config.preSetup?.(app)
const canvasEl = document.createElement('canvas')

View File

@@ -1,4 +1,7 @@
import type { ComfySettingsDialog } from '@/scripts/ui/settings'
import type { ComfyApp } from '@/scripts/app'
import '../../src/scripts/api'
import { ComfyNodeDef } from '@/types/apiTypes'
const fs = require('fs')
const path = require('path')
@@ -97,3 +100,38 @@ export function mockApi(config: APIConfig = {}) {
}
}))
}
export const mockSettingStore = () => {
let app: ComfyApp | null = null
const mockedSettingStore = {
addSettings(settings: ComfySettingsDialog) {
app = settings.app
},
set(key: string, value: any) {
app?.ui.settings.setSettingValue(key, value)
},
get(key: string) {
return (
app?.ui.settings.getSettingValue(key) ??
app?.ui.settings.getSettingDefaultValue(key)
)
}
}
jest.mock('@/stores/settingStore', () => ({
useSettingStore: jest.fn(() => mockedSettingStore)
}))
}
export const mockNodeDefStore = () => {
const mockedNodeDefStore = {
addNodeDef: jest.fn((nodeDef: ComfyNodeDef) => {})
}
jest.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: jest.fn(() => mockedNodeDefStore)
}))
}