Compare commits

..

3 Commits

Author SHA1 Message Date
bezo97
76cf77b901 Use 'output_node_id' metadata from dropped file
- The file is displayed only on the node it was saved by.
- No longer restricted to specific supported node types
2024-10-13 17:35:04 +02:00
bezo97
6b2cbacc09 Merge remote-tracking branch 'origin/main' into feature/show_dropped_image_in_output_node 2024-10-13 15:43:01 +02:00
bezo97
186ff3a78e Show dropped image in output node
- blobUrl is an optional paramtere in loadGraphData
- sets the preview image on SaveImage and PreviewImage nodes
- works for any dropped png or webp, also from the queue
2024-10-07 20:55:35 +02:00
129 changed files with 2487 additions and 2677 deletions

View File

@@ -50,12 +50,6 @@ body:
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
validations:
required: true
- type: textarea
attributes:
label: Setting JSON
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
validations:
required: true
- type: dropdown
id: browsers
attributes:

View File

@@ -1,5 +1 @@
if [[ "$OS" == "Windows_NT" ]]; then
npx.cmd lint-staged
else
npx lint-staged
fi
npx lint-staged

View File

@@ -193,76 +193,7 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
</details>
### Developer APIs
<details>
<summary>v1.3.22: Register bottom panel tabs</summary>
```js
app.registerExtension({
name: 'TestExtension',
bottomPanelTabs: [
{
id: 'TestTab',
title: 'Test Tab',
type: 'custom',
render: (el) => {
el.innerHTML = '<div>Custom tab</div>'
}
}
]
})
```
![image](https://github.com/user-attachments/assets/2114f8b8-2f55-414b-b027-78e61c870b64)
</details>
<details>
<summary>v1.3.22: New settings API</summary>
Legacy settings API.
```js
// Register a new setting
app.ui.settings.addSetting({
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!'
})
// Get the value of a setting
const value = app.ui.settings.getSettingValue('TestSetting')
// Set the value of a setting
app.ui.settings.setSettingValue('TestSetting', 'Hello, universe!')
```
New settings API.
```js
// Register a new setting
app.registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!'
}
]
})
// Get the value of a setting
const value = app.extensionManager.setting.get('TestSetting')
// Set the value of a setting
app.extensionManager.setting.set('TestSetting', 'Hello, universe!')
```
</details>
### Node developers API
<details>
<summary>v1.3.7: Register commands and keybindings</summary>
@@ -367,14 +298,6 @@ We will support custom icons later.
## Development
### Tech Stack
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [Litegraph](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
### Git pre-commit hooks
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
@@ -389,7 +312,7 @@ core extensions will be loaded.
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
### Unit Test
### Test
- `git clone https://github.com/comfyanonymous/ComfyUI_examples.git` to `tests-ui/ComfyUI_examples` or the EXAMPLE_REPO_PATH location specified in .env
- `npm i` to install all dependencies
@@ -397,16 +320,6 @@ core extensions will be loaded.
- `npm run test:generate:examples` to extract the example workflows
- `npm run test` to execute all unit tests.
### Component Test
Component test verifies Vue components in `src/components/`.
- `npm run test:component` to execute all component tests.
### Playwright Test
Playwright test verifies the whole app. See <https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/README.md> for details.
### LiteGraph
This repo is using litegraph package hosted on <https://github.com/Comfy-Org/litegraph.js>. Any changes to litegraph should be submitted in that repo instead.
@@ -414,6 +327,7 @@ This repo is using litegraph package hosted on <https://github.com/Comfy-Org/lit
### Test litegraph changes
- Run `npm link` in the local litegraph repo.
- Run `npm uninstall @comfyorg/litegraph` in this repo.
- Run `npm link @comfyorg/litegraph` in this repo.
This will replace the litegraph package in this repo with the local litegraph repo.

View File

@@ -1,23 +1,287 @@
import type { Page, Locator, APIRequestContext } from '@playwright/test'
import { expect } from '@playwright/test'
import { test as base } from '@playwright/test'
import { ComfyActionbar } from '../helpers/actionbar'
import { ComfyActionbar } from './helpers/actionbar'
import dotenv from 'dotenv'
dotenv.config()
import * as fs from 'fs'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import type { NodeId } from '../../src/types/comfyWorkflow'
import type { KeyCombo } from '../../src/types/keyBindingTypes'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import {
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { NodeReference } from './utils/litegraphUtils'
import type { Position, Size } from './types'
import { SettingDialog } from './components/SettingDialog'
import { NodeBadgeMode } from '../src/types/nodeSource'
import type { NodeId } from '../src/types/comfyWorkflow'
import type { KeyCombo } from '../src/types/keyBindingTypes'
import { ManageGroupNode } from './helpers/manageGroupNode'
import { ComfyTemplates } from './helpers/templates'
interface Position {
x: number
y: number
}
interface Size {
width: number
height: number
}
class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
async selectFilterType(filterType: string) {
await this.page
.locator(
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
)
.click()
}
async selectFilterValue(filterValue: string) {
await this.page.locator('.filter-value-select .p-select-dropdown').click()
await this.page
.locator(
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue)
await this.page.locator('.p-button-label:has-text("Add")').click()
}
}
class ComfyNodeSearchBox {
public readonly input: Locator
public readonly dropdown: Locator
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
constructor(public readonly page: Page) {
this.input = page.locator(
'.comfy-vue-node-search-container input[type="text"]'
)
this.dropdown = page.locator(
'.comfy-vue-node-search-container .p-autocomplete-list'
)
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
}
get filterButton() {
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
}
async fillAndSelectFirstNode(
nodeName: string,
options?: { suggestionIndex: number }
) {
await this.input.waitFor({ state: 'visible' })
await this.input.fill(nodeName)
await this.dropdown.waitFor({ state: 'visible' })
// Wait for some time for the auto complete list to update.
// The auto complete list is debounced and may take some time to update.
await this.page.waitForTimeout(500)
await this.dropdown
.locator('li')
.nth(options?.suggestionIndex || 0)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.filterButton.click()
await this.filterSelectionPanel.addFilter(filterValue, filterType)
}
get filterChips() {
return this.page.locator(
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
)
}
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
}
}
class SidebarTab {
constructor(
public readonly page: Page,
public readonly tabId: string
) {}
get tabButton() {
return this.page.locator(`.${this.tabId}-tab-button`)
}
get selectedTabButton() {
return this.page.locator(
`.${this.tabId}-tab-button.side-bar-button-selected`
)
}
async open() {
if (await this.selectedTabButton.isVisible()) {
return
}
await this.tabButton.click()
}
}
class NodeLibrarySidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'node-library')
}
get nodeLibrarySearchBoxInput() {
return this.page.locator('.node-lib-search-box input[type="text"]')
}
get nodeLibraryTree() {
return this.page.locator('.node-lib-tree-explorer')
}
get nodePreview() {
return this.page.locator('.node-lib-node-preview')
}
get tabContainer() {
return this.page.locator('.sidebar-content-container')
}
get newFolderButton() {
return this.tabContainer.locator('.new-folder-button')
}
async open() {
await super.open()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
async close() {
if (!this.tabButton.isVisible()) {
return
}
await this.tabButton.click()
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
}
folderSelector(folderName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
}
getFolder(folderName: string) {
return this.page.locator(this.folderSelector(folderName))
}
nodeSelector(nodeName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
}
getNode(nodeName: string) {
return this.page.locator(this.nodeSelector(nodeName))
}
}
class WorkflowsSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'workflows')
}
get browseGalleryButton() {
return this.page.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.page.locator('.new-blank-workflow-button')
}
get openWorkflowButton() {
return this.page.locator('.open-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-open .node-label')
.allInnerTexts()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')
.allInnerTexts()
}
async switchToWorkflow(workflowName: string) {
const workflowLocator = this.page.locator(
'.comfyui-workflows-open .node-label',
{ hasText: workflowName }
)
await workflowLocator.click()
await this.page.waitForTimeout(300)
}
}
class Topbar {
constructor(public readonly page: Page) {}
async getTabNames(): Promise<string[]> {
return await this.page
.locator('.workflow-tabs .workflow-label')
.allInnerTexts()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
async getMenuItem(itemLabel: string): Promise<Locator> {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
async getWorkflowTab(tabName: string): Promise<Locator> {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
.locator('..')
}
async closeWorkflowTab(tabName: string) {
const tab = await this.getWorkflowTab(tabName)
await tab.locator('.close-button').click({ force: true })
}
async saveWorkflow(workflowName: string) {
await this.triggerTopbarCommand(['Workflow', 'Save'])
await this.page.locator('.p-dialog-content input').fill(workflowName)
await this.page.keyboard.press('Enter')
// Wait for the dialog to close.
await this.page.waitForTimeout(300)
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
}
const tabName = path[0]
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
)
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.click()
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = this.page
.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
if (i === path.length - 1) {
await menuItem.click()
}
}
}
}
class ComfyMenu {
public readonly sideToolbar: Locator
@@ -90,7 +354,6 @@ export class ComfyPage {
public readonly menu: ComfyMenu
public readonly actionbar: ComfyActionbar
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
constructor(
public readonly page: Page,
@@ -106,7 +369,6 @@ export class ComfyPage {
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page)
}
convertLeafToContent(structure: FolderStructure): FolderStructure {
@@ -158,20 +420,7 @@ export class ComfyPage {
}
}
async setupSettings(settings: Record<string, any>) {
const resp = await this.request.post(
`${this.url}/api/devtools/set_settings`,
{
data: settings
}
)
if (resp.status() !== 200) {
throw new Error(`Failed to setup settings: ${await resp.text()}`)
}
}
async setup() {
async setup({ resetView = true } = {}) {
await this.goto()
await this.page.evaluate(() => {
localStorage.clear()
@@ -192,13 +441,26 @@ export class ComfyPage {
})
await this.page.waitForFunction(() => document.fonts.ready)
await this.page.waitForFunction(
() =>
// window['app'] => GraphCanvas ready
// window['app'].extensionManager => GraphView ready
window['app'] && window['app'].extensionManager
() => window['app'] !== undefined && window['app'].vueAppReady
)
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
await this.page.evaluate(() => {
window['app']['canvas'].show_info = false
})
await this.nextFrame()
if (resetView) {
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await this.resetView()
}
// Hide all badges by default.
await this.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.None)
await this.setSetting(
'Comfy.NodeBadge.NodeSourceBadgeMode',
NodeBadgeMode.None
)
// Hide canvas menu by default.
await this.setSetting('Comfy.Graph.CanvasMenu', false)
}
public assetPath(fileName: string) {
@@ -266,7 +528,7 @@ export class ComfyPage {
async setSetting(settingId: string, settingValue: any) {
return await this.page.evaluate(
async ({ id, value }) => {
await window['app'].extensionManager.setting.set(id, value)
await window['app'].ui.settings.setSettingValueAsync(id, value)
},
{ id: settingId, value: settingValue }
)
@@ -274,7 +536,7 @@ export class ComfyPage {
async getSetting(settingId: string) {
return await this.page.evaluate(async (id) => {
return await window['app'].extensionManager.setting.get(id)
return await window['app'].ui.settings.getSettingValue(id)
}, settingId)
}
@@ -722,19 +984,261 @@ export class ComfyPage {
}
}
export class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
readonly node: NodeReference
) {}
async getPosition() {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
)
},
[this.type, this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
}
return node.outputs[index].links?.length ?? 0
},
[this.type, this.node.id, this.index] as const
)
}
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
} else {
node.disconnectOutput(index)
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const [x, y, w, h] = node.getBounding()
return window['app'].canvas.ds.convertOffsetToCanvas([
x + w / 2,
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
])
},
[this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
}
export class NodeReference {
constructor(
readonly id: NodeId,
readonly comfyPage: ComfyPage
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return !!node
}, this.id)
}
getType(): Promise<string> {
return this.getProperty('type')
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
)
return {
x: pos[0],
y: pos[1]
}
}
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
return {
x,
y,
width,
height
}
}
async getSize(): Promise<Size> {
const size = await this.getProperty<[number, number]>('size')
return {
width: size[0],
height: size[1]
}
}
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
return await this.getProperty('flags')
}
async isPinned() {
return !!(await this.getFlags()).pinned
}
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
[this.id, prop] as const
)
}
async getOutput(index: number) {
return new NodeSlotReference('output', index, this)
}
async getInput(index: number) {
return new NodeSlotReference('input', index, this)
}
async getWidget(index: number) {
return new NodeWidgetReference(index, this)
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
break
case 'collapse':
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
break
default:
throw new Error(`Invalid click position ${position}`)
}
const moveMouseToEmptyArea = options?.moveMouseToEmptyArea
if (options) {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
await this.comfyPage.moveMouseToEmptyArea()
}
}
async copy() {
await this.click('title')
await this.comfyPage.ctrlC()
await this.comfyPage.nextFrame()
}
async connectWidget(
originSlotIndex: number,
targetNode: NodeReference,
targetWidgetIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetWidget.getPosition()
)
return originSlot
}
async connectOutput(
originSlotIndex: number,
targetNode: NodeReference,
targetSlotIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetSlot = await targetNode.getInput(targetSlotIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetSlot.getPosition()
)
return originSlot
}
async getContextMenuOptionNames() {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
return await ctx.locator('.litemenu-entry').allInnerTexts()
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
this.comfyPage.page.once('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
return new ManageGroupNode(
this.comfyPage.page,
this.comfyPage.page.locator('.comfy-group-manage')
)
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ page, request }, use) => {
const comfyPage = new ComfyPage(page, request)
await comfyPage.setupSettings({
// Hide canvas menu/info by default.
'Comfy.Graph.CanvasInfo': false,
'Comfy.Graph.CanvasMenu': false,
// Hide all badges by default.
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
// Disable tooltips by default to avoid flakiness.
'Comfy.EnableTooltips': false
})
await comfyPage.setup()
await use(comfyPage)
}

View File

@@ -1,7 +1,7 @@
import type { Response } from '@playwright/test'
import type { StatusWsMessage } from '../src/types/apiTypes.ts'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from './fixtures/ComfyPage'
import { comfyPageFixture } from './ComfyPage'
import { webSocketFixture } from './fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -11,6 +11,10 @@ test.describe('Actionbar', () => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
/**
* This test ensures that the autoqueue change mode can only queue one change at a time
*/

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Browser tab title', () => {
test.describe('Beta Menu', () => {
@@ -7,6 +7,10 @@ test.describe('Browser tab title', () => {
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

View File

@@ -2,7 +2,7 @@ import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} from './fixtures/ComfyPage'
} from './ComfyPage'
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
const customColorPalettes = {
obsidian: {
@@ -135,6 +135,12 @@ test.describe('Color Palette', () => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', {})
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
})
test('Can show custom color palette', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
await comfyPage.nextFrame()
@@ -152,6 +158,11 @@ test.describe('Node Color Adjustments', () => {
await comfyPage.loadWorkflow('every_node_color')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
})
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Keybindings', () => {
test('Should execute command', async ({ comfyPage }) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Load workflow warning', () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
@@ -63,15 +63,45 @@ test.describe('Missing models warning', () => {
const downloadButton = comfyPage.page.getByLabel('Download')
await expect(downloadButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
const downloadComplete = comfyPage.page.locator('.download-complete')
await expect(downloadComplete).toBeVisible()
})
test('Can configure download folder', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const folderSelectToggle = comfyPage.page.locator(
'.model-path-select-checkbox'
)
const folderSelect = comfyPage.page.locator('.model-path-select')
await expect(folderSelectToggle).toBeVisible()
await expect(folderSelect).not.toBeVisible()
await folderSelectToggle.click() // show the selectors
await expect(folderSelect).toBeVisible()
await folderSelect.click() // open dropdown
await expect(folderSelect).toHaveClass(/p-select-open/)
await folderSelect.click() // close the dropdown
await expect(folderSelect).not.toHaveClass(/p-select-open/)
await folderSelectToggle.click() // hide the selectors
await expect(folderSelect).not.toBeVisible()
})
})
test.describe('Settings', () => {
test.afterEach(async ({ comfyPage }) => {
// Restore default setting value
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const searchBox = comfyPage.page.locator('.settings-content')

View File

@@ -1,11 +1,15 @@
import { expect, Locator } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Topbar commands', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
@@ -79,59 +83,4 @@ test.describe('Topbar commands', () => {
true
)
})
test.describe('Settings', () => {
test('Should allow adding settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!',
onChange: () => {
window['changeCount'] = (window['changeCount'] ?? 0) + 1
}
}
]
})
})
// onChange is called when the setting is first added
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
})
test('Should allow setting boolean settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
id: 'Comfy.TestSetting',
name: 'Test Setting',
type: 'boolean',
defaultValue: false,
onChange: () => {
window['changeCount'] = (window['changeCount'] ?? 0) + 1
}
}
]
})
})
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(false)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
})
})
})

View File

@@ -1,79 +0,0 @@
import { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
async selectFilterType(filterType: string) {
await this.page
.locator(
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
)
.click()
}
async selectFilterValue(filterValue: string) {
await this.page.locator('.filter-value-select .p-select-dropdown').click()
await this.page
.locator(
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue)
await this.page.locator('.p-button-label:has-text("Add")').click()
}
}
export class ComfyNodeSearchBox {
public readonly input: Locator
public readonly dropdown: Locator
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
constructor(public readonly page: Page) {
this.input = page.locator(
'.comfy-vue-node-search-container input[type="text"]'
)
this.dropdown = page.locator(
'.comfy-vue-node-search-container .p-autocomplete-list'
)
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
}
get filterButton() {
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
}
async fillAndSelectFirstNode(
nodeName: string,
options?: { suggestionIndex: number }
) {
await this.input.waitFor({ state: 'visible' })
await this.input.fill(nodeName)
await this.dropdown.waitFor({ state: 'visible' })
// Wait for some time for the auto complete list to update.
// The auto complete list is debounced and may take some time to update.
await this.page.waitForTimeout(500)
await this.dropdown
.locator('li')
.nth(options?.suggestionIndex || 0)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.filterButton.click()
await this.filterSelectionPanel.addFilter(filterValue, filterType)
}
get filterChips() {
return this.page.locator(
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
)
}
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
}
}

View File

@@ -1,34 +0,0 @@
import { Page } from '@playwright/test'
export class SettingDialog {
constructor(public readonly page: Page) {}
async open() {
const button = this.page.locator('button.comfy-settings-btn:visible')
await button.click()
await this.page.waitForSelector('div.settings-container')
}
/**
* Set the value of a text setting
* @param id - The id of the setting
* @param value - The value to set
*/
async setStringSetting(id: string, value: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
await settingInputDiv.locator('input').fill(value)
}
/**
* Toggle the value of a boolean setting
* @param id - The id of the setting
*/
async toggleBooleanSetting(id: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
await settingInputDiv.locator('input').click()
}
}

View File

@@ -1,120 +0,0 @@
import { Page } from '@playwright/test'
class SidebarTab {
constructor(
public readonly page: Page,
public readonly tabId: string
) {}
get tabButton() {
return this.page.locator(`.${this.tabId}-tab-button`)
}
get selectedTabButton() {
return this.page.locator(
`.${this.tabId}-tab-button.side-bar-button-selected`
)
}
async open() {
if (await this.selectedTabButton.isVisible()) {
return
}
await this.tabButton.click()
}
}
export class NodeLibrarySidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'node-library')
}
get nodeLibrarySearchBoxInput() {
return this.page.locator('.node-lib-search-box input[type="text"]')
}
get nodeLibraryTree() {
return this.page.locator('.node-lib-tree-explorer')
}
get nodePreview() {
return this.page.locator('.node-lib-node-preview')
}
get tabContainer() {
return this.page.locator('.sidebar-content-container')
}
get newFolderButton() {
return this.tabContainer.locator('.new-folder-button')
}
async open() {
await super.open()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
async close() {
if (!this.tabButton.isVisible()) {
return
}
await this.tabButton.click()
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
}
folderSelector(folderName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
}
getFolder(folderName: string) {
return this.page.locator(this.folderSelector(folderName))
}
nodeSelector(nodeName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
}
getNode(nodeName: string) {
return this.page.locator(this.nodeSelector(nodeName))
}
}
export class WorkflowsSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'workflows')
}
get browseGalleryButton() {
return this.page.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.page.locator('.new-blank-workflow-button')
}
get openWorkflowButton() {
return this.page.locator('.open-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-open .node-label')
.allInnerTexts()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')
.allInnerTexts()
}
async switchToWorkflow(workflowName: string) {
const workflowLocator = this.page.locator(
'.comfyui-workflows-open .node-label',
{ hasText: workflowName }
)
await workflowLocator.click()
await this.page.waitForTimeout(300)
}
}

View File

@@ -1,66 +0,0 @@
import { Locator, Page } from '@playwright/test'
export class Topbar {
constructor(public readonly page: Page) {}
async getTabNames(): Promise<string[]> {
return await this.page
.locator('.workflow-tabs .workflow-label')
.allInnerTexts()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
async getMenuItem(itemLabel: string): Promise<Locator> {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
async getWorkflowTab(tabName: string): Promise<Locator> {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
.locator('..')
}
async closeWorkflowTab(tabName: string) {
const tab = await this.getWorkflowTab(tabName)
await tab.locator('.close-button').click({ force: true })
}
async saveWorkflow(workflowName: string) {
await this.triggerTopbarCommand(['Workflow', 'Save'])
await this.page.locator('.p-dialog-content input').fill(workflowName)
await this.page.keyboard.press('Enter')
// Wait for the dialog to close.
await this.page.waitForTimeout(300)
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
}
const tabName = path[0]
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
)
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.click()
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = this.page
.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
if (i === path.length - 1) {
await menuItem.click()
}
}
}
}

View File

@@ -1,9 +0,0 @@
export interface Position {
x: number
y: number
}
export interface Size {
width: number
height: number
}

View File

@@ -1,257 +0,0 @@
import { ManageGroupNode } from '../../helpers/manageGroupNode'
import type { NodeId } from '../../../src/types/comfyWorkflow'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import type { Position, Size } from '../types'
export class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
readonly node: NodeReference
) {}
async getPosition() {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
)
},
[this.type, this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
}
return node.outputs[index].links?.length ?? 0
},
[this.type, this.node.id, this.index] as const
)
}
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
} else {
node.disconnectOutput(index)
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const [x, y, w, h] = node.getBounding()
return window['app'].canvas.ds.convertOffsetToCanvas([
x + w / 2,
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
])
},
[this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
}
export class NodeReference {
constructor(
readonly id: NodeId,
readonly comfyPage: ComfyPage
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return !!node
}, this.id)
}
getType(): Promise<string> {
return this.getProperty('type')
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
)
return {
x: pos[0],
y: pos[1]
}
}
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
return {
x,
y,
width,
height
}
}
async getSize(): Promise<Size> {
const size = await this.getProperty<[number, number]>('size')
return {
width: size[0],
height: size[1]
}
}
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
return await this.getProperty('flags')
}
async isPinned() {
return !!(await this.getFlags()).pinned
}
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
[this.id, prop] as const
)
}
async getOutput(index: number) {
return new NodeSlotReference('output', index, this)
}
async getInput(index: number) {
return new NodeSlotReference('input', index, this)
}
async getWidget(index: number) {
return new NodeWidgetReference(index, this)
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
break
case 'collapse':
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
break
default:
throw new Error(`Invalid click position ${position}`)
}
const moveMouseToEmptyArea = options?.moveMouseToEmptyArea
if (options) {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
await this.comfyPage.moveMouseToEmptyArea()
}
}
async copy() {
await this.click('title')
await this.comfyPage.ctrlC()
await this.comfyPage.nextFrame()
}
async connectWidget(
originSlotIndex: number,
targetNode: NodeReference,
targetWidgetIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetWidget.getPosition()
)
return originSlot
}
async connectOutput(
originSlotIndex: number,
targetNode: NodeReference,
targetSlotIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetSlot = await targetNode.getInput(targetSlotIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetSlot.getPosition()
)
return originSlot
}
async getContextMenuOptionNames() {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
return await ctx.locator('.litemenu-entry').allInnerTexts()
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
this.comfyPage.page.once('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
return new ManageGroupNode(
this.comfyPage.page,
this.comfyPage.page.locator('.comfy-group-manage')
)
}
}

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Graph Canvas Menu', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,8 +1,11 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from './fixtures/ComfyPage'
import type { NodeReference } from './fixtures/utils/litegraphUtils'
import { ComfyPage, NodeReference, comfyPageFixture as test } from './ComfyPage'
test.describe('Group Node', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
@@ -16,6 +19,11 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await libraryTab.close()
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
@@ -90,7 +98,6 @@ test.describe('Group Node', () => {
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
const tooltipTimeout = 500
@@ -185,6 +192,13 @@ test.describe('Group Node', () => {
await groupNode.copy()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.page.evaluate((groupNodeName) => {
window['LiteGraph'].unregisterNodeType(groupNodeName)
}, GROUP_NODE_TYPE)
})
test('Copies and pastes group node within the same workflow', async ({
comfyPage
}) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Node Interaction', () => {
test('Can enter prompt', async ({ comfyPage }) => {
@@ -73,8 +73,6 @@ test.describe('Node Interaction', () => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge()
// Move mouse to empty area to avoid slot highlight.
await comfyPage.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
@@ -116,24 +114,6 @@ test.describe('Node Interaction', () => {
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot('copied-link.png')
})
test('Auto snap&highlight when dragging link over node', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.page.mouse.move(
comfyPage.clipTextEncodeNode1InputSlot.x,
comfyPage.clipTextEncodeNode1InputSlot.y
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
comfyPage.clipTextEncodeNode2InputSlot.x,
comfyPage.clipTextEncodeNode2InputSlot.y
)
await expect(comfyPage.canvas).toHaveScreenshot('snapped-highlighted.png')
})
})
test('Can adjust widget value', async ({ comfyPage }) => {
@@ -513,6 +493,10 @@ test.describe('Load duplicate workflow', () => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('A workflow can be loaded multiple times in a row', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Keybindings', () => {
test('Should not trigger non-modifier keybinding when typing in input fields', async ({

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
function listenForEvent(): Promise<Event> {
return new Promise<Event>((resolve) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Load Workflow in Media', () => {
;[

View File

@@ -1,11 +1,19 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// Skip reason: Flaky.
test.skip('Toggle theme', async ({ comfyPage }) => {
test.setTimeout(30000)
@@ -17,7 +25,8 @@ test.describe('Menu', () => {
expect(await comfyPage.menu.getThemeId()).toBe('light')
// Theme id should persist after reload.
await comfyPage.reload()
await comfyPage.page.reload()
await comfyPage.setup()
expect(await comfyPage.menu.getThemeId()).toBe('light')
await comfyPage.menu.toggleTheme()
@@ -359,7 +368,8 @@ test.describe('Menu', () => {
'KSampler'
])
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await comfyPage.reload()
await comfyPage.page.reload()
await comfyPage.setup()
expect(await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks')).toEqual(
[]
)
@@ -402,7 +412,7 @@ test.describe('Menu', () => {
'workflow2.json': 'default.json'
})
// Avoid reset view as the button is not visible in BetaMenu UI.
await comfyPage.setup()
await comfyPage.setup({ resetView: false })
const tab = comfyPage.menu.workflowsTab
await tab.open()

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
import type { ComfyApp } from '../src/scripts/app'
import { NodeBadgeMode } from '../src/types/nodeSource'

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Node search box', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import type { NodeReference } from './fixtures/utils/litegraphUtils'
import { type NodeReference, comfyPageFixture as test } from './ComfyPage'
test.describe('Primitive Node', () => {
test('Can load with correct size', async ({ comfyPage }) => {

View File

@@ -1,11 +1,15 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Properties Panel', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// TODO: Update expectation after new menu dropdown is added.
test.skip('Can change property value', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
import { NodeBadgeMode } from '../src/types/nodeSource'
test.describe('Canvas Right Click Menu', () => {
@@ -96,6 +96,11 @@ test.describe('Node Right Click Menu', () => {
test.describe('Widget conversion', () => {
const convertibleWidgetTypes = ['text', 'string', 'number', 'toggle']
test.afterEach(async ({ comfyPage }) => {
// Restore default setting value
await comfyPage.setSetting('Comfy.NodeInputConversionSubmenus', true)
})
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,11 +1,15 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Templates', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can load template workflows', async ({ comfyPage }) => {
// This test will need expanding on once the templates are decided

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Combo text widget', () => {
test('Truncates text when resized', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

26
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "comfyui-frontend",
"version": "1.3.30",
"version": "1.3.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.3.30",
"version": "1.3.19",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/litegraph": "^0.8.13",
"@comfyorg/litegraph": "^0.8.3",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",
@@ -58,7 +58,6 @@
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsc-files": "^1.1.4",
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
@@ -1911,9 +1910,9 @@
"dev": true
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.13.tgz",
"integrity": "sha512-WT0wlouwGQhjcvd+kQfAXooKTPWMu3CUb2bw702Hs3ctFnVEm8+Z9kaK6z1KRg45GkjhajCdlEuQ9XFQ5A0nVA==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.3.tgz",
"integrity": "sha512-BlKqsHhEAn1+ZXlS8Lv8oIVf9qjHyQSj7sJsnBqherqCl9RCksqiXZUXRu+/+GWlHapscQsZJcnPcoAHzGaOKA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -12106,19 +12105,6 @@
}
}
},
"node_modules/tsc-files": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/tsc-files/-/tsc-files-1.1.4.tgz",
"integrity": "sha512-RePsRsOLru3BPpnf237y1Xe1oCGta8rmSYzM76kYo5tLGsv5R2r3s64yapYorGTPuuLyfS9NVbh9ydzmvNie2w==",
"dev": true,
"license": "MIT",
"bin": {
"tsc-files": "cli.js"
},
"peerDependencies": {
"typescript": ">=3"
}
},
"node_modules/tsconfig": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",

View File

@@ -1,13 +1,12 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.3.30",
"version": "1.3.19",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"deploy": "npm run build && node scripts/deploy.js",
"release": "node scripts/release.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "tsc --noEmit && tsc-strict",
"format": "prettier --write './**/*.{js,ts,tsx,vue}'",
@@ -54,7 +53,6 @@
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsc-files": "^1.1.4",
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
@@ -68,7 +66,7 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/litegraph": "^0.8.13",
"@comfyorg/litegraph": "^0.8.3",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",
@@ -85,7 +83,9 @@
"zod-validation-error": "^3.3.0"
},
"lint-staged": {
"./**/*.{js,ts,tsx,vue}": "prettier --write",
"**/*.ts": "tsc-files --noEmit"
"./**/*.{js,ts,tsx,vue}": [
"prettier --write",
"git add"
]
}
}

View File

@@ -1,23 +0,0 @@
import { execSync } from 'child_process'
import { readFileSync } from 'fs'
try {
// Run npm version patch and capture the output
console.log('Bumping version...')
execSync('npm version patch', { stdio: 'inherit' })
// Read the new version from package.json
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'))
const newVersion = packageJson.version
// Create the PR
console.log('Creating PR...')
execSync(
`gh pr create --title "${newVersion}" --label "Release" --body "Automated version bump to ${newVersion}"`,
{ stdio: 'inherit' }
)
console.log(`✅ Successfully created PR for version ${newVersion}`)
} catch (error) {
console.error('❌ Error during release process:', error.message)
}

View File

@@ -11,7 +11,7 @@
<script setup lang="ts">
import config from '@/config'
import { computed, onMounted } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'

View File

@@ -181,6 +181,25 @@ body {
margin: 3px 3px 3px 4px;
}
.comfy-menu-hamburger {
position: fixed;
top: 10px;
z-index: 9999;
right: 10px;
width: 30px;
display: none;
gap: 8px;
flex-direction: column;
cursor: pointer;
}
.comfy-menu-hamburger div {
height: 3px;
width: 100%;
border-radius: 20px;
background-color: white;
}
.comfy-menu {
font-size: 15px;
position: absolute;

View File

@@ -1,8 +1,5 @@
<template>
<Splitter
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
>
<Splitter class="splitter-overlay" :pt:gutter="gutterClass">
<SplitterPanel
class="side-bar-panel"
:minSize="10"
@@ -12,22 +9,9 @@
>
<slot name="side-bar-panel"></slot>
</SplitterPanel>
<SplitterPanel :size="100">
<Splitter
class="splitter-overlay"
layout="vertical"
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
>
<SplitterPanel class="graph-canvas-panel relative">
<slot name="graph-canvas-panel"></slot>
</SplitterPanel>
<SplitterPanel class="bottom-panel" v-show="bottomPanelVisible">
<slot name="bottom-panel"></slot>
</SplitterPanel>
</Splitter>
<SplitterPanel class="graph-canvas-panel relative" :size="100">
<slot name="graph-canvas-panel"></slot>
</SplitterPanel>
<SplitterPanel
class="side-bar-panel"
:minSize="10"
@@ -42,8 +26,7 @@
<script setup lang="ts">
import { useSettingStore } from '@/stores/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
@@ -54,39 +37,42 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
)
const sidebarPanelVisible = computed(
() => useSidebarTabStore().activeSidebarTab !== null
)
const bottomPanelVisible = computed(
() => useBottomPanelStore().bottomPanelVisible
() => useWorkspaceStore().sidebarTab.activeSidebarTab !== null
)
const gutterClass = computed(() => {
return sidebarPanelVisible.value ? '' : 'gutter-hidden'
})
</script>
<style scoped>
:deep(.p-splitter-gutter) {
<style>
.p-splitter-gutter {
pointer-events: auto;
}
.gutter-hidden {
display: none !important;
}
</style>
<style scoped>
.side-bar-panel {
background-color: var(--bg-color);
pointer-events: auto;
}
.bottom-panel {
background-color: var(--bg-color);
pointer-events: auto;
}
.splitter-overlay {
@apply bg-transparent pointer-events-none border-none;
}
.splitter-overlay-root {
@apply w-full h-full absolute top-0 left-0;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background-color: transparent;
pointer-events: none;
/* Set it the same as the ComfyUI menu */
/* Note: Lite-graph DOM widgets have the same z-index as the node id, so
999 should be sufficient to make sure splitter overlays on node's DOM
widgets */
z-index: 999;
border: none;
}
</style>

View File

@@ -1,46 +0,0 @@
<template>
<Button
v-show="workspaceState.focusMode"
class="comfy-menu-hamburger"
icon="pi pi-bars"
severity="secondary"
text
size="large"
@click="exitFocusMode"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { watchEffect } from 'vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const exitFocusMode = () => {
workspaceState.focusMode = false
}
watchEffect(() => {
if (settingStore.get('Comfy.UseNewMenu') !== 'Disabled') {
return
}
if (workspaceState.focusMode) {
app.ui.menuContainer.style.display = 'none'
} else {
app.ui.menuContainer.style.display = 'block'
}
})
</script>
<style scoped>
.comfy-menu-hamburger {
pointer-events: auto;
position: fixed;
top: 0px;
right: 0px;
z-index: 9999;
}
</style>

View File

@@ -65,7 +65,7 @@ import type { MenuItem } from 'primevue/menuitem'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())

View File

@@ -1,51 +0,0 @@
<template>
<div class="flex flex-col h-full">
<Tabs v-model:value="bottomPanelStore.activeBottomPanelTabId">
<TabList pt:tabList="border-none">
<div class="w-full flex justify-between">
<div class="tabs-container">
<Tab
v-for="tab in bottomPanelStore.bottomPanelTabs"
:key="tab.id"
:value="tab.id"
class="p-3 border-none"
>
<span class="font-bold">
{{ tab.title.toUpperCase() }}
</span>
</Tab>
</div>
<Button
class="justify-self-end"
icon="pi pi-times"
severity="secondary"
size="small"
text
@click="bottomPanelStore.bottomPanelVisible = false"
/>
</div>
</TabList>
</Tabs>
<!-- h-0 to force the div to flex-grow -->
<div class="flex-grow h-0">
<ExtensionSlot
v-if="
bottomPanelStore.bottomPanelVisible &&
bottomPanelStore.activeBottomPanelTab
"
:extension="bottomPanelStore.activeBottomPanelTab"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist'
import Tab from 'primevue/tab'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -1,61 +0,0 @@
<template>
<div class="p-terminal rounded-none h-full w-full">
<ScrollPanel class="h-full w-full" ref="scrollPanelRef">
<pre class="px-4 whitespace-pre-wrap">{{ log }}</pre>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { api } from '@/scripts/api'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const log = ref<string>('')
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
/**
* Whether the user has scrolled to the bottom of the terminal.
* This is used to prevent the terminal from scrolling to the bottom
* when new logs are fetched.
*/
const scrolledToBottom = ref(false)
let intervalId: number = 0
onMounted(async () => {
const element = scrollPanelRef.value?.$el
const scrollContainer = element?.querySelector('.p-scrollpanel-content')
if (scrollContainer) {
scrollContainer.addEventListener('scroll', () => {
scrolledToBottom.value =
scrollContainer.scrollTop + scrollContainer.clientHeight ===
scrollContainer.scrollHeight
})
}
const scrollToBottom = () => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
watch(log, () => {
if (scrolledToBottom.value) {
scrollToBottom()
}
})
const fetchLogs = async () => {
log.value = await api.getLogs()
}
await fetchLogs()
scrollToBottom()
intervalId = window.setInterval(fetchLogs, 500)
})
onBeforeUnmount(() => {
window.clearInterval(intervalId)
})
</script>

View File

@@ -9,7 +9,7 @@
<script setup lang="ts">
import type { DeviceStats } from '@/types/apiTypes'
import { formatSize } from '@/utils/formatUtil'
import { formatMemory } from '@/utils/formatUtil'
const props = defineProps<{
device: DeviceStats
@@ -30,7 +30,7 @@ const formatValue = (value: any, field: string) => {
field
)
) {
return formatSize(value)
return formatMemory(value)
}
return value
}

View File

@@ -1,34 +0,0 @@
<template>
<component v-if="extension.type === 'vue'" :is="extension.component" />
<div
v-else
:ref="
(el) => {
if (el)
mountCustomExtension(
props.extension as CustomExtension,
el as HTMLElement
)
}
"
></div>
</template>
<script setup lang="ts">
import { CustomExtension, VueExtension } from '@/types/extensionTypes'
import { onBeforeUnmount } from 'vue'
const props = defineProps<{
extension: VueExtension | CustomExtension
}>()
const mountCustomExtension = (extension: CustomExtension, el: HTMLElement) => {
extension.render(el)
}
onBeforeUnmount(() => {
if (props.extension.type === 'custom' && props.extension.destroy) {
props.extension.destroy()
}
})
</script>

View File

@@ -1,44 +0,0 @@
<!-- A file download button with a label and a size hint -->
<template>
<div class="flex flex-row items-center gap-2">
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
</div>
<div v-if="props.error" class="file-error">
{{ props.error }}
</div>
</div>
<div class="file-action">
<Button
class="file-action-button"
:label="$t('download') + ' (' + fileSize + ')'"
size="small"
outlined
:disabled="props.error"
@click="download.triggerBrowserDownload"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useDownload } from '@/hooks/downloadHooks'
import Button from 'primevue/button'
import { computed } from 'vue'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="no-results-placeholder p-8 h-full" :class="props.class">
<div class="no-results-placeholder">
<Card>
<template #content>
<div class="flex flex-col items-center">
@@ -22,8 +22,7 @@
import Card from 'primevue/card'
import Button from 'primevue/button'
const props = defineProps<{
class?: string
defineProps<{
icon?: string
title: string
message: string
@@ -34,6 +33,11 @@ defineEmits(['action'])
</script>
<style scoped>
.no-results-placeholder {
height: 100%;
padding: 2rem;
}
.no-results-placeholder :deep(.p-card) {
background-color: var(--surface-ground);
text-align: center;

View File

@@ -35,7 +35,7 @@ import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import type { SystemStats } from '@/types/apiTypes'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import { formatSize } from '@/utils/formatUtil'
import { formatMemory } from '@/utils/formatUtil'
const props = defineProps<{
stats: SystemStats
@@ -58,7 +58,7 @@ const systemColumns = [
const formatValue = (value: any, field: string) => {
if (['ram_total', 'ram_free'].includes(field)) {
return formatSize(value)
return formatMemory(value)
}
return value
}

View File

@@ -46,14 +46,10 @@ const onUnmaximize = () => {
maximized.value = false
}
const contentProps = computed(() =>
maximizable.value
? {
...dialogStore.props,
maximized: maximized.value
}
: dialogStore.props
)
const contentProps = computed(() => ({
...dialogStore.props,
maximized: maximized.value
}))
const headerId = `dialog-${Math.random().toString(36).substr(2, 9)}`
</script>

View File

@@ -93,7 +93,6 @@ const generateReport = (systemStats: SystemStats, logs: string) => {
reportContent.value = `
# ComfyUI Error Report
## Error Details
- **Node ID:** ${props.error.node_id}
- **Node Type:** ${props.error.node_type}
- **Exception Type:** ${props.error.exception_type}
- **Exception Message:** ${props.error.exception_message}

View File

@@ -1,46 +1,57 @@
<template>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Missing Node Types"
message="When loading the graph, the following node types were not found"
/>
<ListBox
:options="uniqueNodes"
optionLabel="label"
scrollHeight="100%"
class="comfy-missing-nodes"
:pt="{
list: { class: 'border-none' }
}"
>
<template #option="slotProps">
<div class="flex align-items-center">
<span class="node-type">{{ slotProps.option.label }}</span>
<span v-if="slotProps.option.hint" class="node-hint">{{
slotProps.option.hint
}}</span>
<Button
v-if="slotProps.option.action"
@click="slotProps.option.action.callback"
:label="slotProps.option.action.text"
size="small"
outlined
/>
</div>
</template>
</ListBox>
<div class="comfy-missing-nodes">
<h4 class="warning-title">Warning: Missing Node Types</h4>
<p class="warning-description">
When loading the graph, the following node types were not found:
</p>
<ListBox
:options="uniqueNodes"
optionLabel="label"
scrollHeight="100%"
:class="'missing-nodes-list' + (props.maximized ? ' maximized' : '')"
:pt="{
list: { class: 'border-none' }
}"
>
<template #option="slotProps">
<div class="missing-node-item">
<span class="node-type">{{ slotProps.option.label }}</span>
<span v-if="slotProps.option.hint" class="node-hint">{{
slotProps.option.hint
}}</span>
<Button
v-if="slotProps.option.action"
@click="slotProps.option.action.callback"
:label="slotProps.option.action.text"
class="p-button-sm p-button-outlined"
/>
</div>
</template>
</ListBox>
<p v-if="hasAddedNodes" class="added-nodes-warning">
Nodes that have failed to load will show as red on the graph.
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ListBox from 'primevue/listbox'
import Button from 'primevue/button'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import type { MissingNodeType } from '@/types/comfy'
interface NodeType {
type: string
hint?: string
action?: {
text: string
callback: () => void
}
}
const props = defineProps<{
missingNodeTypes: MissingNodeType[]
missingNodeTypes: (string | NodeType)[]
hasAddedNodes: boolean
maximized: boolean
}>()
const uniqueNodes = computed(() => {
@@ -65,12 +76,51 @@ const uniqueNodes = computed(() => {
})
</script>
<style>
:root {
--red-600: #dc3545;
}
</style>
<style scoped>
.comfy-missing-nodes {
font-family: monospace;
color: var(--red-600);
padding: 1.5rem;
background-color: var(--surface-ground);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
.warning-title {
margin-top: 0;
margin-bottom: 1rem;
}
.warning-description {
margin-bottom: 1rem;
}
.missing-nodes-list {
max-height: 300px;
overflow-y: auto;
}
.missing-nodes-list.maximized {
max-height: unset;
}
.missing-node-item {
display: flex;
align-items: center;
padding: 0.5rem;
}
.node-type {
font-weight: 600;
color: var(--text-color);
}
.node-hint {
margin-left: 0.5rem;
font-style: italic;
@@ -80,4 +130,9 @@ const uniqueNodes = computed(() => {
:deep(.p-button) {
margin-left: auto;
}
.added-nodes-warning {
margin-top: 1rem;
font-style: italic;
}
</style>

View File

@@ -1,26 +1,96 @@
<template>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Missing Models"
message="When loading the graph, the following models were not found"
/>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
<FileDownload
:url="option.url"
:label="option.label"
:error="option.error"
<div class="comfy-missing-models">
<h4 class="warning-title">Warning: Missing Models</h4>
<p class="warning-description">
When loading the graph, the following models were not found:
</p>
<p class="warning-options">
<Checkbox
class="model-path-select-checkbox"
v-model="showFolderSelect"
label="Show folder selector"
:binary="true"
/>
</template>
</ListBox>
Show folder selector
</p>
<ListBox
:options="missingModels"
optionLabel="label"
scrollHeight="100%"
:class="'missing-models-list' + (props.maximized ? ' maximized' : '')"
:pt="{
list: { class: 'border-none' }
}"
>
<template #option="slotProps">
<div
class="missing-model-item"
:style="{ '--progress': `${slotProps.option.progress}%` }"
>
<div class="model-info">
<div class="model-details">
<span class="model-type" :title="slotProps.option.hint">{{
slotProps.option.label
}}</span>
</div>
<div v-if="slotProps.option.error" class="model-error">
{{ slotProps.option.error }}
</div>
</div>
<div class="model-action">
<Select
class="model-path-select"
v-if="
slotProps.option.action &&
!slotProps.option.downloading &&
!slotProps.option.completed &&
!slotProps.option.error &&
showFolderSelect
"
v-model="slotProps.option.folderPath"
:options="slotProps.option.paths"
@change="updateFolderPath(slotProps.option, $event)"
/>
<Button
v-if="
slotProps.option.action &&
!slotProps.option.downloading &&
!slotProps.option.completed &&
!slotProps.option.error
"
@click="slotProps.option.action.callback"
:label="slotProps.option.action.text"
class="p-button-sm p-button-outlined model-action-button"
/>
<div v-if="slotProps.option.downloading" class="download-progress">
<span class="progress-text"
>{{ slotProps.option.progress.toFixed(2) }}%</span
>
</div>
<div v-if="slotProps.option.completed" class="download-complete">
<i class="pi pi-check" style="color: var(--green-500)"></i>
</div>
<div v-if="slotProps.option.error" class="download-error">
<i class="pi pi-times" style="color: var(--red-600)"></i>
</div>
</div>
</div>
</template>
</ListBox>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import Select from 'primevue/select'
import { SelectChangeEvent } from 'primevue/select'
import Button from 'primevue/button'
import { api } from '@/scripts/api'
import { DownloadModelStatus } from '@/types/apiTypes'
const showFolderSelect = ref(false)
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
@@ -46,16 +116,90 @@ interface ModelInfo {
const props = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
maximized: boolean
}>()
const modelDownloads = ref<Record<string, ModelInfo>>({})
let lastModel: string | null = null
const updateFolderPath = (model: any, event: SelectChangeEvent) => {
const downloadInfo = modelDownloads.value[model.name]
downloadInfo.folder_path = event.value
return false
}
const handleDownloadProgress = (detail: DownloadModelStatus) => {
if (detail.download_path) {
lastModel = detail.download_path
}
if (!lastModel) return
if (detail.status === 'in_progress') {
modelDownloads.value[lastModel] = {
...modelDownloads.value[lastModel],
downloading: true,
progress: detail.progress_percentage,
completed: false
}
} else if (detail.status === 'pending') {
modelDownloads.value[lastModel] = {
...modelDownloads.value[lastModel],
downloading: true,
progress: 0,
completed: false
}
} else if (detail.status === 'completed') {
modelDownloads.value[lastModel] = {
...modelDownloads.value[lastModel],
downloading: false,
progress: 100,
completed: true
}
} else if (detail.status === 'error') {
modelDownloads.value[lastModel] = {
...modelDownloads.value[lastModel],
downloading: false,
progress: 0,
error: detail.message,
completed: false
}
}
// TODO: other statuses?
}
const triggerDownload = async (
url: string,
directory: string,
filename: string,
folder_path: string
) => {
modelDownloads.value[filename] = {
name: filename,
directory,
url,
downloading: true,
progress: 0
}
const download = await api.internalDownloadModel(
url,
directory,
filename,
1,
folder_path
)
lastModel = filename
handleDownloadProgress(download)
}
api.addEventListener('download_progress', (event: CustomEvent) => {
handleDownloadProgress(event.detail)
})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
const paths = props.paths[model.directory]
if (model.directory_invalid || !paths) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
hint: model.url,
error: 'Invalid directory specified (does this require custom nodes?)'
}
}
@@ -73,35 +217,168 @@ const missingModels = computed(() => {
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
hint: model.url,
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
}
}
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
hint: model.url,
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
}
}
return {
url: model.url,
label: `${model.directory} / ${model.name}`,
hint: model.url,
downloading: downloadInfo.downloading,
completed: downloadInfo.completed,
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: paths,
folderPath: downloadInfo.folder_path
folderPath: downloadInfo.folder_path,
action: {
text: 'Download',
callback: () =>
triggerDownload(
model.url,
model.directory,
model.name,
downloadInfo.folder_path
)
}
}
})
})
</script>
<style>
:root {
--red-600: #dc3545;
--green-500: #28a745;
}
</style>
<style scoped>
.comfy-missing-models {
font-family: monospace;
color: var(--red-600);
padding: 1.5rem;
background-color: var(--surface-ground);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
.warning-title {
margin-top: 0;
margin-bottom: 1rem;
}
.warning-description {
margin-bottom: 1rem;
}
.warning-options {
color: var(--fg-color);
}
.missing-models-list {
max-height: 300px;
overflow-y: auto;
}
.missing-models-list.maximized {
max-height: unset;
}
.missing-model-item {
display: flex;
align-items: flex-start;
padding: 0.5rem;
position: relative;
overflow: hidden;
width: 100%;
}
.missing-model-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: var(--progress);
background-color: var(--green-500);
opacity: 0.2;
transition: width 0.3s ease;
}
.model-info {
flex: 1;
min-width: 0;
z-index: 1;
display: flex;
flex-direction: column;
margin-right: 1rem;
overflow: hidden;
}
.model-details {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.model-type {
font-weight: 600;
color: var(--text-color);
margin-right: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-hint {
font-style: italic;
color: var(--text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-error {
color: var(--red-600);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.model-action {
display: flex;
align-items: center;
justify-content: flex-end;
z-index: 1;
}
.model-action-button {
min-width: 80px;
}
.download-progress,
.download-complete,
.download-error {
display: flex;
align-items: center;
justify-content: center;
min-width: 80px;
}
.progress-text {
font-size: 0.8rem;
color: var(--text-color);
}
.download-complete i,
.download-error i {
font-size: 1.2rem;
}
</style>

View File

@@ -86,6 +86,7 @@ function getSettingAttrs(setting: SettingParams) {
}
const updateSetting = (setting: SettingParams, value: any) => {
if (setting.onChange) setting.onChange(value, settingStore.get(setting.id))
settingStore.set(setting.id, value)
}

View File

@@ -4,9 +4,6 @@
<template #side-bar-panel>
<SideToolbar />
</template>
<template #bottom-panel>
<BottomPanel />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu v-if="canvasMenuEnabled" />
</template>
@@ -17,22 +14,19 @@
</teleport>
<NodeSearchboxPopover />
<NodeTooltip v-if="tooltipEnabled" />
<NodeBadge />
</template>
<script setup lang="ts">
import TitleEditor from '@/components/graph/TitleEditor.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeBadge from '@/components/graph/NodeBadge.vue'
import { ref, computed, onMounted, watchEffect } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import {
LiteGraph,
LGraph,
@@ -62,9 +56,7 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const modelToNodeStore = useModelToNodeStore()
const betaMenuEnabled = computed(
() =>
settingStore.get('Comfy.UseNewMenu') !== 'Disabled' &&
!workspaceStore.focusMode
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const canvasMenuEnabled = computed(() =>
settingStore.get('Comfy.Graph.CanvasMenu')
@@ -85,16 +77,6 @@ watchEffect(() => {
}
})
watchEffect(() => {
LiteGraph.snaps_for_comfy = settingStore.get('Comfy.Node.AutoSnapLinkToSlot')
})
watchEffect(() => {
LiteGraph.snap_highlights_node = settingStore.get(
'Comfy.Node.SnapHighlightsNode'
)
})
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -117,23 +99,15 @@ watchEffect(() => {
})
})
watchEffect(() => {
const linkRenderMode = settingStore.get('Comfy.LinkRenderMode')
if (canvasStore.canvas) {
canvasStore.canvas.links_render_mode = linkRenderMode
canvasStore.canvas.setDirty(/* fg */ false, /* bg */ true)
}
})
watchEffect(() => {
if (!canvasStore.canvas) return
if (canvasStore.canvas.state.draggingCanvas) {
if (canvasStore.canvas.dragging_canvas) {
canvasStore.canvas.canvas.style.cursor = 'grabbing'
return
}
if (canvasStore.canvas.state.readOnly) {
if (canvasStore.canvas.read_only) {
canvasStore.canvas.canvas.style.cursor = 'grab'
return
}

View File

@@ -1,93 +0,0 @@
<template>
<div>
<!-- This component does not render anything visible. -->
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import {
defaultColorPalette,
getColorPalette
} from '@/extensions/core/colorPalette'
import { app } from '@/scripts/app'
import type { LGraphNode } from '@comfyorg/litegraph'
import { BadgePosition } from '@comfyorg/litegraph'
import { LGraphBadge } from '@comfyorg/litegraph'
import _ from 'lodash'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
const settingStore = useSettingStore()
const nodeSourceBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
)
const nodeIdBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
)
const nodeLifeCycleBadgeMode = computed(
() =>
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') as NodeBadgeMode
)
watch([nodeSourceBadgeMode, nodeIdBadgeMode, nodeLifeCycleBadgeMode], () => {
app.graph?.setDirtyCanvas(true, true)
})
const colorPalette = computed(() =>
getColorPalette(settingStore.get('Comfy.ColorPalette'))
)
const nodeDefStore = useNodeDefStore()
function badgeTextVisible(
nodeDef: ComfyNodeDefImpl | null,
badgeMode: NodeBadgeMode
): boolean {
return !(
badgeMode === NodeBadgeMode.None ||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
)
}
onMounted(() => {
app.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
node.badgePosition = BadgePosition.TopRight
const badge = computed(() => {
const nodeDef = nodeDefStore.fromLGraphNode(node)
return new LGraphBadge({
text: _.truncate(
[
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
? `#${node.id}`
: '',
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
? nodeDef?.nodeLifeCycleBadgeText ?? ''
: '',
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
? nodeDef?.nodeSource?.badgeText ?? ''
: ''
]
.filter((s) => s.length > 0)
.join(' '),
{
length: 31
}
),
fgColor:
colorPalette.value.colors.litegraph_base?.BADGE_FG_COLOR ||
defaultColorPalette.colors.litegraph_base.BADGE_FG_COLOR,
bgColor:
colorPalette.value.colors.litegraph_base?.BADGE_BG_COLOR ||
defaultColorPalette.colors.litegraph_base.BADGE_BG_COLOR
})
})
node.badges.push(() => badge.value)
}
})
})
</script>

View File

@@ -106,6 +106,7 @@ const canvasEventHandler = (event: LiteGraphCanvasEvent) => {
const [x, y] = group.pos
const e = event.detail.originalEvent
// @ts-expect-error LiteGraphCanvasEvent is not typed
const relativeY = e.canvasY - y
// Only allow editing if the click is on the title bar
if (relativeY > group.titleHeight) {

View File

@@ -17,11 +17,20 @@
</div>
</nav>
</teleport>
<div
v-if="selectedTab"
class="sidebar-content-container h-full overflow-y-auto overflow-x-hidden"
>
<ExtensionSlot :extension="selectedTab" />
<div v-if="selectedTab" class="sidebar-content-container">
<component v-if="selectedTab.type === 'vue'" :is="selectedTab.component" />
<div
v-else
:ref="
(el) => {
if (el)
mountCustomTab(
selectedTab as CustomSidebarTabExtension,
el as HTMLElement
)
}
"
></div>
</div>
</template>
@@ -29,11 +38,13 @@
import SidebarIcon from './SidebarIcon.vue'
import SidebarThemeToggleIcon from './SidebarThemeToggleIcon.vue'
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import { computed } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { computed, onBeforeUnmount } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { useSettingStore } from '@/stores/settingStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import {
CustomSidebarTabExtension,
SidebarTabExtension
} from '@/types/extensionTypes'
import { useKeybindingStore } from '@/stores/keybindingStore'
const workspaceStore = useWorkspaceStore()
@@ -51,9 +62,20 @@ const isSmall = computed(
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const mountCustomTab = (tab: CustomSidebarTabExtension, el: HTMLElement) => {
tab.render(el)
}
const onTabClick = (item: SidebarTabExtension) => {
workspaceStore.sidebarTab.toggleSidebarTab(item.id)
}
onBeforeUnmount(() => {
tabs.value.forEach((tab) => {
if (tab.type === 'custom' && tab.destroy) {
tab.destroy()
}
})
})
const keybindingStore = useKeybindingStore()
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const keybinding = keybindingStore.getKeybindingByCommandId(
@@ -93,4 +115,9 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
align-self: flex-end;
margin-top: auto;
}
.sidebar-content-container {
height: 100%;
overflow-y: auto;
}
</style>

View File

@@ -3,28 +3,11 @@
:title="$t('sideToolbar.modelLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
icon="pi pi-refresh"
@click="modelStore.loadModelFolders"
severity="secondary"
text
v-tooltip="$t('refresh')"
/>
<Button
icon="pi pi-cloud-download"
@click="modelStore.loadModels"
severity="secondary"
text
v-tooltip="$t('loadAllFolders')"
/>
</template>
<template #header>
<SearchBox
class="model-lib-search-box p-4"
v-model:modelValue="searchQuery"
:placeholder="$t('searchModels') + '...'"
@search="handleSearch"
/>
</template>
<template #body>
@@ -32,6 +15,7 @@
class="model-lib-tree-explorer py-0"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<ModelTreeLeaf :node="node" />
@@ -43,17 +27,12 @@
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import SearchBox from '@/components/common/SearchBox.vue'
import { useI18n } from 'vue-i18n'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
import {
ComfyModelDef,
ModelFolder,
ResourceState,
useModelStore
} from '@/stores/modelStore'
import { ComfyModelDef, useModelStore } from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useSettingStore } from '@/stores/settingStore'
import { useTreeExpansion } from '@/hooks/treeHooks'
@@ -61,55 +40,77 @@ import type {
RenderedTreeExplorerNode,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { computed, ref, watch, toRef, onMounted, nextTick } from 'vue'
import { computed, ref, type ComputedRef, watch, toRef } from 'vue'
import type { TreeNode } from 'primevue/treenode'
import { app } from '@/scripts/app'
import { buildTree } from '@/utils/treeUtil'
const { t } = useI18n()
const modelStore = useModelStore()
const modelToNodeStore = useModelToNodeStore()
const settingStore = useSettingStore()
const searchQuery = ref<string>('')
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const filteredModels = ref<ComfyModelDef[]>([])
const handleSearch = async (query: string) => {
if (!query) {
filteredModels.value = []
expandedKeys.value = {}
return
const root: ComputedRef<TreeNode> = computed(() => {
let modelList: ComfyModelDef[] = []
if (!modelStore.modelFolders.length) {
modelStore.getModelFolders()
}
// Load all models to ensure we have the latest data
await modelStore.loadModels()
const search = query.toLocaleLowerCase()
filteredModels.value = modelStore.models.filter((model: ComfyModelDef) => {
return model.searchable.includes(search)
})
nextTick(() => {
expandNode(root.value)
})
}
type ModelOrFolder = ComfyModelDef | ModelFolder
const root = computed<TreeNode>(() => {
const allNodes: ModelOrFolder[] = searchQuery.value
? filteredModels.value
: [...modelStore.modelFolders, ...modelStore.models]
return buildTree(allNodes, (modelOrFolder: ModelOrFolder) =>
modelOrFolder.key.split('/')
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
for (let folder of modelStore.modelFolders) {
modelStore.getModelsInFolderCached(folder)
}
}
for (let folder of modelStore.modelFolders) {
const models = modelStore.modelStoreMap[folder]
if (models) {
if (Object.values(models.models).length) {
modelList.push(...Object.values(models.models))
} else {
// ModelDef with key 'folder/a/b/c/' is treated as empty folder
const fakeModel = new ComfyModelDef('', folder)
fakeModel.is_fake_object = true
modelList.push(fakeModel)
}
} else {
const fakeModel = new ComfyModelDef('Loading', folder)
fakeModel.is_fake_object = true
modelList.push(fakeModel)
}
}
if (searchQuery.value) {
const search = searchQuery.value.toLocaleLowerCase()
modelList = modelList.filter((model: ComfyModelDef) => {
return model.searchable.includes(search)
})
}
const tree: TreeNode = buildTree(modelList, (model: ComfyModelDef) =>
model.key.split('/')
)
return tree
})
const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
const nameFormat = settingStore.get('Comfy.ModelLibrary.NameFormat')
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ModelOrFolder> => {
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ComfyModelDef> => {
const children = node.children?.map(fillNodeInfo)
const model: ComfyModelDef | null =
node.leaf && node.data ? node.data : null
const folder: ModelFolder | null =
!node.leaf && node.data ? node.data : null
if (model?.is_fake_object) {
if (model.file_name === 'Loading') {
return {
key: node.key,
label: t('loading') + '...',
leaf: true,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
return 'pi pi-spin pi-spinner'
},
children: []
}
}
}
return {
key: node.key,
@@ -120,29 +121,32 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
: node.label,
leaf: node.leaf,
data: node.data,
getIcon: () => {
if (model) {
return model.image ? 'pi pi-image' : 'pi pi-file'
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
if (node.leaf) {
if (node.data && node.data.image) {
return 'pi pi-fake-spacer'
}
return 'pi pi-file'
}
if (folder) {
return folder.state === ResourceState.Loading
? 'pi pi-spin pi-spinner'
: 'pi pi-folder'
}
return 'pi pi-folder'
},
getBadgeText: () => {
// Return null to apply default badge text
// Return empty string to hide badge
if (!folder) {
getBadgeText: (node: TreeExplorerNode<ComfyModelDef>) => {
if (node.leaf) {
return null
}
return folder.state === ResourceState.Loaded ? null : ''
if (node.children?.length === 1) {
const onlyChild = node.children[0]
if (onlyChild.data?.is_fake_object) {
if (onlyChild.data.file_name === 'Loading') {
return ''
}
}
}
return null
},
children,
draggable: node.leaf,
handleClick: (
node: RenderedTreeExplorerNode<ModelOrFolder>,
node: RenderedTreeExplorerNode<ComfyModelDef>,
e: MouseEvent
) => {
if (node.leaf) {
@@ -158,8 +162,6 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
widget.value = model.file_name
}
}
} else {
toggleNodeOnEvent(e, node)
}
}
}
@@ -167,6 +169,17 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
return fillNodeInfo(root.value)
})
const handleNodeClick = (
node: RenderedTreeExplorerNode<ComfyModelDef>,
e: MouseEvent
) => {
if (node.leaf) {
// TODO
} else {
toggleNodeOnEvent(e, node)
}
}
watch(
toRef(expandedKeys, 'value'),
(newExpandedKeys) => {
@@ -175,19 +188,13 @@ watch(
const folderPath = key.split('/').slice(1).join('/')
if (folderPath && !folderPath.includes('/')) {
// Trigger (async) load of model data for this folder
modelStore.getLoadedModelFolder(folderPath)
modelStore.getModelsInFolderCached(folderPath)
}
}
})
},
{ deep: true }
)
onMounted(async () => {
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
await modelStore.loadModels()
}
})
</script>
<style scoped>

View File

@@ -18,7 +18,9 @@
</div>
<!-- h-0 to force scrollpanel to flex-grow -->
<ScrollPanel class="comfy-vue-side-bar-body flex-grow h-0">
<slot name="body"></slot>
<div class="h-full">
<slot name="body"></slot>
</div>
</ScrollPanel>
</div>
</template>

View File

@@ -57,6 +57,9 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
)
const handleModelHover = async () => {
if (modelDef.value.is_fake_object) {
return
}
const hoverTarget = modelContentElement.value
const targetRect = hoverTarget.getBoundingClientRect()
@@ -84,6 +87,7 @@ const showPreview = computed(() => {
return (
isHovered.value &&
modelDef.value &&
!modelDef.value.is_fake_object &&
modelDef.value.has_loaded_metadata &&
(modelDef.value.author ||
modelDef.value.simplified_file_name != modelDef.value.title ||
@@ -95,6 +99,9 @@ const showPreview = computed(() => {
})
const handleMouseEnter = async () => {
if (modelDef.value.is_fake_object) {
return
}
isHovered.value = true
await nextTick()
handleModelHover()
@@ -106,7 +113,9 @@ onMounted(() => {
modelContentElement.value = container.value?.closest('.p-tree-node-content')
modelContentElement.value?.addEventListener('mouseenter', handleMouseEnter)
modelContentElement.value?.addEventListener('mouseleave', handleMouseLeave)
modelDef.value.load()
if (!modelDef.value.is_fake_object) {
modelDef.value.load()
}
})
onUnmounted(() => {

View File

@@ -144,7 +144,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
return 'pi pi-circle-fill'
}
const customization =
nodeBookmarkStore.bookmarksCustomization[node.data?.nodePath]
nodeBookmarkStore.bookmarksCustomization[node.data.nodePath]
return customization?.icon
? 'pi ' + customization.icon
: 'pi pi-bookmark-fill'

View File

@@ -1,23 +0,0 @@
<template>
<Button
v-show="bottomPanelStore.bottomPanelTabs.length > 0"
severity="secondary"
text
@click="bottomPanelStore.toggleBottomPanel"
v-tooltip="{ value: $t('menu.toggleBottomPanel'), showDelay: 300 }"
>
<template #icon>
<i-material-symbols:dock-to-bottom
v-if="bottomPanelStore.bottomPanelVisible"
/>
<i-material-symbols:dock-to-bottom-outline v-else />
</template>
</Button>
</template>
<script setup lang="ts">
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import Button from 'primevue/button'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -14,7 +14,6 @@
</div>
<div class="comfyui-menu-right" ref="menuRight"></div>
<Actionbar />
<BottomPanelToggleButton />
</div>
</teleport>
</template>
@@ -24,22 +23,17 @@ import Divider from 'primevue/divider'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
import { computed, onMounted, provide, ref } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import { app } from '@/scripts/app'
import { useEventBus } from '@vueuse/core'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const betaMenuEnabled = computed(
() =>
settingStore.get('Comfy.UseNewMenu') !== 'Disabled' &&
!workspaceState.focusMode
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const teleportTarget = computed(() =>
settingStore.get('Comfy.UseNewMenu') === 'Top'

View File

@@ -1,20 +1,13 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { ComfyDialog, $el } from '../../scripts/ui'
import { ComfyApp } from '../../scripts/app'
export class ClipspaceDialog extends ComfyDialog {
static items: Array<
HTMLButtonElement & {
contextPredicate?: () => boolean
}
> = []
static instance: ClipspaceDialog | null = null
static items = []
static instance = null
static registerButton(
name: string,
contextPredicate: () => boolean,
callback: () => void
) {
static registerButton(name, contextPredicate, callback) {
const item = $el('button', {
type: 'button',
textContent: name,
@@ -54,9 +47,7 @@ export class ClipspaceDialog extends ComfyDialog {
if (self.element) {
// update
if (self.element.firstChild) {
self.element.removeChild(self.element.firstChild)
}
self.element.removeChild(self.element.firstChild)
self.element.appendChild(children)
} else {
// new
@@ -104,7 +95,7 @@ export class ClipspaceDialog extends ComfyDialog {
}
createImgSettings() {
if (ComfyApp.clipspace?.imgs) {
if (ComfyApp.clipspace.imgs) {
const combo_items = []
const imgs = ComfyApp.clipspace.imgs
@@ -116,13 +107,9 @@ export class ClipspaceDialog extends ComfyDialog {
'select',
{
id: 'clipspace_img_selector',
onchange: (event: Event) => {
if (event.target && ComfyApp.clipspace) {
ComfyApp.clipspace['selectedIndex'] = (
event.target as HTMLSelectElement
).selectedIndex
ClipspaceDialog.invalidatePreview()
}
onchange: (event) => {
ComfyApp.clipspace['selectedIndex'] = event.target.selectedIndex
ClipspaceDialog.invalidatePreview()
}
},
combo_items
@@ -137,12 +124,8 @@ export class ClipspaceDialog extends ComfyDialog {
'select',
{
id: 'clipspace_img_paste_mode',
onchange: (event: Event) => {
if (event.target && ComfyApp.clipspace) {
ComfyApp.clipspace['img_paste_mode'] = (
event.target as HTMLSelectElement
).value
}
onchange: (event) => {
ComfyApp.clipspace['img_paste_mode'] = event.target.value
}
},
[
@@ -172,7 +155,7 @@ export class ClipspaceDialog extends ComfyDialog {
}
createImgPreview() {
if (ComfyApp.clipspace?.imgs) {
if (ComfyApp.clipspace.imgs) {
return $el('img', { id: 'clipspace_preview', ondragstart: () => false })
} else return []
}
@@ -188,6 +171,7 @@ export class ClipspaceDialog extends ComfyDialog {
app.registerExtension({
name: 'Comfy.Clipspace',
init(app) {
// @ts-expect-error Move to ComfyApp
app.openClipspace = function () {
if (!ClipspaceDialog.instance) {
ClipspaceDialog.instance = new ClipspaceDialog()

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
@@ -29,17 +30,17 @@ const ext = {
// We must request an animation frame for the current node of the active canvas to update.
requestAnimationFrame(() => {
const currentNode = LGraphCanvas.active_canvas.current_node
const clickedComboValue = currentNode?.widgets
const clickedComboValue = currentNode.widgets
?.filter(
(w) =>
w.type === 'combo' && w.options.values?.length === values.length
w.type === 'combo' && w.options.values.length === values.length
)
.find((w) =>
w.options.values?.every((v, i) => v === values[i])
w.options.values.every((v, i) => v === values[i])
)?.value
let selectedIndex = clickedComboValue
? values.findIndex((v: string) => v === clickedComboValue)
? values.findIndex((v) => v === clickedComboValue)
: 0
if (selectedIndex < 0) {
selectedIndex = 0
@@ -121,7 +122,7 @@ const ext = {
// When filtering, recompute which items are visible for arrow up/down and maintain selection.
displayedItems = items.filter((item) => {
const isVisible =
!term || item.textContent?.toLocaleLowerCase().includes(term)
!term || item.textContent.toLocaleLowerCase().includes(term)
item.style.display = isVisible ? 'block' : 'none'
return isVisible
})

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
@@ -17,22 +18,14 @@ app.registerExtension({
defaultValue: 0.05
})
function incrementWeight(weight: string, delta: number): string {
function incrementWeight(weight, delta) {
const floatWeight = parseFloat(weight)
if (isNaN(floatWeight)) return weight
const newWeight = floatWeight + delta
return String(Number(newWeight.toFixed(10)))
}
type Enclosure = {
start: number
end: number
}
function findNearestEnclosure(
text: string,
cursorPos: number
): Enclosure | null {
function findNearestEnclosure(text, cursorPos) {
let start = cursorPos,
end = cursorPos
let openCount = 0,
@@ -45,7 +38,7 @@ app.registerExtension({
if (text[start] === '(') openCount++
if (text[start] === ')') closeCount++
}
if (start < 0) return null
if (start < 0) return false
openCount = 0
closeCount = 0
@@ -57,12 +50,12 @@ app.registerExtension({
if (text[end] === ')') closeCount++
end++
}
if (end === text.length) return null
if (end === text.length) return false
return { start: start + 1, end: end }
}
function addWeightToParentheses(text: string): string {
function addWeightToParentheses(text) {
const parenRegex = /^\((.*)\)$/
const parenMatch = text.match(parenRegex)

View File

@@ -8,6 +8,7 @@ import './groupNodeManage'
import './groupOptions'
import './invertMenuScrolling'
import './keybinds'
import './linkRenderMode'
import './maskeditor'
import './nodeTemplates'
import './noteNode'
@@ -20,3 +21,4 @@ import './uploadImage'
import './webcamCapture'
import './widgetInputs'
import './uploadAudio'
import './nodeBadge'

View File

@@ -0,0 +1,28 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { LiteGraph } from '@comfyorg/litegraph'
const id = 'Comfy.LinkRenderMode'
const ext = {
name: id,
async setup(app) {
app.ui.settings.addSetting({
id,
category: ['Comfy', 'Graph', 'LinkRenderMode'],
name: 'Link Render Mode',
defaultValue: 2,
type: 'combo',
options: [
{ value: LiteGraph.STRAIGHT_LINK, text: 'Straight' },
{ value: LiteGraph.LINEAR_LINK, text: 'Linear' },
{ value: LiteGraph.SPLINE_LINK, text: 'Spline' },
{ value: LiteGraph.HIDDEN_LINK, text: 'Hidden' }
],
onChange(value: number) {
app.canvas.links_render_mode = +value
app.canvas.setDirty(/* fg */ false, /* bg */ true)
}
})
}
}
app.registerExtension(ext)

View File

@@ -0,0 +1,150 @@
// @ts-strict-ignore
import { app, type ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
import type { LGraphNode } from '@comfyorg/litegraph'
import { LGraphBadge } from '@comfyorg/litegraph'
import { useSettingStore } from '@/stores/settingStore'
import { computed, ComputedRef, watch } from 'vue'
import { NodeBadgeMode, NodeSource, NodeSourceType } from '@/types/nodeSource'
import _ from 'lodash'
import { getColorPalette, defaultColorPalette } from './colorPalette'
import { BadgePosition } from '@comfyorg/litegraph'
import type { Palette } from '@/types/colorPalette'
import { useNodeDefStore } from '@/stores/nodeDefStore'
function getNodeSource(node: LGraphNode): NodeSource | null {
const nodeDef = node.constructor.nodeData
// Frontend-only nodes don't have nodeDef
if (!nodeDef) {
return null
}
const nodeDefStore = useNodeDefStore()
return nodeDefStore.nodeDefsByName[nodeDef.name]?.nodeSource ?? null
}
function isCoreNode(node: LGraphNode) {
return getNodeSource(node)?.type === NodeSourceType.Core
}
function badgeTextVisible(node: LGraphNode, badgeMode: NodeBadgeMode): boolean {
return (
badgeMode === NodeBadgeMode.None ||
(isCoreNode(node) && badgeMode === NodeBadgeMode.HideBuiltIn)
)
}
function getNodeIdBadgeText(node: LGraphNode, nodeIdBadgeMode: NodeBadgeMode) {
return badgeTextVisible(node, nodeIdBadgeMode) ? '' : `#${node.id}`
}
function getNodeSourceBadgeText(
node: LGraphNode,
nodeSourceBadgeMode: NodeBadgeMode
) {
const nodeSource = getNodeSource(node)
return badgeTextVisible(node, nodeSourceBadgeMode)
? ''
: nodeSource?.badgeText ?? ''
}
function getNodeLifeCycleBadgeText(
node: LGraphNode,
nodeLifeCycleBadgeMode: NodeBadgeMode
) {
let text = ''
const nodeDef = node.constructor.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) {
const settingStore = useSettingStore()
this.nodeSourceBadgeMode = computed(
() =>
settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
)
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'))
)
watch(this.nodeSourceBadgeMode, () => {
app.graph.setDirtyCanvas(true, true)
})
watch(this.nodeIdBadgeMode, () => {
app.graph.setDirtyCanvas(true, true)
})
watch(this.nodeLifeCycleBadgeMode, () => {
app.graph.setDirtyCanvas(true, true)
})
}
nodeCreated(node: LGraphNode, app: ComfyApp) {
node.badgePosition = BadgePosition.TopRight
// @ts-expect-error Disable ComfyUI-Manager's badge drawing by setting badge_enabled to true. Remove this when ComfyUI-Manager's badge drawing is removed.
node.badge_enabled = true
const badge = computed(
() =>
new LGraphBadge({
text: _.truncate(
[
getNodeIdBadgeText(node, this.nodeIdBadgeMode.value),
getNodeLifeCycleBadgeText(
node,
this.nodeLifeCycleBadgeMode.value
),
getNodeSourceBadgeText(node, this.nodeSourceBadgeMode.value)
]
.filter((s) => s.length > 0)
.join(' '),
{
length: 31
}
),
fgColor:
this.colorPalette.value.colors.litegraph_base?.BADGE_FG_COLOR ||
defaultColorPalette.colors.litegraph_base.BADGE_FG_COLOR,
bgColor:
this.colorPalette.value.colors.litegraph_base?.BADGE_BG_COLOR ||
defaultColorPalette.colors.litegraph_base.BADGE_BG_COLOR
})
)
node.badges.push(() => badge.value)
}
}
app.registerExtension(new NodeBadgeExtension())

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { ComfyWidgets } from '../../scripts/widgets'
import { LiteGraph } from '@comfyorg/litegraph'
@@ -30,7 +31,7 @@ app.registerExtension({
slot_types_default_in: {},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
var nodeId = nodeData.name
const inputs = nodeData['input']?.['required'] //only show required inputs to reduce the mess also not logical to create node with optional inputs
const inputs = nodeData['input']['required'] //only show required inputs to reduce the mess also not logical to create node with optional inputs
for (const inputKey in inputs) {
var input = inputs[inputKey]
if (typeof input[0] !== 'string') continue
@@ -58,9 +59,9 @@ app.registerExtension({
)
}
var outputs = nodeData['output'] ?? []
for (const el of outputs) {
const type = el as string
var outputs = nodeData['output']
for (const key in outputs) {
var type = outputs[key] as string
if (!(type in this.slot_types_default_in)) {
this.slot_types_default_in[type] = ['Reroute'] // ["Reroute", "Primitive"]; primitive doesn't always work :'()
}
@@ -77,11 +78,10 @@ app.registerExtension({
LiteGraph.slot_types_out.push(type)
}
}
var maxNum = this.suggestionsNumber.value
this.setDefaults(maxNum)
},
setDefaults(maxNum?: number | null) {
setDefaults(maxNum) {
LiteGraph.slot_types_default_out = {}
LiteGraph.slot_types_default_in = {}

View File

@@ -100,7 +100,8 @@ app.registerExtension({
} else {
w = node.size[0]
h = node.size[1]
const titleMode = node.constructor.title_mode
// @ts-expect-error
let titleMode = node.constructor.title_mode
if (
titleMode !== LiteGraph.TRANSPARENT_TITLE &&
titleMode !== LiteGraph.NO_TITLE

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { ComfyNodeDef } from '@/types/apiTypes'
@@ -5,7 +6,7 @@ import { ComfyNodeDef } from '@/types/apiTypes'
app.registerExtension({
name: 'Comfy.UploadImage',
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef) {
async beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef, app) {
if (nodeData?.input?.required?.image?.[1]?.image_upload === true) {
nodeData.input.required.upload = ['IMAGEUPLOAD']
}

View File

@@ -1,14 +0,0 @@
import { useI18n } from 'vue-i18n'
import { markRaw } from 'vue'
import IntegratedTerminal from '@/components/bottomPanel/tabs/IntegratedTerminal.vue'
import { BottomPanelExtension } from '@/types/extensionTypes'
export const useIntegratedTerminalTab = (): BottomPanelExtension => {
const { t } = useI18n()
return {
id: 'integrated-terminal',
title: t('terminal'),
component: markRaw(IntegratedTerminal),
type: 'vue'
}
}

View File

@@ -1,44 +0,0 @@
import { onMounted, ref } from 'vue'
export function useDownload(url: string, fileName?: string) {
const fileSize = ref<number | null>(null)
const fetchFileSize = async (): Promise<number | null> => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) throw new Error('Failed to fetch file size')
const size = response.headers.get('content-length')
if (size) {
return parseInt(size)
} else {
console.error('"content-length" header not found')
return null
}
} catch (e) {
console.error('Error fetching file size:', e)
return null
}
}
/**
* Trigger browser download
*/
const triggerBrowserDownload = () => {
const link = document.createElement('a')
link.href = url
link.download = fileName || url.split('/').pop() || 'download'
link.target = '_blank' // Opens in new tab if download attribute is not supported
link.rel = 'noopener noreferrer' // Security best practice for _blank links
link.click()
}
onMounted(async () => {
fileSize.value = await fetchFileSize()
})
return {
triggerBrowserDownload,
fileSize
}
}

View File

@@ -2,10 +2,6 @@ import { createI18n } from 'vue-i18n'
const messages = {
en: {
download: 'Download',
loadAllFolders: 'Load All Folders',
refresh: 'Refresh',
terminal: 'Terminal',
videoFailedToLoad: 'Video failed to load',
extensionName: 'Extension Name',
reloadToApplyChanges: 'Reload to apply changes',
@@ -95,8 +91,7 @@ const messages = {
refresh: 'Refresh node definitions',
clipspace: 'Open Clipspace',
resetView: 'Reset canvas view',
clear: 'Clear workflow',
toggleBottomPanel: 'Toggle Bottom Panel'
clear: 'Clear workflow'
},
templateWorkflows: {
title: 'Get Started with a Template',
@@ -117,10 +112,6 @@ const messages = {
}
},
zh: {
download: '下载',
loadAllFolders: '加载所有文件夹',
refresh: '刷新',
terminal: '终端',
videoFailedToLoad: '视频加载失败',
extensionName: '扩展名称',
reloadToApplyChanges: '重新加载以应用更改',
@@ -209,8 +200,7 @@ const messages = {
refresh: '刷新节点',
clipspace: '打开剪贴板',
resetView: '重置画布视图',
clear: '清空工作流',
toggleBottomPanel: '底部面板'
clear: '清空工作流'
},
templateWorkflows: {
title: '从模板开始',
@@ -231,10 +221,6 @@ const messages = {
}
},
ru: {
download: 'Скачать',
refresh: 'Обновить',
loadAllFolders: 'Загрузить все папки',
terminal: 'Терминал',
videoFailedToLoad: 'Видео не удалось загрузить',
extensionName: 'Название расширения',
reloadToApplyChanges: 'Перезагрузите, чтобы применить изменения',

View File

@@ -1,17 +1,19 @@
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
// @ts-strict-ignore
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import {
type HistoryTaskItem,
type PendingTaskItem,
type RunningTaskItem,
type ComfyNodeDef,
type EmbeddingsResponse,
type ExtensionsResponse,
type PromptResponse,
type SystemStats,
type User,
type Settings,
type UserDataFullInfo,
validateComfyNodeDef
DownloadModelStatus,
HistoryTaskItem,
PendingTaskItem,
RunningTaskItem,
ComfyNodeDef,
validateComfyNodeDef,
EmbeddingsResponse,
ExtensionsResponse,
PromptResponse,
SystemStats,
User,
Settings,
UserDataFullInfo
} from '@/types/apiTypes'
import axios from 'axios'
@@ -33,23 +35,15 @@ class ComfyApi extends EventTarget {
#registered = new Set()
api_host: string
api_base: string
/**
* The client id from the initial session storage.
*/
initialClientId: string | null
/**
* The current client id from websocket status updates.
*/
clientId?: string
initialClientId: string
user: string
socket: WebSocket | null = null
socket?: WebSocket
clientId?: string
reportedUnknownMessageTypes = new Set<string>()
constructor() {
super()
// api.user is set by ComfyApp.setup()
this.user = ''
this.api_host = location.host
this.api_base = location.pathname.split('/').slice(0, -1).join('/')
console.log('Running on', this.api_host)
@@ -78,14 +72,7 @@ class ComfyApi extends EventTarget {
if (!options.cache) {
options.cache = 'no-cache'
}
if (Array.isArray(options.headers)) {
options.headers.push(['Comfy-User', this.user])
} else if (options.headers instanceof Headers) {
options.headers.set('Comfy-User', this.user)
} else {
options.headers['Comfy-User'] = this.user
}
options.headers['Comfy-User'] = this.user
return fetch(this.apiURL(route), options)
}
@@ -193,10 +180,9 @@ class ComfyApi extends EventTarget {
switch (msg.type) {
case 'status':
if (msg.data.sid) {
const clientId = msg.data.sid
this.clientId = clientId
window.name = clientId // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem('clientId', clientId) // store in session storage so duplicate tab can load correct workflow
this.clientId = msg.data.sid
window.name = this.clientId // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem('clientId', this.clientId) // store in session storage so duplicate tab can load correct workflow
}
this.dispatchEvent(
new CustomEvent('status', { detail: msg.data.status })
@@ -239,6 +225,11 @@ class ComfyApi extends EventTarget {
new CustomEvent('execution_cached', { detail: msg.data })
)
break
case 'download_progress':
this.dispatchEvent(
new CustomEvent('download_progress', { detail: msg.data })
)
break
default:
if (this.#registered.has(msg.type)) {
this.dispatchEvent(
@@ -317,13 +308,10 @@ class ComfyApi extends EventTarget {
*/
async queuePrompt(
number: number,
{
output,
workflow
}: { output: Record<number, any>; workflow: ComfyWorkflowJSON }
{ output, workflow }
): Promise<PromptResponse> {
const body: QueuePromptRequestBody = {
client_id: this.clientId ?? '', // TODO: Unify clientId access
client_id: this.clientId,
prompt: output,
extra_data: { extra_pnginfo: { workflow } }
}
@@ -358,12 +346,9 @@ class ComfyApi extends EventTarget {
async getModelFolders(): Promise<string[]> {
const res = await this.fetchApi(`/models`)
if (res.status === 404) {
return []
return null
}
const folderBlacklist = ['configs', 'custom_nodes']
return (await res.json()).filter(
(folder: string) => !folderBlacklist.includes(folder)
)
return await res.json()
}
/**
@@ -371,10 +356,10 @@ class ComfyApi extends EventTarget {
* @param {string} folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async getModels(folder: string): Promise<string[]> {
async getModels(folder: string) {
const res = await this.fetchApi(`/models/${folder}`)
if (res.status === 404) {
return []
return null
}
return await res.json()
}
@@ -407,6 +392,36 @@ class ComfyApi extends EventTarget {
}
}
/**
* Tells the server to download a model from the specified URL to the specified directory and filename
* @param {string} url The URL to download the model from
* @param {string} model_directory The main directory (eg 'checkpoints') to save the model to
* @param {string} model_filename The filename to save the model as
* @param {number} progress_interval The interval in seconds at which to report download progress (via 'download_progress' event)
*/
async internalDownloadModel(
url: string,
model_directory: string,
model_filename: string,
progress_interval: number,
folder_path: string
): Promise<DownloadModelStatus> {
const res = await this.fetchApi('/internal/models/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
model_directory,
model_filename,
progress_interval,
folder_path
})
})
return await res.json()
}
/**
* Loads a list of items (queue or history)
* @param {string} type The type of items to load, queue or history
@@ -432,12 +447,12 @@ class ComfyApi extends EventTarget {
const data = await res.json()
return {
// Running action uses a different endpoint for cancelling
Running: data.queue_running.map((prompt: Record<number, any>) => ({
Running: data.queue_running.map((prompt) => ({
taskType: 'Running',
prompt,
remove: { name: 'Cancel', cb: () => api.interrupt() }
})),
Pending: data.queue_pending.map((prompt: Record<number, any>) => ({
Pending: data.queue_pending.map((prompt) => ({
taskType: 'Pending',
prompt
}))
@@ -677,15 +692,12 @@ class ComfyApi extends EventTarget {
recurse: boolean,
split?: false
): Promise<string[]>
/**
* @deprecated Use `listUserDataFullInfo` instead.
*/
async listUserData(dir: string, recurse: boolean, split?: boolean) {
async listUserData(dir, recurse, split) {
const resp = await this.fetchApi(
`/userdata?${new URLSearchParams({
recurse: recurse ? 'true' : 'false',
recurse,
dir,
split: split ? 'true' : 'false'
split
})}`
)
if (resp.status === 404) return []

View File

@@ -15,7 +15,7 @@ import { addDomClippingSetting } from './domWidget'
import { createImageHost, calculateImageGrid } from './ui/imagePreview'
import { DraggableList } from './ui/draggableList'
import { applyTextReplacements, addStylesheet } from './utils'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ComfyExtension } from '@/types/comfy'
import {
type ComfyWorkflowJSON,
type NodeId,
@@ -50,7 +50,7 @@ import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useModelStore } from '@/stores/modelStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { useExecutionStore } from '@/stores/executionStore'
import { IWidget } from '@comfyorg/litegraph'
import { useExtensionStore } from '@/stores/extensionStore'
@@ -75,15 +75,6 @@ function sanitizeNodeName(string) {
})
}
type Clipspace = {
widgets?: { type?: string; name?: string; value?: any }[] | null
imgs?: HTMLImageElement[] | null
original_imgs?: HTMLImageElement[] | null
images?: any[] | null
selectedIndex: number
img_paste_mode: string
}
/**
* @typedef {import("types/comfy").ComfyExtension} ComfyExtension
*/
@@ -104,8 +95,8 @@ export class ComfyApp {
* Content Clipboard
* @type {serialized node object}
*/
static clipspace: Clipspace | null = null
static clipspace_invalidate_handler: (() => void) | null = null
static clipspace = null
static clipspace_invalidate_handler = null
static open_maskeditor = null
static clipspace_return_node = null
@@ -148,8 +139,6 @@ export class ComfyApp {
canvasContainer: HTMLElement
menu: ComfyAppMenu
bypassBgColor: string
// Set by Comfy.Clipspace extension
openClipspace: () => void = () => {}
/**
* @deprecated Use useExecutionStore().executingNodeId instead
@@ -1139,7 +1128,7 @@ export class ComfyApp {
// No image found. Look for node data
data = data.getData('text/plain')
let workflow: ComfyWorkflowJSON | null = null
let workflow: ComfyWorkflowJSON
try {
data = data.slice(data.indexOf('{'))
workflow = JSON.parse(data)
@@ -1149,7 +1138,7 @@ export class ComfyApp {
data = data.slice(data.indexOf('{'))
workflow = JSON.parse(data)
} catch (error) {
workflow = null
console.error(error)
}
}
@@ -1401,7 +1390,8 @@ export class ComfyApp {
size,
fgcolor,
bgcolor,
selected
selected,
mouse_over
) {
const res = origDrawNodeShape.apply(this, arguments)
@@ -1426,6 +1416,7 @@ export class ComfyApp {
if (color) {
const shape =
// @ts-expect-error
node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE
ctx.lineWidth = lineWidth
ctx.globalAlpha = 0.8
@@ -1847,10 +1838,19 @@ export class ComfyApp {
this.#addAfterConfigureHandler()
// Make LGraphCanvas.state shallow reactive so that any change on the root
// object triggers reactivity.
this.canvas = new LGraphCanvas(canvasEl, this.graph)
this.canvas.state = shallowReactive(this.canvas.state)
// Make LGraphCanvas shallow reactive so that any change on the root object
// triggers reactivity.
this.canvas = shallowReactive(
new LGraphCanvas(canvasEl, this.graph, {
skip_events: true,
skip_render: true
})
)
// Bind event/ start rendering later, so that event handlers get reactive canvas reference.
this.canvas.options.skip_events = false
this.canvas.options.skip_render = false
this.canvas.bindEvents()
this.canvas.startRendering()
this.ctx = canvasEl.getContext('2d')
@@ -1900,7 +1900,8 @@ export class ComfyApp {
// Save current workflow automatically
setInterval(() => {
const workflow = JSON.stringify(this.serializeGraph())
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
const workflow = JSON.stringify(this.graph.serialize({ sortNodes }))
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
@@ -1960,7 +1961,7 @@ export class ComfyApp {
const nodeDefArray: ComfyNodeDef[] = Object.values(allNodeDefs)
this.#invokeExtensions('beforeRegisterVueAppNodeDefs', nodeDefArray, this)
nodeDefStore.updateNodeDefs(nodeDefArray)
nodeDefStore.widgets = this.widgets
nodeDefStore.updateWidgets(this.widgets)
}
/**
@@ -2179,9 +2180,12 @@ export class ComfyApp {
localStorage.setItem('litegrapheditor_clipboard', old)
}
#showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
#showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
showLoadWorkflowWarning({ missingNodeTypes })
showLoadWorkflowWarning({
missingNodeTypes,
hasAddedNodes
})
}
this.logging.addEntry('Comfy.App', 'warn', {
@@ -2222,7 +2226,9 @@ export class ComfyApp {
clean: boolean = true,
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null,
{ showMissingNodesDialog = true, showMissingModelsDialog = true } = {}
{ showMissingNodesDialog = true, showMissingModelsDialog = true } = {},
droppedImageBlobUrl: string | undefined = undefined,
output_node_id = undefined
) {
if (clean !== false) {
this.clean()
@@ -2259,7 +2265,7 @@ export class ComfyApp {
graphData = validatedGraphData ?? graphData
}
const missingNodeTypes: MissingNodeType[] = []
const missingNodeTypes = []
const missingModels = []
await this.#invokeExtensionsAsync(
'beforeConfigureGraph',
@@ -2284,8 +2290,8 @@ export class ComfyApp {
graphData.models &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
for (const m of graphData.models) {
const models_available = await useModelStore().getLoadedModelFolder(
for (let m of graphData.models) {
const models_available = await useModelStore().getModelsInFolderCached(
m.directory
)
if (models_available === null) {
@@ -2299,7 +2305,6 @@ export class ComfyApp {
}
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (
restore_view &&
@@ -2414,6 +2419,12 @@ export class ComfyApp {
}
}
// Show dropped image on its original output node
if (droppedImageBlobUrl && String(node.id) === output_node_id) {
// @ts-expect-error
this.nodePreviewImages[output_node_id] = [droppedImageBlobUrl]
}
this.#invokeExtensions('loadedGraphNode', node)
}
@@ -2432,18 +2443,7 @@ export class ComfyApp {
}
/**
* Serializes a graph using preferred user settings.
* @param graph The litegraph to serialize.
* @returns A serialized graph (aka workflow) with preferred user settings.
*/
serializeGraph(graph: LGraph = this.graph) {
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
return graph.serialize({ sortNodes })
}
/**
* Converts the current graph workflow for sending to the API.
* Note: Node widgets are updated before serialization to prepare queueing.
* Converts the current graph workflow for sending to the API
* @returns The workflow and node links
*/
async graphToPrompt(graph = this.graph, clean = true) {
@@ -2469,7 +2469,9 @@ export class ComfyApp {
}
}
const workflow = this.serializeGraph(graph)
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
const workflow = graph.serialize({ sortNodes })
const output = {}
// Process nodes in order of execution
for (const outerNode of graph.computeExecutionOrder(false)) {
@@ -2637,7 +2639,6 @@ export class ComfyApp {
const p = await this.graphToPrompt()
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
const res = await api.queuePrompt(number, p)
this.lastNodeErrors = res.node_errors
if (this.lastNodeErrors.length > 0) {
@@ -2715,7 +2716,10 @@ export class ComfyApp {
JSON.parse(pngInfo.workflow),
true,
true,
fileName
fileName,
undefined,
URL.createObjectURL(file),
pngInfo.output_node_id
)
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
@@ -2733,7 +2737,15 @@ export class ComfyApp {
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
this.loadGraphData(
JSON.parse(workflow),
true,
true,
fileName,
undefined,
URL.createObjectURL(file),
pngInfo.output_node_id
)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
@@ -2813,7 +2825,8 @@ export class ComfyApp {
if (missingNodeTypes.length) {
this.#showMissingNodesError(
// @ts-expect-error
missingNodeTypes.map((t) => t.class_type)
missingNodeTypes.map((t) => t.class_type),
false
)
return
}
@@ -2922,6 +2935,7 @@ export class ComfyApp {
}
if (this.vueAppReady) {
useToastStore().add(requestToastMessage)
useModelStore().clearCache()
}
const defs = await api.getNodeDefs({

View File

@@ -1,33 +1,32 @@
// @ts-strict-ignore
import type { ComfyApp } from './app'
import { api } from './api'
import { clone } from './utils'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { ComfyWorkflow } from './workflows'
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import { LGraphNode } from '@comfyorg/litegraph'
import { ExecutedWsMessage } from '@/types/apiTypes'
import { useExecutionStore } from '@/stores/executionStore'
export class ChangeTracker {
static MAX_HISTORY = 50
#app?: ComfyApp
undoQueue: ComfyWorkflowJSON[] = []
redoQueue: ComfyWorkflowJSON[] = []
activeState: ComfyWorkflowJSON | null = null
isOurLoad: boolean = false
changeCount: number = 0
#app: ComfyApp
undoQueue = []
redoQueue = []
activeState = null
isOurLoad = false
workflow: ComfyWorkflow | null
changeCount = 0
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
ds: { scale: number; offset: [number, number] }
nodeOutputs: any
get app(): ComfyApp {
// Global tracker has #app set, while other trackers have workflow bounded
get app() {
return this.#app ?? this.workflow.manager.app
}
constructor(public workflow: ComfyWorkflow) {}
constructor(workflow: ComfyWorkflow) {
this.workflow = workflow
}
#setApp(app: ComfyApp) {
#setApp(app) {
this.#app = app
}
@@ -70,10 +69,10 @@ export class ChangeTracker {
}
}
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
async updateState(source, target) {
const prevState = source.pop()
if (prevState) {
target.push(this.activeState!)
target.push(this.activeState)
this.isOurLoad = true
await this.app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModelsDialog: false,
@@ -91,7 +90,7 @@ export class ChangeTracker {
await this.updateState(this.redoQueue, this.undoQueue)
}
async undoRedo(e: KeyboardEvent) {
async undoRedo(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'y' || e.key == 'Z') {
await this.redo()
@@ -119,8 +118,8 @@ export class ChangeTracker {
globalTracker.#setApp(app)
const loadGraphData = app.loadGraphData
app.loadGraphData = async function (...args) {
const v = await loadGraphData.apply(this, args)
app.loadGraphData = async function () {
const v = await loadGraphData.apply(this, arguments)
const ct = changeTracker()
if (ct.isOurLoad) {
ct.isOurLoad = false
@@ -140,12 +139,12 @@ export class ChangeTracker {
const activeEl = document.activeElement
requestAnimationFrame(async () => {
let bindInputEl: Element | null = null
let bindInputEl
// If we are auto queue in change mode then we do want to trigger on inputs
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === 'instant') {
if (
activeEl?.tagName === 'INPUT' ||
(activeEl && 'type' in activeEl && activeEl.type === 'textarea')
activeEl?.['type'] === 'textarea'
) {
// Ignore events on inputs, they have their native history
return
@@ -195,26 +194,21 @@ export class ChangeTracker {
// Handle litegraph clicks
const processMouseUp = LGraphCanvas.prototype.processMouseUp
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, [e])
const v = processMouseUp.apply(this, arguments)
changeTracker().checkState()
return v
}
const processMouseDown = LGraphCanvas.prototype.processMouseDown
LGraphCanvas.prototype.processMouseDown = function (e) {
const v = processMouseDown.apply(this, [e])
const v = processMouseDown.apply(this, arguments)
changeTracker().checkState()
return v
}
// Handle litegraph dialog popup for number/string widgets
const prompt = LGraphCanvas.prototype.prompt
LGraphCanvas.prototype.prompt = function (
title: string,
value: any,
callback: (v: any) => void,
event: any
) {
const extendedCallback = (v: any) => {
LGraphCanvas.prototype.prompt = function (title, value, callback, event) {
const extendedCallback = (v) => {
callback(v)
changeTracker().checkState()
}
@@ -223,16 +217,16 @@ export class ChangeTracker {
// Handle litegraph context menu for COMBO widgets
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
const v = close.apply(this, [e])
LiteGraph.ContextMenu.prototype.close = function (e) {
const v = close.apply(this, arguments)
changeTracker().checkState()
return v
}
// Detects nodes being added via the node search dialog
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded
LiteGraph.LGraph.prototype.onNodeAdded = function (node: LGraphNode) {
const v = onNodeAdded?.apply(this, [node])
LiteGraph.LGraph.prototype.onNodeAdded = function () {
const v = onNodeAdded?.apply(this, arguments)
if (!app?.configuringGraph) {
const ct = changeTracker()
if (!ct.isOurLoad) {
@@ -243,24 +237,20 @@ export class ChangeTracker {
}
// Handle multiple commands as a single transaction
document.addEventListener('litegraph:canvas', (e: Event) => {
const detail = (e as CustomEvent).detail
if (detail.subType === 'before-change') {
document.addEventListener('litegraph:canvas', (e: CustomEvent) => {
if (e.detail.subType === 'before-change') {
changeTracker().beforeChange()
} else if (detail.subType === 'after-change') {
} else if (e.detail.subType === 'after-change') {
changeTracker().afterChange()
}
})
// Store node outputs
api.addEventListener('executed', (e: CustomEvent<ExecutedWsMessage>) => {
const detail = e.detail
const workflow =
useExecutionStore().queuedPrompts[detail.prompt_id]?.workflow
const changeTracker = workflow?.changeTracker
if (!changeTracker) return
changeTracker.nodeOutputs ??= {}
const nodeOutputs = changeTracker.nodeOutputs
api.addEventListener('executed', ({ detail }) => {
const prompt =
app.workflowManager.executionStore.queuedPrompts[detail.prompt_id]
if (!prompt?.workflow) return
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {})
const output = nodeOutputs[detail.node]
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
@@ -277,30 +267,26 @@ export class ChangeTracker {
})
}
static bindInput(app: ComfyApp, activeEl: Element | null): boolean {
static bindInput(app, activeEl) {
if (
!activeEl ||
activeEl.tagName === 'CANVAS' ||
activeEl.tagName === 'BODY'
activeEl &&
activeEl.tagName !== 'CANVAS' &&
activeEl.tagName !== 'BODY'
) {
return false
}
for (const evt of ['change', 'input', 'blur']) {
const htmlElement = activeEl as HTMLElement
if (`on${evt}` in htmlElement) {
const listener = () => {
app.workflowManager.activeWorkflow?.changeTracker?.checkState()
htmlElement.removeEventListener(evt, listener)
for (const evt of ['change', 'input', 'blur']) {
if (`on${evt}` in activeEl) {
const listener = () => {
app.workflowManager.activeWorkflow.changeTracker.checkState()
activeEl.removeEventListener(evt, listener)
}
activeEl.addEventListener(evt, listener)
return true
}
htmlElement.addEventListener(evt, listener)
return true
}
}
return false
}
static graphEqual(a: any, b: any, path = '') {
static graphEqual(a, b, path = '') {
if (a === b) return true
if (typeof a == 'object' && a && typeof b == 'object' && b) {

View File

@@ -8,7 +8,6 @@ import { TaskItem } from '@/types/apiTypes'
import { showSettingsDialog } from '@/services/dialogService'
import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
export const ComfyDialog = _ComfyDialog
@@ -351,6 +350,7 @@ export class ComfyUI {
autoQueueMode: string
graphHasChanged: boolean
autoQueueEnabled: boolean
menuHamburger: HTMLDivElement
menuContainer: HTMLDivElement
queueSize: Element
restoreMenuPosition: () => void
@@ -421,6 +421,18 @@ export class ComfyUI {
}
})
this.menuHamburger = $el(
'div.comfy-menu-hamburger',
{
parent: containerElement,
onclick: () => {
this.menuContainer.style.display = 'block'
this.menuHamburger.style.display = 'none'
}
},
[$el('div'), $el('div'), $el('div')]
) as HTMLDivElement
this.menuContainer = $el('div.comfy-menu', { parent: containerElement }, [
$el(
'div.drag-handle.comfy-menu-header',
@@ -443,7 +455,8 @@ export class ComfyUI {
$el('button.comfy-close-menu-btn', {
textContent: '\u00d7',
onclick: () => {
useWorkspaceStore().focusMode = true
this.menuContainer.style.display = 'none'
this.menuHamburger.style.display = 'flex'
}
})
])
@@ -598,6 +611,7 @@ export class ComfyUI {
$el('button', {
id: 'comfy-clipspace-button',
textContent: 'Clipspace',
// @ts-expect-error Move to ComfyApp
onclick: () => app.openClipspace()
}),
$el('button', {

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