Compare commits

..

77 Commits

Author SHA1 Message Date
Chenlei Hu
e128f39760 1.2.4 (#246) 2024-07-27 22:36:22 -04:00
Chenlei Hu
f22713aeb0 1.2.3 (#244) 2024-07-27 22:32:06 -04:00
Chenlei Hu
74fd1c9abe Reduce history queue max length (#243) 2024-07-27 22:23:46 -04:00
Chenlei Hu
f4f0c960a3 Fix combo input default value (#242)
* Fix combo input default value

* Supress logs and fix failure
2024-07-27 22:17:42 -04:00
Chenlei Hu
c0875d066a Node library side bar tab (#237)
* Basic tree

* Add node filter

* Fix key issue

* Add icons

* Node count

* nit

* Add node preview basics

* Node preview

* Make comfy node in node lib draggable

* Set drop target

* Add node on drop

* Drop on dynamic location

* nit

* nit

* Fix hover preview issue

* nit

* More visual diff between node and folder

* Add playwright test

* Add dep

* Get rid of screenshot test
2024-07-27 21:28:48 -04:00
Chenlei Hu
980ed0083d Fix settings getter (#240) 2024-07-27 18:22:22 -04:00
Chenlei Hu
76be537351 Add pp.addNodeOnGraph to simplify adding node on canvas (#239) 2024-07-27 17:44:17 -04:00
Alexander Brown
424a5f7a86 Expose Queue Button on the menu. (#238) 2024-07-27 17:44:06 -04:00
Chenlei Hu
542e1c1f59 Move sidebar y-axis overflow style to container (#236) 2024-07-26 16:25:12 -04:00
Chenlei Hu
972ffe73e3 Use ComfyNodeDefImpl on nodeDefStore (#235) 2024-07-26 15:53:22 -04:00
Chenlei Hu
b4d7735855 Use ComfyNodeDefImpl on nodeSearchService (#233)
* Make nodeSearchService use ComfyNodeDefImpl

* Fix test
2024-07-26 13:41:24 -04:00
Chenlei Hu
9bcc08d7ab Allow duplicated output names (#232) 2024-07-26 11:57:55 -04:00
Chenlei Hu
a1750212e5 Make node def output class (#231)
* Refactor node def output

* Adjust test
2024-07-26 11:09:43 -04:00
Chenlei Hu
4dba1d3ab0 Fix splitter overlay z-index (#230) 2024-07-26 10:47:04 -04:00
Chenlei Hu
ee6eed1c1c Fix extension register tab with API (#229)
* Get rid of extension manager impl

* nit

* Test register tab
2024-07-26 10:29:20 -04:00
Chenlei Hu
8e1d3f3baa Use ComfyNodeDefImpl on NodePreview component (#228)
* store widgets

* Use node def impl
2024-07-25 23:15:03 -04:00
Chenlei Hu
dc13ed102b Reverse init order (#227) 2024-07-25 22:20:38 -04:00
Chenlei Hu
9d56bb4e0e Set default empty record for InputSpec (#225) 2024-07-25 21:29:22 -04:00
Chenlei Hu
c97ff6fd85 Add name field to BaseInputSpec (#226) 2024-07-25 21:07:16 -04:00
Chenlei Hu
0ec15ba101 Transform ComfyNodeDef to ComfyNodeDefImpl (#224) 2024-07-25 20:27:16 -04:00
Chenlei Hu
55d5ec8c25 (update) CSS formatting (#221)
Co-authored-by: dmx <vincent.f@intp.com>
2024-07-25 16:32:57 -04:00
Chenlei Hu
c6d2767af1 Transforms ComfyInputsSpec on nodes (#220)
* Convert input spec defs

* Fix test

* Add combo test

* import metadata
2024-07-25 13:50:55 -04:00
Chenlei Hu
e179f75387 Apply new code format standard (#217) 2024-07-25 10:10:18 -04:00
Chenlei Hu
19c70d95d3 Sidebar tab API for extensions (#215)
* Add extensionManager to manage tabs

* Fix null bug

* nit
2024-07-24 21:31:59 -04:00
Chenlei Hu
ebdd7b8e40 Use store to manage nodeSearchService (#214) 2024-07-24 12:00:23 -04:00
Chenlei Hu
b73fe80761 [Major Refactor] Use pinia store to manage setting & nodeDef (#202)
* Node def store and settings tore

* Fix initial values

* Remove legacy setting listen

* Fix searchbox test
2024-07-24 11:49:09 -04:00
Chenlei Hu
84d8c5fc16 Fix ws dev server URL (#213) 2024-07-24 11:01:22 -04:00
Chenlei Hu
5b4e96f6c5 Test theme toggle feature (#212)
* WIP

* Add test on theme toggle

* Add teardown logic

* Cleanup menu setting
2024-07-23 17:56:35 -04:00
Chenlei Hu
1b7db43f8a Format everything (#211) 2024-07-23 15:40:54 -04:00
Chenlei Hu
648e52e39c 1.2.2 (#209) 2024-07-23 14:21:37 -04:00
Chenlei Hu
89b195dc13 Only install chromium in github action (#210) 2024-07-23 14:21:26 -04:00
Chenlei Hu
69d95f6e46 Update litegraph (Fix auto connect slot) (#208)
* Update litegraph

* Update version again

* Add browser test for litegraph change

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-23 14:05:02 -04:00
Chenlei Hu
609d3fe279 Add i18n for side tool bar tooltips (#207)
* Add npm dep

* Add i18n for side bar tooltips
2024-07-23 10:43:10 -04:00
Chenlei Hu
9b36c6b254 Add side bar icon tooltip (#206) 2024-07-23 09:45:47 -04:00
Chenlei Hu
d87058babf 1.2.1 (#204) 2024-07-22 23:00:13 -04:00
Chenlei Hu
a71f7671ae Fix default setting issue for first time install (#203)
* Fix default setting issue for first time install

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-22 22:54:59 -04:00
Chenlei Hu
bd68617c82 Fix theme toggle (#200)
* Use builtin event on color change

* Fix theme toggle

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-22 16:46:02 -04:00
Chenlei Hu
189662cd7c 1.2.0 (#197) 2024-07-22 10:28:48 -04:00
Chenlei Hu
fc327fe071 Fix typo (#195) 2024-07-22 10:16:54 -04:00
Chenlei Hu
65740a30c5 v1.2.0 Side Bar & Menu rework (#189)
* Basic side tool bar skeleton + Theme toggle (#164)

* Side bar skeleton

* Fix grid layout

* nit

* Add theme toggle logic

* Change primevue color theme to blue to match beta menu UI

* Add litegraph canvas splitter overlay (#177)

* Add vue wrapper

* Splitter overlay

* Move teleport to side bar comp

* Toolbar placeholder

* Move settings button from top menu to side bar (#178)

* Reverse relationship between splitter overlay and sidebar component (#180)

* Reverse relationship between splitter overlay and sidebar component

* nit

* Remove border on splitter

* Fix canvas shift (#186)

* Move queue/history display to side bar (#185)

* Side bar placeholder

* Pinia store for queue items

* Flatten task item

* Fix schema

* computed

* Switch running / pending order

* Use class-transformer

* nit

* Show display status

* Add tag severity style

* Add execution time

* nit

* Rename to execution success

* Add time display

* Sort queue desc order

* nit

* Add remove item feature

* Load workflow

* Add confirmation popup

* Add empty table placeholder

* Remove beta menu UI's queue button/list

* Add tests on litegraph widget text truncate (#191)

* Add tests on litegraph widget text truncate

* Updated screenshots

* Revert port change

* Remove screenshots

* Update test expectations [skip ci]

* Add back menu.settingsGroup for compatibility (#192)

* Close side bar on menu location set as disabled (#194)

* Remove placeholder side bar tabs (#196)

---------

Co-authored-by: bymyself <abolkonsky.rem@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2024-07-22 10:15:41 -04:00
Chenlei Hu
1521cd47c8 Move searchbox position up to 25% to top of screen (#187) 2024-07-21 10:35:04 -04:00
pythongosssss
f18740d5e4 Sync PR (#182)
#133
2024-07-21 10:52:58 +01:00
Chenlei Hu
3fbffc1eb6 1.1.9 (#173) 2024-07-19 19:10:21 -04:00
Chenlei Hu
cb9042f9f9 Sync #4061 (#172) 2024-07-19 19:08:42 -04:00
Chenlei Hu
a99a833c38 Fix searchbox immediate dismiss issue (#171) 2024-07-19 19:00:52 -04:00
Chenlei Hu
a2afdd74b2 Mount vue app after comfy app init (#167)
* Mount vue app after comfy app

* Emit event when vue app loaded

* Dispatch event to window

* Fix test timeout

* Try observe variable

* Revert

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-19 18:47:53 -04:00
Chenlei Hu
396e0c9525 Sync #4044 (#168) 2024-07-19 15:47:53 -04:00
Chenlei Hu
050fd4eb32 Sync #4043 (#169) 2024-07-19 15:47:43 -04:00
Chenlei Hu
631a060fff Enable CI for dev* branches (#165) 2024-07-19 12:23:48 -04:00
Chenlei Hu
d0030e1185 1.1.8 (#163) 2024-07-18 17:47:24 -04:00
Chenlei Hu
71ac0dcccc Allow dynamic widgets values (#162) 2024-07-18 17:45:42 -04:00
Chenlei Hu
ab7436f87c Update litegraph to 0.7.26 (#161)
* Update litegraph

* Test node order

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-18 17:34:35 -04:00
Chenlei Hu
9961be1bc7 Validate node def from /object_info endpoint (#159)
* Validate node def

* nit

* nit

* More tests
2024-07-18 12:20:47 -04:00
Chenlei Hu
2568746071 Add node search service test (#158) 2024-07-18 10:13:05 -04:00
Chenlei Hu
dea9af8650 Display frontend version in settings dialog (#157)
* Display frontend version in settings dialog

* Change execution order
2024-07-18 10:09:15 -04:00
Chenlei Hu
c2e7ef11ec 1.1.7 (#153) 2024-07-17 22:35:31 -04:00
Chenlei Hu
54246d37b0 Relands "Fix node searchbox default value setting" (#152)
* Revert "Revert "Fix node searchbox default value setting (#150)" (#151)"

This reverts commit bb02f935ff.

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-17 22:34:03 -04:00
Chenlei Hu
bb02f935ff Revert "Fix node searchbox default value setting (#150)" (#151)
This reverts commit 3dfef8a73e.
2024-07-17 22:30:36 -04:00
Chenlei Hu
3dfef8a73e Fix node searchbox default value setting (#150) 2024-07-17 22:23:49 -04:00
Chenlei Hu
24cdb6ad2d Convert legacy format node.widget_values (#149) 2024-07-17 22:05:16 -04:00
Chenlei Hu
7619e9159b Convert pos object to array on parsing (#147) 2024-07-17 20:57:14 -04:00
Chenlei Hu
05d5896c82 1.1.6 (#146) 2024-07-17 17:31:05 -04:00
Chenlei Hu
1706476dca 1.1.5 (#145) 2024-07-17 17:28:53 -04:00
Chenlei Hu
31f4ee332a Fix bypass display issue (#144)
* Fix bypass display issue

* nit

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-07-17 17:27:24 -04:00
Chenlei Hu
6b2acc146d Fix zod parsing for pos in workflow (#142)
* Fix zod parsing for pos in workflow

* Add test

* Fix test
2024-07-17 16:02:49 -04:00
Chenlei Hu
edb1349bb5 1.1.4 (#139) 2024-07-17 00:22:21 -04:00
Chenlei Hu
3fbaeb1fb8 Fix custom node def validation issue (#137)
* Fix nodeDef.output undefined

* fix2

* nit
2024-07-16 22:40:25 -04:00
Chenlei Hu
2ab3c2bca1 Update README on how to use (#135) 2024-07-16 14:36:08 -04:00
Chenlei Hu
13cda7de41 Annotate settings.ts (#134)
* Annotate settings.ts

* nit
2024-07-16 11:21:23 -04:00
bymyself
e216fa82c5 Fix search preview's widget text wrap (#132) 2024-07-14 19:46:25 -04:00
Chenlei Hu
900c4d9ca5 1.1.3 (#130) 2024-07-13 16:16:03 -04:00
Chenlei Hu
d49c68e7bf Auto release on release PR merge (#129)
* wip

* Add release workflow

* nit
2024-07-13 16:13:34 -04:00
Chenlei Hu
93a0c1012f Add system node to serach box db (#128) 2024-07-13 09:25:58 -04:00
Chenlei Hu
5fac7c9365 Fix vue warning on key (#127) 2024-07-13 09:04:16 -04:00
Chenlei Hu
f1acdf976a Shift node color's brightness for light mode (#123)
* Shift node color's brightness for light mode

* nit

* Fix test
2024-07-13 09:01:35 -04:00
Chenlei Hu
15900cd523 Fix filter dialog color in light mode (#126) 2024-07-13 08:58:48 -04:00
Chenlei Hu
55431d1e4f Avoid conflict with N-SideBar (#122)
* Avoid conflict with N-SideBar

* nit
2024-07-12 17:32:51 -04:00
167 changed files with 15065 additions and 12073 deletions

View File

@@ -7,8 +7,8 @@ PLAYWRIGHT_TEST_URL=http://localhost:5173
DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# The target ComfyUI checkout directory to deploy the frontend code to.
# The dist directory will be copied to {DEPLOY_COMFY_UI_DIR}/custom_web_versions/main/dev
# Add `--front-end-root {DEPLOY_COMFY_UI_DIR}/custom_web_versions/main/dev`
# The dist directory will be copied to {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev
# Add `--front-end-root {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev`
# to ComfyUI launch script to serve the custom web version.
DEPLOY_COMFYUI_DIR=/home/ComfyUI/web

43
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Create Release Draft
on:
pull_request:
types: [closed]
branches:
- main
- master
paths:
- "package.json"
jobs:
draft_release:
runs-on: ubuntu-latest
if: >
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'Release')
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: lts/*
- name: Get current version
id: current_version
run: echo ::set-output name=version::$(node -p "require('./package.json').version")
- name: Build project
run: |
npm ci
npm run build
npm run zipdist
- name: Create release
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
files: |
dist.zip
tag_name: v${{ steps.current_version.outputs.version }}
draft: true
prerelease: false
make_latest: "true"

View File

@@ -50,7 +50,7 @@ jobs:
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Run Playwright tests and update snapshots
id: playwright-tests

View File

@@ -2,9 +2,15 @@ name: Tests CI
on:
push:
branches: [ main, master ]
branches:
- main
- master
- 'dev*'
pull_request:
branches: [ main, master ]
branches:
- main
- master
- 'dev*'
jobs:
test:
@@ -68,7 +74,7 @@ jobs:
npm test -- --verbose
working-directory: ComfyUI_frontend
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Run Playwright tests
run: npx playwright test

View File

@@ -1,4 +1,6 @@
{
"semi": true,
"trailingComma": "es5"
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none"
}

View File

@@ -2,6 +2,18 @@
Front-end of [ComfyUI](https://github.com/comfyanonymous/ComfyUI) modernized. This repo is fully compatible with the existing extension system.
## How To Use
Add command line argument `--front-end-version Comfy-Org/ComfyUI_frontend@latest` to your
ComfyUI launch script.
For Windows stand-alone build users, please edit the `run_cpu.bat` / `run_nvidia_gpu.bat` file as following
```bat
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --front-end-version Comfy-Org/ComfyUI_frontend@latest
pause
```
## Road Map
### What has been done
@@ -16,10 +28,12 @@ Front-end of [ComfyUI](https://github.com/comfyanonymous/ComfyUI) modernized. Th
- Starting with node search box revamp ![image](https://github.com/user-attachments/assets/ef6ce019-5194-4e55-9f1e-91440e473920)
- Easy install and version management (<https://github.com/comfyanonymous/ComfyUI/pull/3897>).
### What to be done
- Replace the existing ComfyUI front-end impl (<https://github.com/comfyanonymous/ComfyUI/pull/3897>).
- Replace the existing ComfyUI front-end impl
- Remove `@ts-ignore`s.
- Turn on `strict` on `tsconfig.json`.
- Introduce a UI library to add more widget types for node developers.

View File

@@ -3,13 +3,6 @@
"@babel/preset-env"
],
"plugins": [
"babel-plugin-transform-import-meta",
[
"transform-rename-import",
{
"original": "^(.+?)\\.js$",
"replacement": "$1"
}
]
"babel-plugin-transform-import-meta"
]
}

View File

@@ -1,248 +1,448 @@
import type { Page, Locator } from "@playwright/test";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
dotenv.config();
import type { Page, Locator } from '@playwright/test'
import { test as base } from '@playwright/test'
import dotenv from 'dotenv'
dotenv.config()
interface Position {
x: number;
y: number;
x: number
y: number
}
interface Size {
width: number
height: number
}
class ComfyNodeSearchBox {
public readonly input: Locator;
public readonly dropdown: Locator;
public readonly input: Locator
public readonly dropdown: Locator
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"
);
'.comfy-vue-node-search-container .p-autocomplete-list'
)
}
async fillAndSelectFirstNode(nodeName: string) {
await this.input.waitFor({ state: "visible" });
await this.input.fill(nodeName);
await this.dropdown.waitFor({ state: "visible" });
await this.dropdown.locator("li").nth(0).click();
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(0).click()
}
}
class NodeLibrarySideBarTab {
public readonly tabId: string = 'node-library'
constructor(public readonly page: Page) {}
get tabButton() {
return this.page.locator(`.${this.tabId}-tab-button`)
}
get selectedTabButton() {
return this.page.locator(
`.${this.tabId}-tab-button.side-bar-button-selected`
)
}
get nodeLibraryTree() {
return this.page.locator('.node-lib-tree')
}
get nodePreview() {
return this.page.locator('.node-lib-node-preview')
}
async open() {
if (await this.selectedTabButton.isVisible()) {
return
}
await this.tabButton.click()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
async toggleFirstFolder() {
await this.page.locator('.p-tree-node-toggle-button').nth(0).click()
}
}
class ComfyMenu {
public readonly sideToolBar: Locator
public readonly themeToggleButton: Locator
constructor(public readonly page: Page) {
this.sideToolBar = page.locator('.side-tool-bar-container')
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
}
get nodeLibraryTab() {
return new NodeLibrarySideBarTab(this.page)
}
async toggleTheme() {
await this.themeToggleButton.click()
await this.page.evaluate(() => {
return new Promise((resolve) => {
window['app'].ui.settings.addEventListener(
'Comfy.ColorPalette.change',
resolve,
{ once: true }
)
setTimeout(resolve, 5000)
})
})
}
async getThemeId() {
return await this.page.evaluate(async () => {
return await window['app'].ui.settings.getSettingValue(
'Comfy.ColorPalette'
)
})
}
}
export class ComfyPage {
public readonly url: string;
public readonly url: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator;
public readonly widgetTextBox: Locator;
public readonly canvas: Locator
public readonly widgetTextBox: Locator
// Buttons
public readonly resetViewButton: Locator;
public readonly resetViewButton: Locator
// Search box
public readonly searchBox: ComfyNodeSearchBox;
// Inputs
public readonly workflowUploadInput: Locator
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
constructor(public readonly page: Page) {
this.url = process.env.PLAYWRIGHT_TEST_URL || "http://localhost:8188";
this.canvas = page.locator("#graph-canvas");
this.widgetTextBox = page.getByPlaceholder("text").nth(1);
this.resetViewButton = page.getByRole("button", { name: "Reset View" });
this.searchBox = new ComfyNodeSearchBox(page);
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.canvas = page.locator('#graph-canvas')
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
}
async getGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return window['app']?.graph?._nodes?.length || 0
})
}
async setup() {
await this.goto()
// Unify font for consistent screenshots.
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
})
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
})
await this.page.addStyleTag({
content: `
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}`
})
await this.page.waitForFunction(() => document.fonts.ready)
await this.page.waitForFunction(
() => window['app'] !== undefined && window['app'].vueAppReady
)
await this.page.evaluate(() => {
window['app']['canvas'].show_info = false
})
await this.nextFrame()
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await this.resetView()
}
async realod() {
await this.page.reload({ timeout: 15000 })
await this.setup()
}
async goto() {
await this.page.goto(this.url);
await this.page.goto(this.url)
}
async nextFrame() {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame);
});
return new Promise<number>(requestAnimationFrame)
})
}
async loadWorkflow(workflowName: string) {
await this.workflowUploadInput.setInputFiles(
`./browser_tests/assets/${workflowName}.json`
)
await this.nextFrame()
}
async resetView() {
await this.resetViewButton.click();
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
// Avoid "Reset View" button highlight.
await this.page.mouse.move(10, 10);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async clickTextEncodeNode1() {
await this.canvas.click({
position: {
x: 618,
y: 191,
},
});
await this.nextFrame();
y: 191
}
})
await this.nextFrame()
}
async clickTextEncodeNode2() {
await this.canvas.click({
position: {
x: 622,
y: 400,
},
});
await this.nextFrame();
y: 400
}
})
await this.nextFrame()
}
async clickEmptySpace() {
await this.canvas.click({
position: {
x: 35,
y: 31,
},
});
await this.nextFrame();
y: 31
}
})
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position) {
await this.page.mouse.move(source.x, source.y);
await this.page.mouse.down();
await this.page.mouse.move(target.x, target.y);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y)
await this.page.mouse.up()
await this.nextFrame()
}
async dragNode2() {
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 });
await this.nextFrame();
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
await this.nextFrame()
}
async disconnectEdge() {
// CLIP input anchor
await this.page.mouse.move(427, 198);
await this.page.mouse.down();
await this.page.mouse.move(427, 98);
await this.page.mouse.up();
await this.page.mouse.move(427, 198)
await this.page.mouse.down()
await this.page.mouse.move(427, 98)
await this.page.mouse.up()
// Move out the way to avoid highlight of menu item.
await this.page.mouse.move(10, 10);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async connectEdge() {
// CLIP output anchor on Load Checkpoint Node.
await this.page.mouse.move(332, 509);
await this.page.mouse.down();
await this.page.mouse.move(332, 509)
await this.page.mouse.down()
// CLIP input anchor on CLIP Text Encode Node.
await this.page.mouse.move(427, 198);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(427, 198)
await this.page.mouse.up()
await this.nextFrame()
}
async adjustWidgetValue() {
// Adjust Empty Latent Image's width input.
const page = this.page;
await page.locator("#graph-canvas").click({
const page = this.page
await page.locator('#graph-canvas').click({
position: {
x: 724,
y: 645,
},
});
await page.locator('input[type="text"]').click();
await page.locator('input[type="text"]').fill("128");
await page.locator('input[type="text"]').press("Enter");
await this.nextFrame();
y: 645
}
})
await page.locator('input[type="text"]').click()
await page.locator('input[type="text"]').fill('128')
await page.locator('input[type="text"]').press('Enter')
await this.nextFrame()
}
async zoom(deltaY: number) {
await this.page.mouse.move(10, 10);
await this.page.mouse.wheel(0, deltaY);
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.page.mouse.wheel(0, deltaY)
await this.nextFrame()
}
async pan(offset: Position) {
await this.page.mouse.move(10, 10);
await this.page.mouse.down();
await this.page.mouse.move(offset.x, offset.y);
await this.page.mouse.up();
await this.nextFrame();
await this.page.mouse.move(10, 10)
await this.page.mouse.down()
await this.page.mouse.move(offset.x, offset.y)
await this.page.mouse.up()
await this.nextFrame()
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: "right" });
await this.nextFrame();
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
}
async doubleClickCanvas() {
await this.page.mouse.dblclick(10, 10);
await this.nextFrame();
await this.page.mouse.dblclick(10, 10)
await this.nextFrame()
}
async clickEmptyLatentNode() {
await this.canvas.click({
position: {
x: 724,
y: 625,
},
});
this.page.mouse.move(10, 10);
await this.nextFrame();
y: 625
}
})
this.page.mouse.move(10, 10)
await this.nextFrame()
}
async rightClickEmptyLatentNode() {
await this.canvas.click({
position: {
x: 724,
y: 645,
y: 645
},
button: "right",
});
this.page.mouse.move(10, 10);
await this.nextFrame();
button: 'right'
})
this.page.mouse.move(10, 10)
await this.nextFrame()
}
async select2Nodes() {
// Select 2 CLIP nodes.
await this.page.keyboard.down("Control");
await this.clickTextEncodeNode1();
await this.clickTextEncodeNode2();
await this.page.keyboard.up("Control");
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.clickTextEncodeNode1()
await this.clickTextEncodeNode2()
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlC() {
await this.page.keyboard.down("Control");
await this.page.keyboard.press("KeyC");
await this.page.keyboard.up("Control");
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyC')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlV() {
await this.page.keyboard.down("Control");
await this.page.keyboard.press("KeyV");
await this.page.keyboard.up("Control");
await this.nextFrame();
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyV')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async closeMenu() {
await this.page.click('button.comfy-close-menu-btn')
await this.nextFrame()
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
ratioX: number,
ratioY: number,
revertAfter: boolean = false
) {
const bottomRight = {
x: nodePos.x + nodeSize.width,
y: nodePos.y + nodeSize.height
}
const target = {
x: nodePos.x + nodeSize.width * ratioX,
y: nodePos.y + nodeSize.height * ratioY
}
await this.dragAndDrop(bottomRight, target)
await this.nextFrame()
if (revertAfter) {
await this.dragAndDrop(target, bottomRight)
await this.nextFrame()
}
}
async resizeKsamplerNode(
percentX: number,
percentY: number,
revertAfter: boolean = false
) {
const ksamplerPos = {
x: 864,
y: 157
}
const ksamplerSize = {
width: 315,
height: 292
}
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter)
}
async resizeLoadCheckpointNode(
percentX: number,
percentY: number,
revertAfter: boolean = false
) {
const loadCheckpointPos = {
x: 25,
y: 440
}
const loadCheckpointSize = {
width: 320,
height: 120
}
this.resizeNode(
loadCheckpointPos,
loadCheckpointSize,
percentX,
percentY,
revertAfter
)
}
async resizeEmptyLatentNode(
percentX: number,
percentY: number,
revertAfter: boolean = false
) {
const emptyLatentPos = {
x: 475,
y: 580
}
const emptyLatentSize = {
width: 303,
height: 132
}
this.resizeNode(
emptyLatentPos,
emptyLatentSize,
percentX,
percentY,
revertAfter
)
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ page }, use) => {
const comfyPage = new ComfyPage(page);
await comfyPage.goto();
// Unify font for consistent screenshots.
await page.addStyleTag({
url: "https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap",
});
await page.addStyleTag({
url: "https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap",
});
await page.addStyleTag({
content: `
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}`,
});
await page.waitForFunction(() => document.fonts.ready);
await page.waitForFunction(() => window["app"] != undefined);
await page.evaluate(() => {
window["app"]["canvas"].show_info = false;
});
await comfyPage.nextFrame();
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await comfyPage.resetView();
await use(comfyPage);
},
});
const comfyPage = new ComfyPage(page)
await comfyPage.setup()
await use(comfyPage)
}
})

View File

@@ -0,0 +1,164 @@
{
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [
590,
40
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null,
"slot_index": 0
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 3,
"slot_index": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null,
"slot_index": 2
},
{
"name": "latent_image",
"type": "LATENT",
"link": null,
"slot_index": 3
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [
20,
50
],
"size": {
"0": 400,
"1": 200
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
3
],
"shape": 3
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 5,
"type": "CLIPTextEncode",
"pos": [
20,
320
],
"size": {
"0": 400,
"1": 200
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
}
],
"links": [
[
3,
4,
0,
1,
1,
"CONDITIONING"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -1,56 +1,56 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe("Copy Paste", () => {
test("Can copy and paste node", async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode();
await comfyPage.page.mouse.move(10, 10);
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await expect(comfyPage.canvas).toHaveScreenshot("copied-node.png");
});
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot('copied-node.png')
})
test("Can copy and paste text", async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
const originalString = await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await comfyPage.ctrlV();
const resultString = await textBox.inputValue();
expect(resultString).toBe(originalString + originalString);
});
test('Can copy and paste text', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox
await textBox.click()
const originalString = await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.ctrlV()
const resultString = await textBox.inputValue()
expect(resultString).toBe(originalString + originalString)
})
/**
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/98
*/
test("Paste in text area with node previously copied", async ({
comfyPage,
test('Paste in text area with node previously copied', async ({
comfyPage
}) => {
await comfyPage.clickEmptyLatentNode();
await comfyPage.ctrlC();
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
await comfyPage.ctrlV();
await comfyPage.ctrlV();
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot(
"paste-in-text-area-with-node-previously-copied.png"
);
});
'paste-in-text-area-with-node-previously-copied.png'
)
})
test("Copy text area does not copy node", async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.inputValue();
await textBox.selectText();
await comfyPage.ctrlC();
test('Copy text area does not copy node', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
// Unfocus textbox.
await comfyPage.page.mouse.click(10, 10);
await comfyPage.ctrlV();
await expect(comfyPage.canvas).toHaveScreenshot("no-node-copied.png");
});
});
await comfyPage.page.mouse.click(10, 10)
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1,54 +1,75 @@
import { expect } from '@playwright/test';
import { comfyPageFixture as test } from './ComfyPage';
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from './ComfyPage'
test.describe('Node Interaction', () => {
test('Can enter prompt', async ({ comfyPage }) => {
const textBox = comfyPage.widgetTextBox;
await textBox.click();
await textBox.fill('Hello World');
await expect(textBox).toHaveValue('Hello World');
await textBox.fill('Hello World 2');
await expect(textBox).toHaveValue('Hello World 2');
});
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('Hello World')
await expect(textBox).toHaveValue('Hello World')
await textBox.fill('Hello World 2')
await expect(textBox).toHaveValue('Hello World 2')
})
test('Can highlight selected', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png');
await comfyPage.clickTextEncodeNode1();
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png');
await comfyPage.clickTextEncodeNode2();
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png');
});
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
// Flaky. See https://github.com/comfyanonymous/ComfyUI/issues/3866
test.skip('Can drag node', async ({ comfyPage }) => {
await comfyPage.dragNode2();
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png');
});
await comfyPage.dragNode2()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.disconnectEdge();
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge-with-menu.png');
await comfyPage.connectEdge();
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot(
'disconnected-edge-with-menu.png'
)
await comfyPage.connectEdge()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', { maxDiffPixels: 50 });
});
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
})
})
test('Can adjust widget value', async ({ comfyPage }) => {
await comfyPage.adjustWidgetValue();
await expect(comfyPage.canvas).toHaveScreenshot('adjusted-widget-value.png');
});
});
await comfyPage.adjustWidgetValue()
await expect(comfyPage.canvas).toHaveScreenshot('adjusted-widget-value.png')
})
test('Link snap to slot', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('snap_to_slot')
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot.png')
const outputSlotPos = {
x: 406,
y: 333
}
const samplerNodeCenterPos = {
x: 748,
y: 77
}
await comfyPage.dragAndDrop(outputSlotPos, samplerNodeCenterPos)
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png')
})
})
test.describe('Canvas Interaction', () => {
test('Can zoom in/out', async ({ comfyPage }) => {
await comfyPage.zoom(-100);
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png');
await comfyPage.zoom(200);
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png');
});
await comfyPage.zoom(-100)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png')
await comfyPage.zoom(200)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png')
})
test('Can pan', async ({ comfyPage }) => {
await comfyPage.pan({ x: 200, y: 200 });
await expect(comfyPage.canvas).toHaveScreenshot('panned.png');
});
});
await comfyPage.pan({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,34 +1,36 @@
import { expect } from "@playwright/test";
import { comfyPageFixture as test } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
function listenForEvent(): Promise<Event> {
return new Promise<Event>((resolve) => {
document.addEventListener("litegraph:canvas", (e) => resolve(e), { once: true });
});
document.addEventListener('litegraph:canvas', (e) => resolve(e), {
once: true
})
})
}
test.describe("Canvas Event", () => {
test("Emit litegraph:canvas empty-release", async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent);
const disconnectPromise = comfyPage.disconnectEdge();
const event = await eventPromise;
await disconnectPromise;
test.describe('Canvas Event', () => {
test('Emit litegraph:canvas empty-release', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const disconnectPromise = comfyPage.disconnectEdge()
const event = await eventPromise
await disconnectPromise
expect(event).not.toBeNull();
expect(event).not.toBeNull()
// No further check on event content as the content is dropped by
// playwright for some reason.
// See https://github.com/microsoft/playwright/issues/31580
});
})
test("Emit litegraph:canvas empty-double-click", async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent);
const doubleClickPromise = comfyPage.doubleClickCanvas();
const event = await eventPromise;
await doubleClickPromise;
test('Emit litegraph:canvas empty-double-click', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const doubleClickPromise = comfyPage.doubleClickCanvas()
const event = await eventPromise
await doubleClickPromise
expect(event).not.toBeNull();
expect(event).not.toBeNull()
// No further check on event content as the content is dropped by
// playwright for some reason.
// See https://github.com/microsoft/playwright/issues/31580
});
});
})
})

View File

@@ -0,0 +1,95 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(async () => {
await window['app'].ui.settings.setSettingValueAsync(
'Comfy.UseNewMenu',
'Top'
)
})
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.page.evaluate(async () => {
await window['app'].ui.settings.setSettingValueAsync(
'Comfy.UseNewMenu',
'Disabled'
)
})
})
test('Toggle theme', async ({ comfyPage }) => {
test.setTimeout(30000)
expect(await comfyPage.menu.getThemeId()).toBe('dark')
await comfyPage.menu.toggleTheme()
expect(await comfyPage.menu.getThemeId()).toBe('light')
// Theme id should persist after reload.
await comfyPage.page.reload()
await comfyPage.setup()
expect(await comfyPage.menu.getThemeId()).toBe('light')
await comfyPage.menu.toggleTheme()
expect(await comfyPage.menu.getThemeId()).toBe('dark')
})
test('Can register sidebar tab', async ({ comfyPage }) => {
const initialChildrenCount = await comfyPage.menu.sideToolBar.evaluate(
(el) => el.children.length
)
await comfyPage.page.evaluate(async () => {
window['app'].extensionManager.registerSidebarTab({
id: 'search',
icon: 'pi pi-search',
title: 'search',
tooltip: 'search',
type: 'custom',
render: (el) => {
el.innerHTML = '<div>Custom search tab</div>'
}
})
})
await comfyPage.nextFrame()
const newChildrenCount = await comfyPage.menu.sideToolBar.evaluate(
(el) => el.children.length
)
expect(newChildrenCount).toBe(initialChildrenCount + 1)
})
test('Sidebar node preview and drag to canvas', async ({ comfyPage }) => {
// Open the sidebar
const tab = comfyPage.menu.nodeLibraryTab
await tab.open()
await tab.toggleFirstFolder()
// Hover over a node to display the preview
const nodeSelector = '.p-tree-node-leaf'
await comfyPage.page.hover(nodeSelector)
// Verify the preview is displayed
const previewVisible = await comfyPage.page.isVisible(
'.node-lib-node-preview'
)
expect(previewVisible).toBe(true)
const count = await comfyPage.getGraphNodesCount()
// Drag the node onto the canvas
const canvasSelector = '#graph-canvas'
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector)
// Verify the node is added to the canvas
expect(await comfyPage.getGraphNodesCount()).toBe(count + 1)
})
})

View File

@@ -1,47 +1,35 @@
import { expect } from "@playwright/test";
import { ComfyPage, comfyPageFixture } from "./ComfyPage";
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
export const test = comfyPageFixture.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ comfyPage }, use) => {
await comfyPage.page.evaluate(async () => {
await window["app"].ui.settings.setSettingValueAsync(
"Comfy.NodeSearchBoxImpl",
"default"
);
});
await use(comfyPage);
},
});
test.describe('Node search box', () => {
test('Can trigger on empty canvas double click', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test.describe("Node search box", () => {
test("Can trigger on empty canvas double click", async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas();
await expect(comfyPage.searchBox.input).toHaveCount(1);
});
test('Can trigger on link release', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('Shift')
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test("Can trigger on link release", async ({ comfyPage }) => {
await comfyPage.page.keyboard.down("Shift");
await comfyPage.disconnectEdge();
await expect(comfyPage.searchBox.input).toHaveCount(1);
});
test('Does not trigger on link release (no shift)', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(0)
})
test("Does not trigger on link release (no shift)", async ({ comfyPage }) => {
await comfyPage.disconnectEdge();
await expect(comfyPage.searchBox.input).toHaveCount(0);
});
test('Can add node', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await expect(comfyPage.canvas).toHaveScreenshot('added-node.png')
})
test("Can add node", async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas();
await expect(comfyPage.searchBox.input).toHaveCount(1);
await comfyPage.searchBox.fillAndSelectFirstNode("KSampler");
await expect(comfyPage.canvas).toHaveScreenshot("added-node.png");
});
test("Can auto link node", async ({ comfyPage }) => {
await comfyPage.page.keyboard.down("Shift");
await comfyPage.disconnectEdge();
await comfyPage.page.keyboard.up("Shift");
await comfyPage.searchBox.fillAndSelectFirstNode("CLIPTextEncode");
await expect(comfyPage.canvas).toHaveScreenshot("auto-linked-node.png");
});
});
test('Can auto link node', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('Shift')
await comfyPage.disconnectEdge()
await comfyPage.page.keyboard.up('Shift')
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode')
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -1,80 +1,88 @@
import { expect } from '@playwright/test';
import { comfyPageFixture as test } from './ComfyPage';
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Canvas Right Click Menu', () => {
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png');
await comfyPage.page.getByText('Add Node').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu.png');
await comfyPage.page.getByText('loaders').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu-loaders.png');
await comfyPage.page.getByText('Load VAE').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png');
});
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu.png')
await comfyPage.page.getByText('loaders').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-menu-loaders.png')
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
})
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip('Can add group', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png');
await comfyPage.page.getByText('Add Group', { exact: true }).click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png');
});
// See https://github.com/comfyanonymous/ComfyUI/issues/3883
// Right-click menu on canvas's option sequence is not stable.
test.skip('Can add group', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes();
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png');
comfyPage.page.on('dialog', async dialog => {
await dialog.accept("GroupNode2CLIP");
});
await comfyPage.rightClickCanvas();
await comfyPage.page.getByText('Convert to Group Node').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-group-node.png');
});
});
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
comfyPage.page.on('dialog', async (dialog) => {
await dialog.accept('GroupNode2CLIP')
})
await comfyPage.rightClickCanvas()
await comfyPage.page.getByText('Convert to Group Node').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-group-node.png'
)
})
})
test.describe('Node Right Click Menu', () => {
test('Can open properties panel', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png');
await comfyPage.page.getByText('Properties Panel').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-properties-panel.png');
});
test('Can open properties panel', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-properties-panel.png'
)
})
test('Can collapse', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png');
await comfyPage.page.getByText('Collapse').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-collapsed.png');
});
test('Can collapse', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Collapse').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-collapsed.png'
)
})
// See https://github.com/Comfy-Org/ComfyUI_frontend/pull/57
// Bypass produces different output on Windows VS Linux.
test.skip('Can bypass', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png');
await comfyPage.page.getByText('Bypass').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-bypassed.png');
});
test('Can bypass', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Bypass').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-bypassed.png'
)
})
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png');
await comfyPage.page.getByText('Convert Widget to Input').click();
await comfyPage.nextFrame();
await comfyPage.page.getByText('Convert width to input').click();
await comfyPage.nextFrame();
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node-widget-converted.png');
});
});
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert Widget to Input').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('Convert width to input').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-widget-converted.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Combo text widget', () => {
test('Truncates text when resized', async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
await expect(comfyPage.canvas).toHaveScreenshot(
'load-checkpoint-resized-min-width.png'
)
await comfyPage.closeMenu()
await comfyPage.resizeKsamplerNode(0.2, 1)
await expect(comfyPage.canvas).toHaveScreenshot(
`ksampler-resized-min-width.png`
)
})
test("Doesn't truncate when space still available", async ({ comfyPage }) => {
await comfyPage.resizeEmptyLatentNode(0.8, 0.8)
await expect(comfyPage.canvas).toHaveScreenshot(
'empty-latent-resized-80-percent.png'
)
})
test('Can revert to full text', async ({ comfyPage }) => {
await comfyPage.resizeLoadCheckpointNode(0.8, 1, true)
await expect(comfyPage.canvas).toHaveScreenshot('resized-to-original.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -12,11 +12,13 @@
font-family: 'Roboto Mono', 'Noto Color Emoji';
}
</style> -->
<script type="module" src="/src/main.ts"></script>
<script src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module">
import 'reflect-metadata';
window["__COMFYUI_FRONTEND_VERSION__"] = __COMFYUI_FRONTEND_VERSION__;
console.log("ComfyUI Front-end version:", __COMFYUI_FRONTEND_VERSION__);
</script>
<script type="module" src="/src/main.ts"></script>
<link rel="stylesheet" type="text/css" href="/user.css" />
<link rel="stylesheet" type="text/css" href="/materialdesignicons.min.css" />
</head>

View File

@@ -1,23 +1,26 @@
import type { JestConfigWithTsJest } from "ts-jest";
import type { JestConfigWithTsJest } from 'ts-jest'
const jestConfig: JestConfigWithTsJest = {
testMatch: ["**/tests-ui/**/*.test.ts"],
testEnvironment: "jsdom",
transform: {
'^.+\\.m?[tj]sx?$': ["ts-jest", {
tsconfig: "./tsconfig.json",
babelConfig: "./babel.config.json",
}],
},
setupFiles: ["./tests-ui/globalSetup.ts"],
setupFilesAfterEnv: ["./tests-ui/afterSetup.ts"],
clearMocks: true,
resetModules: true,
testTimeout: 10000,
moduleNameMapper: {
"^src/(.*)$": "<rootDir>/src/$1",
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
};
testMatch: ['**/tests-ui/**/*.test.ts'],
testEnvironment: 'jsdom',
transform: {
'^.+\\.m?[tj]sx?$': [
'ts-jest',
{
tsconfig: './tsconfig.json',
babelConfig: './babel.config.json'
}
]
},
setupFiles: ['./tests-ui/globalSetup.ts'],
setupFilesAfterEnv: ['./tests-ui/afterSetup.ts'],
clearMocks: true,
resetModules: true,
testTimeout: 10000,
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
}
}
export default jestConfig;
export default jestConfig

169
package-lock.json generated
View File

@@ -1,22 +1,27 @@
{
"name": "comfyui-frontend",
"version": "1.0.1",
"version": "1.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.0.1",
"version": "1.2.4",
"dependencies": {
"@comfyorg/litegraph": "^0.7.25",
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.29",
"@primevue/themes": "^4.0.0-rc.2",
"@vitejs/plugin-vue": "^5.0.5",
"class-transformer": "^0.5.1",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.0.0-rc.2",
"reflect-metadata": "^0.2.2",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
@@ -74,6 +79,17 @@
"node": ">=6.0.0"
}
},
"node_modules/@atlaskit/pragmatic-drag-and-drop": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.2.1.tgz",
"integrity": "sha512-gW2wJblFAeg94YXITHg0YdhFM2nmFAdDmX0LKYBIm79yEbIrOiuHHukgSjII07M4U5JpJ0Ff/4BaADjN23ix+A==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.0.0",
"bind-event-listener": "^3.0.0",
"raf-schd": "^4.0.3"
}
},
"node_modules/@babel/code-frame": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
@@ -1751,7 +1767,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz",
"integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==",
"dev": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -1815,9 +1830,9 @@
"dev": true
},
"node_modules/@comfyorg/litegraph": {
"version": "0.7.25",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.25.tgz",
"integrity": "sha512-/Zk9lT0Cq17IFrlntZ8fKmiH6CpMXaEmAdWHdWtaiCDybNFAqCpr7w0JDjax0dR4pjYqHSAPtHUSjLvD+5q8Kw=="
"version": "0.7.29",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.29.tgz",
"integrity": "sha512-lXgqcJseywRJQ/B9ClW+5u6VIbDJWy8SMJJ1nxXDgTsE30UUmOnBhZkLZZ3ffMv3QFUcYoNLq5EJn3EFx3g+zA=="
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
@@ -2186,6 +2201,47 @@
"node": ">=12"
}
},
"node_modules/@intlify/core-base": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.13.1.tgz",
"integrity": "sha512-+bcQRkJO9pcX8d0gel9ZNfrzU22sZFSA0WVhfXrf5jdJOS24a+Bp8pozuS9sBI9Hk/tGz83pgKfmqcn/Ci7/8w==",
"dependencies": {
"@intlify/message-compiler": "9.13.1",
"@intlify/shared": "9.13.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.13.1.tgz",
"integrity": "sha512-SKsVa4ajYGBVm7sHMXd5qX70O2XXjm55zdZB3VeMFCvQyvLew/dLvq3MqnaIsTMF1VkkOb9Ttr6tHcMlyPDL9w==",
"dependencies": {
"@intlify/shared": "9.13.1",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.13.1.tgz",
"integrity": "sha512-u3b6BKGhE6j/JeRU6C/RL2FgyJfy6LakbtfeVF8fJXURpZZTzfh3e05J0bu0XPw447Q6/WUp3C4ajv4TMS4YsQ==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -3535,6 +3591,11 @@
"@vue/shared": "3.4.31"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.3.tgz",
"integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw=="
},
"node_modules/@vue/reactivity": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.31.tgz",
@@ -4005,6 +4066,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bind-event-listener": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz",
"integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -4199,6 +4266,11 @@
"integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==",
"dev": true
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
},
"node_modules/cli-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
@@ -8133,6 +8205,56 @@
"node": ">=0.10.0"
}
},
"node_modules/pinia": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz",
"integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==",
"dependencies": {
"@vue/devtools-api": "^6.5.0",
"vue-demi": ">=0.14.5"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@vue/composition-api": "^1.4.0",
"typescript": ">=4.4.4",
"vue": "^2.6.14 || ^3.3.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/pirates": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
@@ -8468,6 +8590,12 @@
}
]
},
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
"license": "MIT"
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -8510,6 +8638,11 @@
"node": ">=8.10.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -8531,8 +8664,7 @@
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"dev": true
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regenerator-transform": {
"version": "0.15.2",
@@ -9705,6 +9837,25 @@
}
}
},
"node_modules/vue-i18n": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.13.1.tgz",
"integrity": "sha512-mh0GIxx0wPtPlcB1q4k277y0iKgo25xmDPWioVVYanjPufDBpvu5ySTjP5wOrSvlYQ2m1xI+CFhGdauv/61uQg==",
"dependencies": {
"@intlify/core-base": "9.13.1",
"@intlify/shared": "9.13.1",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",

View File

@@ -1,15 +1,15 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.1.2",
"version": "1.2.4",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"deploy": "node scripts/deploy.js",
"deploy": "npm run build && node scripts/deploy.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "tsc --noEmit",
"format": "prettier --write 'src/**/*.{js,ts,tsx,vue}'",
"format": "prettier --write './**/*.{js,ts,tsx,vue}'",
"test": "npm run build && jest",
"test:generate:examples": "npx tsx tests-ui/extractExamples",
"test:generate": "npx tsx tests-ui/setup",
@@ -46,20 +46,25 @@
"zip-dir": "^2.0.0"
},
"dependencies": {
"@comfyorg/litegraph": "^0.7.25",
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.29",
"@primevue/themes": "^4.0.0-rc.2",
"@vitejs/plugin-vue": "^5.0.5",
"class-transformer": "^0.5.1",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.0.0-rc.2",
"reflect-metadata": "^0.2.2",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"lint-staged": {
"src/**/*.{js,ts,tsx,vue}": [
"./**/*.{js,ts,tsx,vue}": [
"prettier --write",
"git add"
]

View File

@@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
@@ -28,7 +28,7 @@ export default defineConfig({
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: 'on-first-retry'
},
/* Configure projects for major browsers */
@@ -36,8 +36,8 @@ export default defineConfig({
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 3000,
},
timeout: 5000
}
// {
// name: 'firefox',
@@ -68,7 +68,7 @@ export default defineConfig({
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
]
/* Run your local dev server before starting the tests */
// webServer: {
@@ -76,4 +76,4 @@ export default defineConfig({
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});
})

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
autoprefixer: {}
}
}

View File

@@ -1,14 +1,14 @@
import { copy } from 'fs-extra';
import { config } from "dotenv";
config();
import { copy } from 'fs-extra'
import { config } from 'dotenv'
config()
const sourceDir = './dist';
const targetDir = process.env.DEPLOY_COMFYUI_DIR;
const sourceDir = './dist'
const targetDir = process.env.DEPLOY_COMFYUI_DIR
copy(sourceDir, targetDir)
.then(() => {
console.log(`Directory copied successfully! ${sourceDir} -> ${targetDir}`);
console.log(`Directory copied successfully! ${sourceDir} -> ${targetDir}`)
})
.catch((err) => {
console.error('Error copying directory:', err);
});
console.error('Error copying directory:', err)
})

View File

@@ -1,9 +1,9 @@
import zipdir from 'zip-dir';
import zipdir from 'zip-dir'
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
if (err) {
console.error('Error zipping "dist" directory:', err);
console.error('Error zipping "dist" directory:', err)
} else {
console.log('Successfully zipped "dist" directory.');
console.log('Successfully zipped "dist" directory.')
}
});
})

View File

@@ -2,70 +2,107 @@
<ProgressSpinner v-if="isLoading" class="spinner"></ProgressSpinner>
<div v-else>
<NodeSearchboxPopover v-if="nodeSearchEnabled" />
<teleport to=".graph-canvas-container">
<LiteGraphCanvasSplitterOverlay v-if="betaMenuEnabled">
<template #side-bar-panel>
<SideToolBar />
</template>
</LiteGraphCanvasSplitterOverlay>
</teleport>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, provide, ref } from "vue";
import NodeSearchboxPopover from "@/components/NodeSearchBoxPopover.vue";
import ProgressSpinner from "primevue/progressspinner";
import { api } from "@/scripts/api";
import { NodeSearchService } from "./services/nodeSearchService";
import { ColorPaletteLoadedEvent } from "./types/colorPalette";
import { LiteGraphNodeSearchSettingEvent } from "./scripts/ui";
import { computed, markRaw, onMounted, onUnmounted, ref, watch } from 'vue'
import NodeSearchboxPopover from '@/components/NodeSearchBoxPopover.vue'
import SideToolBar from '@/components/sidebar/SideToolBar.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import QueueSideBarTab from '@/components/sidebar/tabs/QueueSideBarTab.vue'
import ProgressSpinner from 'primevue/progressspinner'
import { app } from './scripts/app'
import { useSettingStore } from './stores/settingStore'
import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from './stores/workspaceStateStore'
import NodeLibrarySideBarTab from './components/sidebar/tabs/NodeLibrarySideBarTab.vue'
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { useNodeDefStore } from './stores/nodeDefStore'
const isLoading = ref(true);
const nodeSearchEnabled = ref(false);
const nodeSearchService = ref<NodeSearchService>();
const isLoading = ref(true)
const nodeSearchEnabled = computed<boolean>(
() => useSettingStore().get('Comfy.NodeSearchBoxImpl') === 'default'
)
const theme = computed<string>(() =>
useSettingStore().get('Comfy.ColorPalette')
)
watch(
theme,
(newTheme) => {
const DARK_THEME_CLASS = 'dark-theme'
const isDarkTheme = newTheme !== 'light'
if (isDarkTheme) {
document.body.classList.add(DARK_THEME_CLASS)
} else {
document.body.classList.remove(DARK_THEME_CLASS)
}
},
{ immediate: true }
)
const betaMenuEnabled = computed(
() => useSettingStore().get('Comfy.UseNewMenu') !== 'Disabled'
)
const updateTheme = (e: ColorPaletteLoadedEvent) => {
const DARK_THEME_CLASS = "dark-theme";
const isDarkTheme = e.detail.id !== "light";
const { t } = useI18n()
let dropTargetCleanup = () => {}
const init = () => {
useSettingStore().addSettings(app.ui.settings)
app.vueAppReady = true
app.extensionManager = useWorkspaceStore()
app.extensionManager.registerSidebarTab({
id: 'queue',
icon: 'pi pi-history',
title: t('sideToolBar.queue'),
tooltip: t('sideToolBar.queue'),
component: markRaw(QueueSideBarTab),
type: 'vue'
})
app.extensionManager.registerSidebarTab({
id: 'node-library',
icon: 'pi pi-book',
title: t('sideToolBar.nodeLibrary'),
tooltip: t('sideToolBar.nodeLibrary'),
component: markRaw(NodeLibrarySideBarTab),
type: 'vue'
})
if (isDarkTheme) {
document.body.classList.add(DARK_THEME_CLASS);
} else {
document.body.classList.remove(DARK_THEME_CLASS);
}
};
dropTargetCleanup = dropTargetForElements({
element: document.querySelector('.graph-canvas-container'),
onDrop: (event) => {
const loc = event.location.current.input
// Add an offset on x to make sure after adding the node, the cursor
// is on the node (top left corner)
const pos = app.clientPosToCanvasPos([loc.clientX - 20, loc.clientY])
const comfyNodeName = event.source.element.getAttribute(
'data-comfy-node-name'
)
const nodeDef = useNodeDefStore().nodeDefsByName[comfyNodeName]
app.addNodeOnGraph(nodeDef, { pos })
}
})
}
const updateNodeSearchSetting = (e: LiteGraphNodeSearchSettingEvent) => {
nodeSearchEnabled.value = !e.detail;
};
const init = async () => {
const nodeDefs = Object.values(await api.getNodeDefs());
nodeSearchService.value = new NodeSearchService(nodeDefs);
document.addEventListener("comfy:setting:color-palette-loaded", updateTheme);
document.addEventListener(
"comfy:setting:litegraph-node-search",
updateNodeSearchSetting
);
};
onMounted(async () => {
onMounted(() => {
try {
await init();
init()
} catch (e) {
console.error("Failed to init Vue app", e);
console.error('Failed to init Vue app', e)
} finally {
isLoading.value = false;
isLoading.value = false
}
});
})
onUnmounted(() => {
document.removeEventListener(
"comfy:setting:color-palette-loaded",
updateTheme
);
document.removeEventListener(
"comfy:setting:litegraph-node-search",
updateNodeSearchSetting
);
});
provide("nodeSearchService", nodeSearchService);
dropTargetCleanup()
})
</script>
<style scoped>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
<template>
<Splitter class="splitter-overlay" :pt:gutter="gutterClass">
<SplitterPanel
class="side-bar-panel"
:minSize="10"
:size="20"
v-show="sideBarPanelVisible"
>
<slot name="side-bar-panel"></slot>
</SplitterPanel>
<SplitterPanel class="graph-canvas-panel" :size="100">
<div></div>
</SplitterPanel>
</Splitter>
</template>
<script setup lang="ts">
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
const sideBarPanelVisible = computed(
() => useWorkspaceStore().activeSidebarTab !== null
)
const gutterClass = computed(() => {
return sideBarPanelVisible.value ? '' : 'gutter-hidden'
})
</script>
<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;
}
.splitter-overlay {
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

@@ -3,114 +3,74 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
-->
<template>
<div id="previewDiv">
<div class="sb_table">
<div class="_sb_node_preview">
<div class="_sb_table">
<div class="node_header">
<div class="sb_dot headdot"></div>
<div class="_sb_dot headdot"></div>
{{ nodeDef.display_name }}
</div>
<div class="sb_preview_badge">PREVIEW</div>
<div class="_sb_preview_badge">PREVIEW</div>
<!-- Node slot I/O -->
<div
v-for="[slotInput, slotOutput] in _.zip(slotInputDefs, allOutputDefs)"
class="sb_row slot_row"
class="_sb_row slot_row"
:key="(slotInput?.name || '') + (slotOutput?.index.toString() || '')"
>
<div class="sb_col">
<div v-if="slotInput" :class="['sb_dot', slotInput.type]"></div>
<div class="_sb_col">
<div v-if="slotInput" :class="['_sb_dot', slotInput.type]"></div>
</div>
<div class="sb_col">{{ slotInput ? slotInput.name : "" }}</div>
<div class="sb_col middle-column"></div>
<div class="sb_col sb_inherit">
{{ slotOutput ? slotOutput.name : "" }}
<div class="_sb_col">{{ slotInput ? slotInput.name : '' }}</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">
{{ slotOutput ? slotOutput.name : '' }}
</div>
<div class="sb_col">
<div v-if="slotOutput" :class="['sb_dot', slotOutput.type]"></div>
<div class="_sb_col">
<div v-if="slotOutput" :class="['_sb_dot', slotOutput.type]"></div>
</div>
</div>
<!-- Node widget inputs -->
<div v-for="widgetInput in widgetInputDefs" class="sb_row long_field">
<div class="sb_col sb_arrow">&#x25C0;</div>
<div class="sb_col">{{ widgetInput.name }}</div>
<div class="sb_col middle-column"></div>
<div class="sb_col sb_inherit">{{ widgetInput.defaultValue }}</div>
<div class="sb_col sb_arrow">&#x25B6;</div>
<div
v-for="widgetInput in widgetInputDefs"
class="_sb_row _long_field"
:key="widgetInput.name"
>
<div class="_sb_col _sb_arrow">&#x25C0;</div>
<div class="_sb_col">{{ widgetInput.name }}</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">{{ widgetInput.default }}</div>
<div class="_sb_col _sb_arrow">&#x25B6;</div>
</div>
</div>
<div class="sb_description" v-if="nodeDef.description">
<div class="_sb_description" v-if="nodeDef.description">
{{ nodeDef.description }}
</div>
</div>
</template>
<script setup lang="ts">
import { app } from "@/scripts/app";
import { type ComfyNodeDef } from "@/types/apiTypes";
import _ from "lodash";
import { PropType } from "vue";
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import _ from 'lodash'
const props = defineProps({
nodeDef: {
type: Object as PropType<ComfyNodeDef>,
required: true,
},
// Make sure vue properly re-render the component when the nodeDef changes
key: {
type: String,
required: true,
},
});
const nodeDef = props.nodeDef as ComfyNodeDef;
// --------------------------------------------------
// TODO: Move out to separate file
interface IComfyNodeInputDef {
name: string;
type: string;
widgetType: string | null;
defaultValue: any;
}
interface IComfyNodeOutputDef {
name: string | null;
type: string;
isList: boolean;
}
const allInputs = Object.assign(
{},
nodeDef.input.required || {},
nodeDef.input.optional || {}
);
const allInputDefs: IComfyNodeInputDef[] = Object.entries(allInputs).map(
([inputName, inputData]) => {
return {
name: inputName,
type: inputData[0],
widgetType: app.getWidgetType(inputData, inputName),
defaultValue:
inputData[1]?.default ||
(inputData[0] instanceof Array ? inputData[0][0] : ""),
};
type: ComfyNodeDefImpl,
required: true
}
);
})
const allOutputDefs: IComfyNodeOutputDef[] = _.zip(
nodeDef.output,
nodeDef.output_name || [],
nodeDef.output_is_list || []
).map(([outputType, outputName, isList]) => {
return {
name: outputName,
type: outputType instanceof Array ? "COMBO" : outputType,
isList: isList,
};
});
const nodeDefStore = useNodeDefStore()
const slotInputDefs = allInputDefs.filter((input) => !input.widgetType);
const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
const nodeDef = props.nodeDef
const allInputDefs = nodeDef.input.all
const allOutputDefs = nodeDef.output.all
const slotInputDefs = allInputDefs.filter(
(input) => !nodeDefStore.inputIsWidget(input)
)
const widgetInputDefs = allInputDefs.filter((input) =>
nodeDefStore.inputIsWidget(input)
)
</script>
<style scoped>
@@ -119,7 +79,7 @@ const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
}
/* Original N-SideBar styles */
.sb_dot {
._sb_dot {
width: 8px;
height: 8px;
border-radius: 50%;
@@ -177,9 +137,9 @@ const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
background-color: #a5d6a7;
}
#previewDiv {
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: "Open Sans", sans-serif;
font-family: 'Open Sans', sans-serif;
font-size: small;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);
@@ -193,7 +153,7 @@ const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
padding-bottom: 10px;
}
#previewDiv .sb_description {
._sb_node_preview ._sb_description {
margin: 10px;
padding: 6px;
background: var(--border-color);
@@ -203,7 +163,7 @@ const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
font-size: 0.9rem;
}
.sb_table {
._sb_table {
display: grid;
grid-column-gap: 10px;
@@ -212,20 +172,21 @@ const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
/* Imposta la larghezza della tabella al 100% del contenitore */
}
.sb_row {
._sb_row {
display: grid;
grid-template-columns: 10px 1fr 1fr 1fr 10px;
grid-column-gap: 10px;
align-items: center;
padding-left: 9px;
padding-right: 9px;
overflow-x: hidden;
}
.sb_row_string {
._sb_row_string {
grid-template-columns: 10px 1fr 1fr 10fr 1fr;
}
.sb_col {
._sb_col {
border: 0px solid #000;
display: flex;
align-items: flex-end;
@@ -235,23 +196,24 @@ const widgetInputDefs = allInputDefs.filter((input) => !!input.widgetType);
justify-content: flex-end;
}
.sb_inherit {
._sb_inherit {
display: inherit;
}
.long_field {
._long_field {
background: var(--bg-color);
border: 2px solid var(--border-color);
margin: 5px 5px 0 5px;
border-radius: 10px;
line-height: 1.7;
text-wrap: nowrap;
}
.sb_arrow {
._sb_arrow {
color: var(--fg-color);
}
.sb_preview_badge {
._sb_preview_badge {
text-align: center;
background: var(--comfy-input-bg);
font-weight: bold;

View File

@@ -53,75 +53,72 @@
</template>
<script setup lang="ts">
import { computed, inject, onMounted, Ref, ref } from "vue";
import AutoCompletePlus from "./primevueOverride/AutoCompletePlus.vue";
import Chip from "primevue/chip";
import Badge from "primevue/badge";
import NodeSearchFilter from "@/components/NodeSearchFilter.vue";
import NodeSourceChip from "@/components/NodeSourceChip.vue";
import { ComfyNodeDef } from "@/types/apiTypes";
import {
NodeSearchService,
type FilterAndValue,
} from "@/services/nodeSearchService";
import NodePreview from "./NodePreview.vue";
import { computed, onMounted, ref } from 'vue'
import AutoCompletePlus from './primevueOverride/AutoCompletePlus.vue'
import Chip from 'primevue/chip'
import Badge from 'primevue/badge'
import NodeSearchFilter from '@/components/NodeSearchFilter.vue'
import NodeSourceChip from '@/components/NodeSourceChip.vue'
import { type FilterAndValue } from '@/services/nodeSearchService'
import NodePreview from './NodePreview.vue'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
const props = defineProps({
filters: {
type: Array<FilterAndValue>,
type: Array<FilterAndValue>
},
searchLimit: {
type: Number,
default: 64,
},
});
default: 64
}
})
const nodeSearchService = (
inject("nodeSearchService") as Ref<NodeSearchService>
).value;
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`;
const suggestions = ref<ComfyNodeDef[]>([]);
const hoveredSuggestion = ref<ComfyNodeDef | null>(null);
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDefImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const placeholder = computed(() => {
return props.filters.length === 0 ? "Search for nodes" : "";
});
return props.filters.length === 0 ? 'Search for nodes' : ''
})
const search = (query: string) => {
suggestions.value = nodeSearchService.searchNode(query, props.filters, {
limit: props.searchLimit,
});
};
suggestions.value = useNodeDefStore().nodeSearchService.searchNode(
query,
props.filters,
{
limit: props.searchLimit
}
)
}
const emit = defineEmits(["addFilter", "removeFilter", "addNode"]);
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
const reFocusInput = () => {
const inputElement = document.getElementById(inputId) as HTMLInputElement;
const inputElement = document.getElementById(inputId) as HTMLInputElement
if (inputElement) {
inputElement.blur();
inputElement.focus();
inputElement.blur()
inputElement.focus()
}
};
}
onMounted(reFocusInput);
onMounted(reFocusInput)
const onAddFilter = (filterAndValue: FilterAndValue) => {
emit("addFilter", filterAndValue);
reFocusInput();
};
emit('addFilter', filterAndValue)
reFocusInput()
}
const onRemoveFilter = (event: Event, filterAndValue: FilterAndValue) => {
event.stopPropagation();
event.preventDefault();
emit("removeFilter", filterAndValue);
reFocusInput();
};
event.stopPropagation()
event.preventDefault()
emit('removeFilter', filterAndValue)
reFocusInput()
}
const setHoverSuggestion = (index: number) => {
if (index === -1) {
hoveredSuggestion.value = null;
return;
hoveredSuggestion.value = null
return
}
const value = suggestions.value[index];
hoveredSuggestion.value = value;
};
const value = suggestions.value[index]
hoveredSuggestion.value = value
}
</script>
<style scoped>

View File

@@ -2,9 +2,11 @@
<div>
<Dialog
v-model:visible="visible"
pt:root:class="invisible-dialog-root"
dismissable-mask
pt:root="invisible-dialog-root"
pt:mask="node-search-box-dialog-mask"
modal
:dismissable-mask="dismissable"
@hide="clearFilters"
>
<template #container>
<NodeSearchBox
@@ -19,141 +21,137 @@
</template>
<script setup lang="ts">
import { app } from "@/scripts/app";
import { inject, onMounted, onUnmounted, reactive, Ref, ref } from "vue";
import NodeSearchBox from "./NodeSearchBox.vue";
import Dialog from "primevue/dialog";
import { app } from '@/scripts/app'
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import NodeSearchBox from './NodeSearchBox.vue'
import Dialog from 'primevue/dialog'
import {
INodeSlot,
LiteGraph,
LiteGraphCanvasEvent,
LGraphNode,
LinkReleaseContext,
} from "@comfyorg/litegraph";
import {
FilterAndValue,
NodeSearchService,
} from "@/services/nodeSearchService";
import { ComfyNodeDef } from "@/types/apiTypes";
LinkReleaseContext
} from '@comfyorg/litegraph'
import { FilterAndValue } from '@/services/nodeSearchService'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
interface LiteGraphPointerEvent extends Event {
canvasX: number;
canvasY: number;
canvasX: number
canvasY: number
}
const visible = ref(false);
const triggerEvent = ref<LiteGraphCanvasEvent | null>(null);
const visible = ref(false)
const dismissable = ref(true)
const triggerEvent = ref<LiteGraphCanvasEvent | null>(null)
const getNewNodeLocation = (): [number, number] => {
if (triggerEvent.value === null) {
return [100, 100];
return [100, 100]
}
const originalEvent = triggerEvent.value.detail
.originalEvent as LiteGraphPointerEvent;
return [originalEvent.canvasX, originalEvent.canvasY];
};
const nodeFilters = reactive([]);
.originalEvent as LiteGraphPointerEvent
return [originalEvent.canvasX, originalEvent.canvasY]
}
const nodeFilters = reactive([])
const addFilter = (filter: FilterAndValue) => {
nodeFilters.push(filter);
};
nodeFilters.push(filter)
}
const removeFilter = (filter: FilterAndValue) => {
const index = nodeFilters.findIndex((f) => f === filter);
const index = nodeFilters.findIndex((f) => f === filter)
if (index !== -1) {
nodeFilters.splice(index, 1);
nodeFilters.splice(index, 1)
}
};
}
const clearFilters = () => {
nodeFilters.splice(0, nodeFilters.length);
};
nodeFilters.splice(0, nodeFilters.length)
}
const closeDialog = () => {
clearFilters();
visible.value = false;
};
visible.value = false
}
const connectNodeOnLinkRelease = (
node: LGraphNode,
context: LinkReleaseContext
) => {
const destIsInput = context.node_from !== undefined;
const destIsInput = context.node_from !== undefined
const srcNode = (
destIsInput ? context.node_from : context.node_to
) as LGraphNode;
const srcSlotIndex: number = context.slot_from.slot_index;
) as LGraphNode
const srcSlotIndex: number = context.slot_from.slot_index
const linkDataType = destIsInput
? context.type_filter_in
: context.type_filter_out;
const destSlots = destIsInput ? node.inputs : node.outputs;
: context.type_filter_out
const destSlots = destIsInput ? node.inputs : node.outputs
const destSlotIndex = destSlots.findIndex(
(slot: INodeSlot) => slot.type === linkDataType
);
)
if (destSlotIndex === -1) {
console.warn(
`Could not find slot with type ${linkDataType} on node ${node.title}`
);
return;
)
return
}
if (destIsInput) {
srcNode.connect(srcSlotIndex, node, destSlotIndex);
srcNode.connect(srcSlotIndex, node, destSlotIndex)
} else {
node.connect(destSlotIndex, srcNode, srcSlotIndex);
node.connect(destSlotIndex, srcNode, srcSlotIndex)
}
};
const addNode = (nodeDef: ComfyNodeDef) => {
closeDialog();
const node = LiteGraph.createNode(nodeDef.name, nodeDef.display_name, {});
if (node) {
node.pos = getNewNodeLocation();
app.graph.add(node);
}
const addNode = (nodeDef: ComfyNodeDefImpl) => {
closeDialog()
const eventDetail = triggerEvent.value.detail;
if (eventDetail.subType === "empty-release") {
connectNodeOnLinkRelease(node, eventDetail.linkReleaseContext);
}
const node = app.addNodeOnGraph(nodeDef, { pos: getNewNodeLocation() })
const eventDetail = triggerEvent.value.detail
if (eventDetail.subType === 'empty-release') {
connectNodeOnLinkRelease(node, eventDetail.linkReleaseContext)
}
};
const nodeSearchService = (
inject("nodeSearchService") as Ref<NodeSearchService>
).value;
}
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
const shiftPressed = (e.detail.originalEvent as KeyboardEvent).shiftKey;
const shiftPressed = (e.detail.originalEvent as KeyboardEvent).shiftKey
// Ignore empty releases unless shift is pressed
// Empty release without shift is trigger right click menu
if (e.detail.subType === "empty-release" && !shiftPressed) {
return;
if (e.detail.subType === 'empty-release' && !shiftPressed) {
return
}
if (e.detail.subType === "empty-release") {
const destIsInput = e.detail.linkReleaseContext.node_from !== undefined;
const filter = destIsInput
? nodeSearchService.getFilterById("input")
: nodeSearchService.getFilterById("output");
if (e.detail.subType === 'empty-release') {
const destIsInput = e.detail.linkReleaseContext.node_from !== undefined
const filter = useNodeDefStore().nodeSearchService.getFilterById(
destIsInput ? 'input' : 'output'
)
const value = destIsInput
? e.detail.linkReleaseContext.type_filter_in
: e.detail.linkReleaseContext.type_filter_out;
: e.detail.linkReleaseContext.type_filter_out
addFilter([filter, value]);
addFilter([filter, value])
}
triggerEvent.value = e;
visible.value = true;
};
triggerEvent.value = e
visible.value = true
// Prevent the dialog from being dismissed immediately
dismissable.value = false
setTimeout(() => {
dismissable.value = true
}, 300)
}
const handleEscapeKeyPress = (event) => {
if (event.key === "Escape") {
closeDialog();
if (event.key === 'Escape') {
closeDialog()
}
};
}
onMounted(() => {
document.addEventListener("litegraph:canvas", canvasEventHandler);
document.addEventListener("keydown", handleEscapeKeyPress);
});
document.addEventListener('litegraph:canvas', canvasEventHandler)
document.addEventListener('keydown', handleEscapeKeyPress)
})
onUnmounted(() => {
document.removeEventListener("litegraph:canvas", canvasEventHandler);
document.removeEventListener("keydown", handleEscapeKeyPress);
});
document.removeEventListener('litegraph:canvas', canvasEventHandler)
document.removeEventListener('keydown', handleEscapeKeyPress)
})
</script>
<style>
@@ -163,5 +161,10 @@ onUnmounted(() => {
max-width: 48rem;
border: 0 !important;
background-color: transparent !important;
margin-top: 25vh;
}
.node-search-box-dialog-mask {
align-items: flex-start !important;
}
</style>

View File

@@ -2,14 +2,14 @@
<Button
icon="pi pi-filter"
severity="secondary"
class="filter-button"
class="_filter-button"
@click="showModal"
/>
<Dialog v-model:visible="visible" class="dialog">
<Dialog v-model:visible="visible" class="_dialog">
<template #header>
<h3>Add node filter condition</h3>
</template>
<div class="dialog-body">
<div class="_dialog-body">
<SelectButton
v-model="selectedFilter"
:options="filters"
@@ -34,68 +34,64 @@
</template>
<script setup lang="ts">
import {
NodeFilter,
NodeSearchService,
type FilterAndValue,
} from "@/services/nodeSearchService";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import SelectButton from "primevue/selectbutton";
import AutoComplete from "primevue/autocomplete";
import { inject, ref, onMounted } from "vue";
import { NodeFilter, type FilterAndValue } from '@/services/nodeSearchService'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import SelectButton from 'primevue/selectbutton'
import AutoComplete from 'primevue/autocomplete'
import { ref, onMounted } from 'vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const visible = ref<boolean>(false);
const nodeSearchService: NodeSearchService = inject("nodeSearchService").value;
const filters = ref<NodeFilter[]>([]);
const selectedFilter = ref<NodeFilter>();
const filterValues = ref<string[]>([]);
const selectedFilterValue = ref<string>("");
const visible = ref<boolean>(false)
const filters = ref<NodeFilter[]>([])
const selectedFilter = ref<NodeFilter>()
const filterValues = ref<string[]>([])
const selectedFilterValue = ref<string>('')
onMounted(() => {
filters.value = nodeSearchService.nodeFilters;
selectedFilter.value = nodeSearchService.nodeFilters[0];
});
const nodeSearchService = useNodeDefStore().nodeSearchService
filters.value = nodeSearchService.nodeFilters
selectedFilter.value = nodeSearchService.nodeFilters[0]
})
const emit = defineEmits(["addFilter"]);
const emit = defineEmits(['addFilter'])
const updateSelectedFilterValue = () => {
updateFilterValues("");
updateFilterValues('')
if (filterValues.value.includes(selectedFilterValue.value)) {
return;
return
}
selectedFilterValue.value = filterValues.value[0];
};
selectedFilterValue.value = filterValues.value[0]
}
const updateFilterValues = (query: string) => {
filterValues.value = selectedFilter.value.fuseSearch.search(query);
};
filterValues.value = selectedFilter.value.fuseSearch.search(query)
}
const submit = () => {
visible.value = false;
emit("addFilter", [
visible.value = false
emit('addFilter', [
selectedFilter.value,
selectedFilterValue.value,
] as FilterAndValue);
};
selectedFilterValue.value
] as FilterAndValue)
}
const showModal = () => {
updateSelectedFilterValue();
visible.value = true;
};
updateSelectedFilterValue()
visible.value = true
}
</script>
<style scoped>
.filter-button {
._filter-button {
z-index: 10;
}
.dialog {
._dialog {
@apply min-w-96;
}
.dialog-body {
._dialog-body {
@apply flex flex-col space-y-2;
}
</style>

View File

@@ -5,18 +5,18 @@
</template>
<script setup lang="ts">
import { getNodeSource } from "@/types/nodeSource";
import Chip from "primevue/chip";
import { computed } from "vue";
import { getNodeSource } from '@/types/nodeSource'
import Chip from 'primevue/chip'
import { computed } from 'vue'
const props = defineProps({
python_module: {
type: String,
required: true,
},
});
required: true
}
})
const nodeSource = computed(() => getNodeSource(props.python_module));
const nodeSource = computed(() => getNodeSource(props.python_module))
</script>
<style scoped>

View File

@@ -1,14 +1,14 @@
<!-- Auto complete with extra event "focused-option-changed" -->
<script>
import AutoComplete from "primevue/autocomplete";
import AutoComplete from 'primevue/autocomplete'
export default {
name: "AutoCompletePlus",
name: 'AutoCompletePlus',
extends: AutoComplete,
emits: ["focused-option-changed"],
emits: ['focused-option-changed'],
mounted() {
if (typeof AutoComplete.mounted === "function") {
AutoComplete.mounted.call(this);
if (typeof AutoComplete.mounted === 'function') {
AutoComplete.mounted.call(this)
}
// Add a watcher on the focusedOptionIndex property
@@ -16,9 +16,9 @@ export default {
() => this.focusedOptionIndex,
(newVal, oldVal) => {
// Emit a custom event when focusedOptionIndex changes
this.$emit("focused-option-changed", newVal);
this.$emit('focused-option-changed', newVal)
}
);
},
};
)
}
}
</script>

View File

@@ -0,0 +1,106 @@
<!-- Tree with all leaf nodes draggable -->
<script>
import Tree from 'primevue/tree'
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { h, onMounted, onBeforeUnmount, computed } from 'vue'
export default {
name: 'TreePlus',
extends: Tree,
props: {
dragSelector: {
type: String,
default: '.p-tree-node'
},
// Explicitly declare all v-model props
expandedKeys: {
type: Object,
default: () => ({})
},
selectionKeys: {
type: Object,
default: () => ({})
}
},
emits: ['update:expandedKeys', 'update:selectionKeys'],
setup(props, context) {
// Create computed properties for each v-model prop
const computedExpandedKeys = computed({
get: () => props.expandedKeys,
set: (value) => context.emit('update:expandedKeys', value)
})
const computedSelectionKeys = computed({
get: () => props.selectionKeys,
set: (value) => context.emit('update:selectionKeys', value)
})
let observer = null
const makeDraggable = (element) => {
if (!element._draggableCleanup) {
element._draggableCleanup = draggable({
element
})
}
}
const observeTreeChanges = (treeElement) => {
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
node.querySelectorAll(props.dragSelector).forEach(makeDraggable)
}
})
}
})
})
observer.observe(treeElement, { childList: true, subtree: true })
// Make existing nodes draggable
treeElement.querySelectorAll(props.dragSelector).forEach(makeDraggable)
}
onMounted(() => {
const treeElement = document.querySelector('.p-tree')
if (treeElement) {
observeTreeChanges(treeElement)
}
})
onBeforeUnmount(() => {
if (observer) {
observer.disconnect()
}
// Clean up draggable instances if necessary
const treeElement = document.querySelector('.p-tree')
if (treeElement) {
treeElement.querySelectorAll(props.dragSelector).forEach((node) => {
if (node._draggableCleanup) {
node._draggableCleanup()
}
})
}
})
return () =>
h(
Tree,
{
...context.attrs,
...props,
expandedKeys: computedExpandedKeys.value,
selectionKeys: computedSelectionKeys.value,
'onUpdate:expandedKeys': (value) =>
(computedExpandedKeys.value = value),
'onUpdate:selectionKeys': (value) =>
(computedSelectionKeys.value = value)
},
context.slots
)
}
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<Button
:icon="props.icon"
:class="props.class"
text
:pt="{
root: `side-bar-button ${
props.selected
? 'p-button-primary side-bar-button-selected'
: 'p-button-secondary'
}`,
icon: 'side-bar-button-icon'
}"
@click="emit('click', $event)"
v-tooltip="{ value: props.tooltip, showDelay: 300, hideDelay: 300 }"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
const props = defineProps({
icon: String,
selected: Boolean,
tooltip: {
type: String,
default: ''
},
class: {
type: String,
default: ''
}
})
const emit = defineEmits(['click'])
</script>
<style>
.p-button-icon.side-bar-button-icon {
font-size: var(--sidebar-icon-size) !important;
}
.side-bar-button-selected .p-button-icon.side-bar-button-icon {
font-size: var(--sidebar-icon-size) !important;
font-weight: bold;
}
</style>
<style scoped>
.side-bar-button {
width: var(--sidebar-width);
height: var(--sidebar-width);
border-radius: 0;
}
.side-bar-button.side-bar-button-selected,
.side-bar-button.side-bar-button-selected:hover {
border-left: 4px solid var(--p-button-text-primary-color);
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<SideBarIcon
icon="pi pi-cog"
@click="showSetting"
:tooltip="$t('sideToolBar.settings')"
/>
</template>
<script setup lang="ts">
import { app } from '@/scripts/app'
import SideBarIcon from './SideBarIcon.vue'
const showSetting = () => {
app.ui.settings.show()
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<SideBarIcon
:icon="icon"
@click="toggleTheme"
:tooltip="$t('sideToolBar.themeToggle')"
class="comfy-vue-theme-toggle"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import SideBarIcon from './SideBarIcon.vue'
import { useSettingStore } from '@/stores/settingStore'
const previousDarkTheme = ref('dark')
const currentTheme = computed(() => useSettingStore().get('Comfy.ColorPalette'))
const isDarkMode = computed(() => currentTheme.value !== 'light')
const icon = computed(() => (isDarkMode.value ? 'pi pi-moon' : 'pi pi-sun'))
const toggleTheme = () => {
if (isDarkMode.value) {
previousDarkTheme.value = currentTheme.value
useSettingStore().set('Comfy.ColorPalette', 'light')
} else {
useSettingStore().set('Comfy.ColorPalette', previousDarkTheme.value)
}
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<teleport to=".comfyui-body-left">
<nav class="side-tool-bar-container">
<SideBarIcon
v-for="tab in tabs"
:key="tab.id"
:icon="tab.icon"
:tooltip="tab.tooltip"
:selected="tab === selectedTab"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<div class="side-tool-bar-end">
<SideBarThemeToggleIcon />
<SideBarSettingsToggleIcon />
</div>
</nav>
</teleport>
<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>
<script setup lang="ts">
import SideBarIcon from './SideBarIcon.vue'
import SideBarThemeToggleIcon from './SideBarThemeToggleIcon.vue'
import SideBarSettingsToggleIcon from './SideBarSettingsToggleIcon.vue'
import { computed, onBeforeUnmount } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import {
CustomSidebarTabExtension,
SidebarTabExtension
} from '@/types/extensionTypes'
const workspaceStore = useWorkspaceStore()
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed<SidebarTabExtension | null>(() => {
const tabId = workspaceStore.activeSidebarTab
return tabs.value.find((tab) => tab.id === tabId) || null
})
const mountCustomTab = (tab: CustomSidebarTabExtension, el: HTMLElement) => {
tab.render(el)
}
const onTabClick = (item: SidebarTabExtension) => {
workspaceStore.updateActiveSidebarTab(
workspaceStore.activeSidebarTab === item.id ? null : item.id
)
}
onBeforeUnmount(() => {
tabs.value.forEach((tab) => {
if (tab.type === 'custom' && tab.destroy) {
tab.destroy()
}
})
})
</script>
<style>
:root {
--sidebar-width: 64px;
--sidebar-icon-size: 1.5rem;
}
</style>
<style scoped>
.side-tool-bar-container {
display: flex;
flex-direction: column;
align-items: center;
pointer-events: auto;
width: var(--sidebar-width);
height: 100%;
background-color: var(--comfy-menu-bg);
color: var(--fg-color);
}
.side-tool-bar-end {
align-self: flex-end;
margin-top: auto;
}
.sidebar-content-container {
height: 100%;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<TreePlus
class="node-lib-tree"
v-model:expandedKeys="expandedKeys"
selectionMode="single"
:value="renderedRoot.children"
:filter="true"
filterMode="lenient"
dragSelector=".p-tree-node-leaf"
:pt="{
nodeLabel: 'node-lib-tree-node-label',
nodeChildren: ({ props }) => ({
'data-comfy-node-name': props.node?.data?.name,
onMouseenter: (event: MouseEvent) => {
hoveredComfyNodeName = props.node?.data?.name
const hoverTarget = event.target as HTMLElement
const targetRect = hoverTarget.getBoundingClientRect()
nodePreviewStyle.top = `${targetRect.top - 40}px`
nodePreviewStyle.left = `${targetRect.right}px`
},
onMouseleave: () => {
hoveredComfyNodeName = null
}
})
}"
>
<template #folder="{ node }">
<span class="folder-label">{{ node.label }}</span>
<Badge
:value="node.totalNodes"
severity="secondary"
:style="{ marginLeft: '0.5rem' }"
></Badge>
</template>
<template #node="{ node }">
<span class="node-label">{{ node.label }}</span>
</template>
</TreePlus>
<div
v-if="hoveredComfyNode"
class="node-lib-node-preview"
:style="nodePreviewStyle"
>
<NodePreview
:key="hoveredComfyNode.name"
:nodeDef="hoveredComfyNode"
></NodePreview>
</div>
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { computed, ref } from 'vue'
import { TreeNode } from 'primevue/treenode'
import TreePlus from '@/components/primevueOverride/TreePlus.vue'
import NodePreview from '@/components/NodePreview.vue'
const nodeDefStore = useNodeDefStore()
const expandedKeys = ref({})
const hoveredComfyNodeName = ref<string | null>(null)
const hoveredComfyNode = computed<ComfyNodeDefImpl | null>(() => {
if (!hoveredComfyNodeName.value) {
return null
}
return nodeDefStore.nodeDefsByName[hoveredComfyNodeName.value] || null
})
const nodePreviewStyle = ref<Record<string, string>>({
position: 'absolute',
top: '0px',
left: '0px'
})
const root = computed(() => nodeDefStore.nodeTree)
const renderedRoot = computed(() => {
return fillNodeInfo(root.value)
})
const fillNodeInfo = (node: TreeNode): TreeNode => {
const isLeaf = node.children === undefined || node.children.length === 0
const isExpanded = expandedKeys.value[node.key]
const icon = isLeaf
? 'pi pi-circle-fill'
: isExpanded
? 'pi pi-folder-open'
: 'pi pi-folder'
const children = node.children?.map(fillNodeInfo)
return {
...node,
icon,
children,
type: isLeaf ? 'node' : 'folder',
totalNodes: isLeaf
? 1
: children.reduce((acc, child) => acc + child.totalNodes, 0)
}
}
</script>

View File

@@ -0,0 +1,171 @@
<template>
<DataTable
v-if="tasks.length > 0"
:value="tasks"
dataKey="promptId"
class="queue-table"
>
<Column header="STATUS">
<template #body="{ data }">
<Tag :severity="taskTagSeverity(data.displayStatus)">
{{ data.displayStatus.toUpperCase() }}
</Tag>
</template>
</Column>
<Column header="TIME" :pt="{ root: { class: 'queue-time-cell' } }">
<template #body="{ data }">
<div v-if="data.isHistory" class="queue-time-cell-content">
{{ formatTime(data.executionTimeInSeconds) }}
</div>
<div v-else-if="data.isRunning" class="queue-time-cell-content">
<i class="pi pi-spin pi-spinner"></i>
</div>
<div v-else class="queue-time-cell-content">...</div>
</template>
</Column>
<Column
:pt="{
headerCell: {
class: 'queue-tool-header-cell'
},
bodyCell: {
class: 'queue-tool-body-cell'
}
}"
>
<template #header>
<Toast />
<ConfirmPopup />
<Button
icon="pi pi-trash"
text
severity="primary"
@click="confirmRemoveAll($event)"
/>
</template>
<template #body="{ data }">
<Button
icon="pi pi-file-export"
text
severity="primary"
@click="data.loadWorkflow()"
/>
<Button
icon="pi pi-times"
text
severity="secondary"
@click="removeTask(data)"
/>
</template>
</Column>
</DataTable>
<div>
<Message icon="pi pi-info" severity="error">
<span class="ml-2">No tasks</span>
</Message>
</div>
</template>
<script setup lang="ts">
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import Toast from 'primevue/toast'
import Message from 'primevue/message'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import {
TaskItemDisplayStatus,
TaskItemImpl,
useQueueStore
} from '@/stores/queueStore'
import { computed, onMounted } from 'vue'
import { api } from '@/scripts/api'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
const tasks = computed(() => queueStore.tasks)
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'secondary'
case TaskItemDisplayStatus.Running:
return 'info'
case TaskItemDisplayStatus.Completed:
return 'success'
case TaskItemDisplayStatus.Failed:
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return 'warning'
}
}
const formatTime = (time?: number) => {
if (time === undefined) {
return ''
}
return `${time.toFixed(2)}s`
}
const removeTask = (task: TaskItemImpl) => {
if (task.isRunning) {
api.interrupt()
}
queueStore.delete(task)
}
const removeAllTasks = async () => {
await queueStore.clear()
}
const confirmRemoveAll = (event) => {
confirm.require({
target: event.currentTarget,
message: 'Do you want to delete all tasks?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
await removeAllTasks()
toast.add({
severity: 'info',
summary: 'Confirmed',
detail: 'Tasks deleted',
life: 3000
})
}
})
}
onMounted(() => {
api.addEventListener('status', () => {
queueStore.update()
})
queueStore.update()
})
</script>
<style>
.queue-tool-header-cell {
display: flex;
justify-content: flex-end;
}
.queue-tool-body-cell {
display: table-cell;
text-align: right !important;
}
</style>
<style scoped>
.queue-time-cell-content {
width: fit-content;
}
</style>

View File

@@ -1,20 +1,20 @@
import { app } from "../../scripts/app";
import { ComfyDialog, $el } from "../../scripts/ui";
import { ComfyApp } from "../../scripts/app";
import { app } from '../../scripts/app'
import { ComfyDialog, $el } from '../../scripts/ui'
import { ComfyApp } from '../../scripts/app'
export class ClipspaceDialog extends ComfyDialog {
static items = [];
static instance = null;
static items = []
static instance = null
static registerButton(name, contextPredicate, callback) {
const item = $el("button", {
type: "button",
const item = $el('button', {
type: 'button',
textContent: name,
contextPredicate: contextPredicate,
onclick: callback,
});
onclick: callback
})
ClipspaceDialog.items.push(item);
ClipspaceDialog.items.push(item)
}
static invalidatePreview() {
@@ -24,161 +24,161 @@ export class ClipspaceDialog extends ComfyDialog {
ComfyApp.clipspace.imgs.length > 0
) {
const img_preview = document.getElementById(
"clipspace_preview"
) as HTMLImageElement;
'clipspace_preview'
) as HTMLImageElement
if (img_preview) {
img_preview.src =
ComfyApp.clipspace.imgs[ComfyApp.clipspace["selectedIndex"]].src;
img_preview.style.maxHeight = "100%";
img_preview.style.maxWidth = "100%";
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
img_preview.style.maxHeight = '100%'
img_preview.style.maxWidth = '100%'
}
}
}
static invalidate() {
if (ClipspaceDialog.instance) {
const self = ClipspaceDialog.instance;
const self = ClipspaceDialog.instance
// allow reconstruct controls when copying from non-image to image content.
const children = $el("div.comfy-modal-content", [
const children = $el('div.comfy-modal-content', [
self.createImgSettings(),
...self.createButtons(),
]);
...self.createButtons()
])
if (self.element) {
// update
self.element.removeChild(self.element.firstChild);
self.element.appendChild(children);
self.element.removeChild(self.element.firstChild)
self.element.appendChild(children)
} else {
// new
self.element = $el("div.comfy-modal", { parent: document.body }, [
children,
]);
self.element = $el('div.comfy-modal', { parent: document.body }, [
children
])
}
if (self.element.children[0].children.length <= 1) {
self.element.children[0].appendChild(
$el("p", {}, [
"Unable to find the features to edit content of a format stored in the current Clipspace.",
$el('p', {}, [
'Unable to find the features to edit content of a format stored in the current Clipspace.'
])
);
)
}
ClipspaceDialog.invalidatePreview();
ClipspaceDialog.invalidatePreview()
}
}
constructor() {
super();
super()
}
createButtons() {
const buttons = [];
const buttons = []
for (let idx in ClipspaceDialog.items) {
const item = ClipspaceDialog.items[idx];
const item = ClipspaceDialog.items[idx]
if (!item.contextPredicate || item.contextPredicate())
buttons.push(ClipspaceDialog.items[idx]);
buttons.push(ClipspaceDialog.items[idx])
}
buttons.push(
$el("button", {
type: "button",
textContent: "Close",
$el('button', {
type: 'button',
textContent: 'Close',
onclick: () => {
this.close();
},
this.close()
}
})
);
)
return buttons;
return buttons
}
createImgSettings() {
if (ComfyApp.clipspace.imgs) {
const combo_items = [];
const imgs = ComfyApp.clipspace.imgs;
const combo_items = []
const imgs = ComfyApp.clipspace.imgs
for (let i = 0; i < imgs.length; i++) {
combo_items.push($el("option", { value: i }, [`${i}`]));
combo_items.push($el('option', { value: i }, [`${i}`]))
}
const combo1 = $el(
"select",
'select',
{
id: "clipspace_img_selector",
id: 'clipspace_img_selector',
onchange: (event) => {
ComfyApp.clipspace["selectedIndex"] = event.target.selectedIndex;
ClipspaceDialog.invalidatePreview();
},
ComfyApp.clipspace['selectedIndex'] = event.target.selectedIndex
ClipspaceDialog.invalidatePreview()
}
},
combo_items
);
)
const row1 = $el("tr", {}, [
$el("td", {}, [$el("font", { color: "white" }, ["Select Image"])]),
$el("td", {}, [combo1]),
]);
const row1 = $el('tr', {}, [
$el('td', {}, [$el('font', { color: 'white' }, ['Select Image'])]),
$el('td', {}, [combo1])
])
const combo2 = $el(
"select",
'select',
{
id: "clipspace_img_paste_mode",
id: 'clipspace_img_paste_mode',
onchange: (event) => {
ComfyApp.clipspace["img_paste_mode"] = event.target.value;
},
ComfyApp.clipspace['img_paste_mode'] = event.target.value
}
},
[
$el("option", { value: "selected" }, "selected"),
$el("option", { value: "all" }, "all"),
$el('option', { value: 'selected' }, 'selected'),
$el('option', { value: 'all' }, 'all')
]
) as HTMLSelectElement;
combo2.value = ComfyApp.clipspace["img_paste_mode"];
) as HTMLSelectElement
combo2.value = ComfyApp.clipspace['img_paste_mode']
const row2 = $el("tr", {}, [
$el("td", {}, [$el("font", { color: "white" }, ["Paste Mode"])]),
$el("td", {}, [combo2]),
]);
const row2 = $el('tr', {}, [
$el('td', {}, [$el('font', { color: 'white' }, ['Paste Mode'])]),
$el('td', {}, [combo2])
])
const td = $el(
"td",
{ align: "center", width: "100px", height: "100px", colSpan: "2" },
[$el("img", { id: "clipspace_preview", ondragstart: () => false }, [])]
);
'td',
{ align: 'center', width: '100px', height: '100px', colSpan: '2' },
[$el('img', { id: 'clipspace_preview', ondragstart: () => false }, [])]
)
const row3 = $el("tr", {}, [td]);
const row3 = $el('tr', {}, [td])
return $el("table", {}, [row1, row2, row3]);
return $el('table', {}, [row1, row2, row3])
} else {
return [];
return []
}
}
createImgPreview() {
if (ComfyApp.clipspace.imgs) {
return $el("img", { id: "clipspace_preview", ondragstart: () => false });
} else return [];
return $el('img', { id: 'clipspace_preview', ondragstart: () => false })
} else return []
}
show() {
const img_preview = document.getElementById("clipspace_preview");
ClipspaceDialog.invalidate();
const img_preview = document.getElementById('clipspace_preview')
ClipspaceDialog.invalidate()
this.element.style.display = "block";
this.element.style.display = 'block'
}
}
app.registerExtension({
name: "Comfy.Clipspace",
name: 'Comfy.Clipspace',
init(app) {
app.openClipspace = function () {
if (!ClipspaceDialog.instance) {
ClipspaceDialog.instance = new ClipspaceDialog();
ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate;
ClipspaceDialog.instance = new ClipspaceDialog()
ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate
}
if (ComfyApp.clipspace) {
ClipspaceDialog.instance.show();
} else app.ui.dialog.show("Clipspace is Empty!");
};
},
});
ClipspaceDialog.instance.show()
} else app.ui.dialog.show('Clipspace is Empty!')
}
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +1,172 @@
import { LiteGraph, LGraphCanvas } from "@comfyorg/litegraph";
import { app } from "../../scripts/app";
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
// Adds filtering to combo context menus
const ext = {
name: "Comfy.ContextMenuFilter",
name: 'Comfy.ContextMenuFilter',
init() {
const ctxMenu = LiteGraph.ContextMenu;
const ctxMenu = LiteGraph.ContextMenu
// @ts-ignore
// TODO Very hacky way to modify Litegraph behaviour. Fix this later.
LiteGraph.ContextMenu = function (values, options) {
const ctx = ctxMenu.call(this, values, options);
const ctx = ctxMenu.call(this, values, options)
// If we are a dark menu (only used for combo boxes) then add a filter input
if (options?.className === "dark" && values?.length > 10) {
const filter = document.createElement("input");
filter.classList.add("comfy-context-menu-filter");
filter.placeholder = "Filter list";
this.root.prepend(filter);
if (options?.className === 'dark' && values?.length > 10) {
const filter = document.createElement('input')
filter.classList.add('comfy-context-menu-filter')
filter.placeholder = 'Filter list'
this.root.prepend(filter)
const items = Array.from(
this.root.querySelectorAll(".litemenu-entry")
) as HTMLElement[];
let displayedItems = [...items];
let itemCount = displayedItems.length;
this.root.querySelectorAll('.litemenu-entry')
) as HTMLElement[]
let displayedItems = [...items]
let itemCount = displayedItems.length
// We must request an animation frame for the current node of the active canvas to update.
requestAnimationFrame(() => {
// @ts-ignore
const currentNode = LGraphCanvas.active_canvas.current_node;
const currentNode = LGraphCanvas.active_canvas.current_node
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])
)?.value;
)?.value
let selectedIndex = clickedComboValue
? values.findIndex((v) => v === clickedComboValue)
: 0;
: 0
if (selectedIndex < 0) {
selectedIndex = 0;
selectedIndex = 0
}
let selectedItem = displayedItems[selectedIndex];
updateSelected();
let selectedItem = displayedItems[selectedIndex]
updateSelected()
// Apply highlighting to the selected item
function updateSelected() {
selectedItem?.style.setProperty("background-color", "");
selectedItem?.style.setProperty("color", "");
selectedItem = displayedItems[selectedIndex];
selectedItem?.style.setProperty('background-color', '')
selectedItem?.style.setProperty('color', '')
selectedItem = displayedItems[selectedIndex]
selectedItem?.style.setProperty(
"background-color",
"#ccc",
"important"
);
selectedItem?.style.setProperty("color", "#000", "important");
'background-color',
'#ccc',
'important'
)
selectedItem?.style.setProperty('color', '#000', 'important')
}
const positionList = () => {
const rect = this.root.getBoundingClientRect();
const rect = this.root.getBoundingClientRect()
// If the top is off-screen then shift the element with scaling applied
if (rect.top < 0) {
const scale =
1 -
this.root.getBoundingClientRect().height /
this.root.clientHeight;
const shift = (this.root.clientHeight * scale) / 2;
this.root.style.top = -shift + "px";
this.root.clientHeight
const shift = (this.root.clientHeight * scale) / 2
this.root.style.top = -shift + 'px'
}
};
}
// Arrow up/down to select items
filter.addEventListener("keydown", (event) => {
filter.addEventListener('keydown', (event) => {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
case 'ArrowUp':
event.preventDefault()
if (selectedIndex === 0) {
selectedIndex = itemCount - 1;
selectedIndex = itemCount - 1
} else {
selectedIndex--;
selectedIndex--
}
updateSelected();
break;
case "ArrowRight":
event.preventDefault();
selectedIndex = itemCount - 1;
updateSelected();
break;
case "ArrowDown":
event.preventDefault();
updateSelected()
break
case 'ArrowRight':
event.preventDefault()
selectedIndex = itemCount - 1
updateSelected()
break
case 'ArrowDown':
event.preventDefault()
if (selectedIndex === itemCount - 1) {
selectedIndex = 0;
selectedIndex = 0
} else {
selectedIndex++;
selectedIndex++
}
updateSelected();
break;
case "ArrowLeft":
event.preventDefault();
selectedIndex = 0;
updateSelected();
break;
case "Enter":
selectedItem?.click();
break;
case "Escape":
this.close();
break;
updateSelected()
break
case 'ArrowLeft':
event.preventDefault()
selectedIndex = 0
updateSelected()
break
case 'Enter':
selectedItem?.click()
break
case 'Escape':
this.close()
break
}
});
})
filter.addEventListener("input", () => {
filter.addEventListener('input', () => {
// Hide all items that don't match our filter
const term = filter.value.toLocaleLowerCase();
const term = filter.value.toLocaleLowerCase()
// 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);
item.style.display = isVisible ? "block" : "none";
return isVisible;
});
!term || item.textContent.toLocaleLowerCase().includes(term)
item.style.display = isVisible ? 'block' : 'none'
return isVisible
})
selectedIndex = 0;
selectedIndex = 0
if (displayedItems.includes(selectedItem)) {
selectedIndex = displayedItems.findIndex(
(d) => d === selectedItem
);
)
}
itemCount = displayedItems.length;
itemCount = displayedItems.length
updateSelected();
updateSelected()
// If we have an event then we can try and position the list under the source
if (options.event) {
let top = options.event.clientY - 10;
let top = options.event.clientY - 10
const bodyRect = document.body.getBoundingClientRect();
const rootRect = this.root.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect()
const rootRect = this.root.getBoundingClientRect()
if (
bodyRect.height &&
top > bodyRect.height - rootRect.height - 10
) {
top = Math.max(0, bodyRect.height - rootRect.height - 10);
top = Math.max(0, bodyRect.height - rootRect.height - 10)
}
this.root.style.top = top + "px";
positionList();
this.root.style.top = top + 'px'
positionList()
}
});
})
requestAnimationFrame(() => {
// Focus the filter box when opening
filter.focus();
filter.focus()
positionList();
});
});
positionList()
})
})
}
return ctx;
};
return ctx
}
LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
},
};
LiteGraph.ContextMenu.prototype = ctxMenu.prototype
}
}
app.registerExtension(ext);
app.registerExtension(ext)

View File

@@ -1,4 +1,4 @@
import { app } from "../../scripts/app";
import { app } from '../../scripts/app'
// Allows for simple dynamic prompt replacement
// Inputs in the format {a|b} will have a random value of a or b chosen when the prompt is queued.
@@ -7,46 +7,46 @@ import { app } from "../../scripts/app";
* Strips C-style line and block comments from a string
*/
function stripComments(str) {
return str.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, "");
return str.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
}
app.registerExtension({
name: "Comfy.DynamicPrompts",
name: 'Comfy.DynamicPrompts',
nodeCreated(node) {
if (node.widgets) {
// Locate dynamic prompt text widgets
// Include any widgets with dynamicPrompts set to true, and customtext
const widgets = node.widgets.filter((n) => n.dynamicPrompts);
const widgets = node.widgets.filter((n) => n.dynamicPrompts)
for (const widget of widgets) {
// Override the serialization of the value to resolve dynamic prompts for all widgets supporting it in this node
widget.serializeValue = (workflowNode, widgetIndex) => {
let prompt = stripComments(widget.value);
let prompt = stripComments(widget.value)
while (
prompt.replace("\\{", "").includes("{") &&
prompt.replace("\\}", "").includes("}")
prompt.replace('\\{', '').includes('{') &&
prompt.replace('\\}', '').includes('}')
) {
const startIndex = prompt.replace("\\{", "00").indexOf("{");
const endIndex = prompt.replace("\\}", "00").indexOf("}");
const startIndex = prompt.replace('\\{', '00').indexOf('{')
const endIndex = prompt.replace('\\}', '00').indexOf('}')
const optionsString = prompt.substring(startIndex + 1, endIndex);
const options = optionsString.split("|");
const optionsString = prompt.substring(startIndex + 1, endIndex)
const options = optionsString.split('|')
const randomIndex = Math.floor(Math.random() * options.length);
const randomOption = options[randomIndex];
const randomIndex = Math.floor(Math.random() * options.length)
const randomOption = options[randomIndex]
prompt =
prompt.substring(0, startIndex) +
randomOption +
prompt.substring(endIndex + 1);
prompt.substring(endIndex + 1)
}
// Overwrite the value in the serialized workflow pnginfo
if (workflowNode?.widgets_values)
workflowNode.widgets_values[widgetIndex] = prompt;
workflowNode.widgets_values[widgetIndex] = prompt
return prompt;
};
return prompt
}
}
}
},
});
}
})

View File

@@ -1,161 +1,161 @@
import { app } from "../../scripts/app";
import { app } from '../../scripts/app'
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
app.registerExtension({
name: "Comfy.EditAttention",
name: 'Comfy.EditAttention',
init() {
const editAttentionDelta = app.ui.settings.addSetting({
id: "Comfy.EditAttention.Delta",
name: "Ctrl+up/down precision",
type: "slider",
id: 'Comfy.EditAttention.Delta',
name: 'Ctrl+up/down precision',
type: 'slider',
attrs: {
min: 0.01,
max: 0.5,
step: 0.01,
step: 0.01
},
defaultValue: 0.05,
});
defaultValue: 0.05
})
function incrementWeight(weight, delta) {
const floatWeight = parseFloat(weight);
if (isNaN(floatWeight)) return weight;
const newWeight = floatWeight + delta;
if (newWeight < 0) return "0";
return String(Number(newWeight.toFixed(10)));
const floatWeight = parseFloat(weight)
if (isNaN(floatWeight)) return weight
const newWeight = floatWeight + delta
if (newWeight < 0) return '0'
return String(Number(newWeight.toFixed(10)))
}
function findNearestEnclosure(text, cursorPos) {
let start = cursorPos,
end = cursorPos;
end = cursorPos
let openCount = 0,
closeCount = 0;
closeCount = 0
// Find opening parenthesis before cursor
while (start >= 0) {
start--;
if (text[start] === "(" && openCount === closeCount) break;
if (text[start] === "(") openCount++;
if (text[start] === ")") closeCount++;
start--
if (text[start] === '(' && openCount === closeCount) break
if (text[start] === '(') openCount++
if (text[start] === ')') closeCount++
}
if (start < 0) return false;
if (start < 0) return false
openCount = 0;
closeCount = 0;
openCount = 0
closeCount = 0
// Find closing parenthesis after cursor
while (end < text.length) {
if (text[end] === ")" && openCount === closeCount) break;
if (text[end] === "(") openCount++;
if (text[end] === ")") closeCount++;
end++;
if (text[end] === ')' && openCount === closeCount) break
if (text[end] === '(') openCount++
if (text[end] === ')') closeCount++
end++
}
if (end === text.length) return false;
if (end === text.length) return false
return { start: start + 1, end: end };
return { start: start + 1, end: end }
}
function addWeightToParentheses(text) {
const parenRegex = /^\((.*)\)$/;
const parenMatch = text.match(parenRegex);
const parenRegex = /^\((.*)\)$/
const parenMatch = text.match(parenRegex)
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/;
const floatMatch = text.match(floatRegex);
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
const floatMatch = text.match(floatRegex)
if (parenMatch && !floatMatch) {
return `(${parenMatch[1]}:1.0)`;
return `(${parenMatch[1]}:1.0)`
} else {
return text;
return text
}
}
function editAttention(event) {
const inputField = event.composedPath()[0];
const delta = parseFloat(editAttentionDelta.value);
const inputField = event.composedPath()[0]
const delta = parseFloat(editAttentionDelta.value)
if (inputField.tagName !== "TEXTAREA") return;
if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) return;
if (!event.ctrlKey && !event.metaKey) return;
if (inputField.tagName !== 'TEXTAREA') return
if (!(event.key === 'ArrowUp' || event.key === 'ArrowDown')) return
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault();
event.preventDefault()
let start = inputField.selectionStart;
let end = inputField.selectionEnd;
let selectedText = inputField.value.substring(start, end);
let start = inputField.selectionStart
let end = inputField.selectionEnd
let selectedText = inputField.value.substring(start, end)
// If there is no selection, attempt to find the nearest enclosure, or select the current word
if (!selectedText) {
const nearestEnclosure = findNearestEnclosure(inputField.value, start);
const nearestEnclosure = findNearestEnclosure(inputField.value, start)
if (nearestEnclosure) {
start = nearestEnclosure.start;
end = nearestEnclosure.end;
selectedText = inputField.value.substring(start, end);
start = nearestEnclosure.start
end = nearestEnclosure.end
selectedText = inputField.value.substring(start, end)
} else {
// Select the current word, find the start and end of the word
const delimiters = " .,\\/!?%^*;:{}=-_`~()\r\n\t";
const delimiters = ' .,\\/!?%^*;:{}=-_`~()\r\n\t'
while (
!delimiters.includes(inputField.value[start - 1]) &&
start > 0
) {
start--;
start--
}
while (
!delimiters.includes(inputField.value[end]) &&
end < inputField.value.length
) {
end++;
end++
}
selectedText = inputField.value.substring(start, end);
if (!selectedText) return;
selectedText = inputField.value.substring(start, end)
if (!selectedText) return
}
}
// If the selection ends with a space, remove it
if (selectedText[selectedText.length - 1] === " ") {
selectedText = selectedText.substring(0, selectedText.length - 1);
end -= 1;
if (selectedText[selectedText.length - 1] === ' ') {
selectedText = selectedText.substring(0, selectedText.length - 1)
end -= 1
}
// If there are parentheses left and right of the selection, select them
if (
inputField.value[start - 1] === "(" &&
inputField.value[end] === ")"
inputField.value[start - 1] === '(' &&
inputField.value[end] === ')'
) {
start -= 1;
end += 1;
selectedText = inputField.value.substring(start, end);
start -= 1
end += 1
selectedText = inputField.value.substring(start, end)
}
// If the selection is not enclosed in parentheses, add them
if (
selectedText[0] !== "(" ||
selectedText[selectedText.length - 1] !== ")"
selectedText[0] !== '(' ||
selectedText[selectedText.length - 1] !== ')'
) {
selectedText = `(${selectedText})`;
selectedText = `(${selectedText})`
}
// If the selection does not have a weight, add a weight of 1.0
selectedText = addWeightToParentheses(selectedText);
selectedText = addWeightToParentheses(selectedText)
// Increment the weight
const weightDelta = event.key === "ArrowUp" ? delta : -delta;
const weightDelta = event.key === 'ArrowUp' ? delta : -delta
const updatedText = selectedText.replace(
/\((.*):(\d+(?:\.\d+)?)\)/,
(match, text, weight) => {
weight = incrementWeight(weight, weightDelta);
weight = incrementWeight(weight, weightDelta)
if (weight == 1) {
return text;
return text
} else {
return `(${text}:${weight})`;
return `(${text}:${weight})`
}
}
);
)
inputField.setRangeText(updatedText, start, end, "select");
inputField.setRangeText(updatedText, start, end, 'select')
}
window.addEventListener("keydown", editAttention);
},
});
window.addEventListener('keydown', editAttention)
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,41 @@
import { $el, ComfyDialog } from "../../scripts/ui";
import { DraggableList } from "../../scripts/ui/draggableList";
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode";
import "./groupNodeManage.css";
import { app, type ComfyApp } from "../../scripts/app";
import { $el, ComfyDialog } from '../../scripts/ui'
import { DraggableList } from '../../scripts/ui/draggableList'
import { GroupNodeConfig, GroupNodeHandler } from './groupNode'
import './groupNodeManage.css'
import { app, type ComfyApp } from '../../scripts/app'
import {
LiteGraph,
type LGraphNode,
type LGraphNodeConstructor,
} from "@comfyorg/litegraph";
type LGraphNodeConstructor
} from '@comfyorg/litegraph'
const ORDER: symbol = Symbol();
const ORDER: symbol = Symbol()
function merge(target, source) {
if (typeof target === "object" && typeof source === "object") {
if (typeof target === 'object' && typeof source === 'object') {
for (const key in source) {
const sv = source[key];
if (typeof sv === "object") {
let tv = target[key];
if (!tv) tv = target[key] = {};
merge(tv, source[key]);
const sv = source[key]
if (typeof sv === 'object') {
let tv = target[key]
if (!tv) tv = target[key] = {}
merge(tv, source[key])
} else {
target[key] = sv;
target[key] = sv
}
}
}
return target;
return target
}
export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
tabs: Record<
"Inputs" | "Outputs" | "Widgets",
'Inputs' | 'Outputs' | 'Widgets',
{ tab: HTMLAnchorElement; page: HTMLElement }
>;
selectedNodeIndex: number | null | undefined;
selectedTab: keyof ManageGroupDialog["tabs"] = "Inputs";
selectedGroup: string | undefined;
>
selectedNodeIndex: number | null | undefined
selectedTab: keyof ManageGroupDialog['tabs'] = 'Inputs'
selectedGroup: string | undefined
modifications: Record<
string,
Record<
@@ -45,474 +45,472 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
{ name?: string | undefined; visible?: boolean | undefined }
>
>
> = {};
nodeItems: any[];
app: ComfyApp;
groupNodeType: LGraphNodeConstructor<LGraphNode>;
groupNodeDef: any;
groupData: any;
> = {}
nodeItems: any[]
app: ComfyApp
groupNodeType: LGraphNodeConstructor<LGraphNode>
groupNodeDef: any
groupData: any
innerNodesList: HTMLUListElement;
widgetsPage: HTMLElement;
inputsPage: HTMLElement;
outputsPage: HTMLElement;
draggable: any;
innerNodesList: HTMLUListElement
widgetsPage: HTMLElement
inputsPage: HTMLElement
outputsPage: HTMLElement
draggable: any
get selectedNodeInnerIndex() {
return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex;
return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex
}
constructor(app) {
super();
this.app = app;
this.element = $el("dialog.comfy-group-manage", {
parent: document.body,
}) as HTMLDialogElement;
super()
this.app = app
this.element = $el('dialog.comfy-group-manage', {
parent: document.body
}) as HTMLDialogElement
}
changeTab(tab) {
this.tabs[this.selectedTab].tab.classList.remove("active");
this.tabs[this.selectedTab].page.classList.remove("active");
this.tabs[tab].tab.classList.add("active");
this.tabs[tab].page.classList.add("active");
this.selectedTab = tab;
this.tabs[this.selectedTab].tab.classList.remove('active')
this.tabs[this.selectedTab].page.classList.remove('active')
this.tabs[tab].tab.classList.add('active')
this.tabs[tab].page.classList.add('active')
this.selectedTab = tab
}
changeNode(index, force?) {
if (!force && this.selectedNodeIndex === index) return;
if (!force && this.selectedNodeIndex === index) return
if (this.selectedNodeIndex != null) {
this.nodeItems[this.selectedNodeIndex].classList.remove("selected");
this.nodeItems[this.selectedNodeIndex].classList.remove('selected')
}
this.nodeItems[index].classList.add("selected");
this.selectedNodeIndex = index;
this.nodeItems[index].classList.add('selected')
this.selectedNodeIndex = index
if (!this.buildInputsPage() && this.selectedTab === "Inputs") {
this.changeTab("Widgets");
if (!this.buildInputsPage() && this.selectedTab === 'Inputs') {
this.changeTab('Widgets')
}
if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") {
this.changeTab("Outputs");
if (!this.buildWidgetsPage() && this.selectedTab === 'Widgets') {
this.changeTab('Outputs')
}
if (!this.buildOutputsPage() && this.selectedTab === "Outputs") {
this.changeTab("Inputs");
if (!this.buildOutputsPage() && this.selectedTab === 'Outputs') {
this.changeTab('Inputs')
}
this.changeTab(this.selectedTab);
this.changeTab(this.selectedTab)
}
getGroupData() {
this.groupNodeType =
LiteGraph.registered_node_types["workflow/" + this.selectedGroup];
this.groupNodeDef = this.groupNodeType.nodeData;
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType);
LiteGraph.registered_node_types['workflow/' + this.selectedGroup]
this.groupNodeDef = this.groupNodeType.nodeData
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)
}
changeGroup(group, reset = true) {
this.selectedGroup = group;
this.getGroupData();
this.selectedGroup = group
this.getGroupData()
const nodes = this.groupData.nodeData.nodes;
const nodes = this.groupData.nodeData.nodes
this.nodeItems = nodes.map((n, i) =>
$el(
"li.draggable-item",
'li.draggable-item',
{
dataset: {
nodeindex: n.index + "",
nodeindex: n.index + ''
},
onclick: () => {
this.changeNode(i);
},
this.changeNode(i)
}
},
[
$el("span.drag-handle"),
$el('span.drag-handle'),
$el(
"div",
'div',
{
textContent: n.title ?? n.type,
textContent: n.title ?? n.type
},
n.title
? $el("span", {
textContent: n.type,
? $el('span', {
textContent: n.type
})
: []
),
)
]
)
);
)
this.innerNodesList.replaceChildren(...this.nodeItems);
this.innerNodesList.replaceChildren(...this.nodeItems)
if (reset) {
this.selectedNodeIndex = null;
this.changeNode(0);
this.selectedNodeIndex = null
this.changeNode(0)
} else {
const items = this.draggable.getAllItems();
let index = items.findIndex((item) =>
item.classList.contains("selected")
);
if (index === -1) index = this.selectedNodeIndex;
this.changeNode(index, true);
const items = this.draggable.getAllItems()
let index = items.findIndex((item) => item.classList.contains('selected'))
if (index === -1) index = this.selectedNodeIndex
this.changeNode(index, true)
}
const ordered = [...nodes];
this.draggable?.dispose();
this.draggable = new DraggableList(this.innerNodesList, "li");
const ordered = [...nodes]
this.draggable?.dispose()
this.draggable = new DraggableList(this.innerNodesList, 'li')
this.draggable.addEventListener(
"dragend",
'dragend',
({ detail: { oldPosition, newPosition } }) => {
if (oldPosition === newPosition) return;
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]);
if (oldPosition === newPosition) return
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0])
for (let i = 0; i < ordered.length; i++) {
this.storeModification({
nodeIndex: ordered[i].index,
section: ORDER,
prop: "order",
value: i,
});
prop: 'order',
value: i
})
}
}
);
)
}
storeModification(props: {
nodeIndex?: number;
section: symbol;
prop: string;
value: any;
nodeIndex?: number
section: symbol
prop: string
value: any
}) {
const { nodeIndex, section, prop, value } = props;
const groupMod = (this.modifications[this.selectedGroup] ??= {});
const nodesMod = (groupMod.nodes ??= {});
const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {});
const typeMod = (nodeMod[section] ??= {});
if (typeof value === "object") {
const objMod = (typeMod[prop] ??= {});
Object.assign(objMod, value);
const { nodeIndex, section, prop, value } = props
const groupMod = (this.modifications[this.selectedGroup] ??= {})
const nodesMod = (groupMod.nodes ??= {})
const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {})
const typeMod = (nodeMod[section] ??= {})
if (typeof value === 'object') {
const objMod = (typeMod[prop] ??= {})
Object.assign(objMod, value)
} else {
typeMod[prop] = value;
typeMod[prop] = value
}
}
getEditElement(section, prop, value, placeholder, checked, checkable = true) {
if (value === placeholder) value = "";
if (value === placeholder) value = ''
const mods =
this.modifications[this.selectedGroup]?.nodes?.[
this.selectedNodeInnerIndex
]?.[section]?.[prop];
]?.[section]?.[prop]
if (mods) {
if (mods.name != null) {
value = mods.name;
value = mods.name
}
if (mods.visible != null) {
checked = mods.visible;
checked = mods.visible
}
}
return $el("div", [
$el("input", {
return $el('div', [
$el('input', {
value,
placeholder,
type: "text",
type: 'text',
onchange: (e) => {
this.storeModification({
section,
prop,
value: { name: e.target.value },
});
},
value: { name: e.target.value }
})
}
}),
$el("label", { textContent: "Visible" }, [
$el("input", {
type: "checkbox",
$el('label', { textContent: 'Visible' }, [
$el('input', {
type: 'checkbox',
checked,
disabled: !checkable,
onchange: (e) => {
this.storeModification({
section,
prop,
value: { visible: !!e.target.checked },
});
},
}),
]),
]);
value: { visible: !!e.target.checked }
})
}
})
])
])
}
buildWidgetsPage() {
const widgets =
this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex];
const items = Object.keys(widgets ?? {});
const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.input;
this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]
const items = Object.keys(widgets ?? {})
const type = app.graph.extra.groupNodes[this.selectedGroup]
const config = type.config?.[this.selectedNodeInnerIndex]?.input
this.widgetsPage.replaceChildren(
...items.map((oldName) => {
return this.getEditElement(
"input",
'input',
oldName,
widgets[oldName],
oldName,
config?.[oldName]?.visible !== false
);
)
})
);
return !!items.length;
)
return !!items.length
}
buildInputsPage() {
const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex];
const items = Object.keys(inputs ?? {});
const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.input;
const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]
const items = Object.keys(inputs ?? {})
const type = app.graph.extra.groupNodes[this.selectedGroup]
const config = type.config?.[this.selectedNodeInnerIndex]?.input
this.inputsPage.replaceChildren(
...items
.map((oldName) => {
let value = inputs[oldName];
let value = inputs[oldName]
if (!value) {
return;
return
}
return this.getEditElement(
"input",
'input',
oldName,
value,
oldName,
config?.[oldName]?.visible !== false
);
)
})
.filter(Boolean)
);
return !!items.length;
)
return !!items.length
}
buildOutputsPage() {
const nodes = this.groupData.nodeData.nodes;
const nodes = this.groupData.nodeData.nodes
const innerNodeDef = this.groupData.getNodeDef(
nodes[this.selectedNodeInnerIndex]
);
const outputs = innerNodeDef?.output ?? [];
)
const outputs = innerNodeDef?.output ?? []
const groupOutputs =
this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex];
this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]
const type = app.graph.extra.groupNodes[this.selectedGroup];
const config = type.config?.[this.selectedNodeInnerIndex]?.output;
const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex];
const checkable = node.type !== "PrimitiveNode";
const type = app.graph.extra.groupNodes[this.selectedGroup]
const config = type.config?.[this.selectedNodeInnerIndex]?.output
const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]
const checkable = node.type !== 'PrimitiveNode'
this.outputsPage.replaceChildren(
...outputs
.map((type, slot) => {
const groupOutputIndex = groupOutputs?.[slot];
const oldName = innerNodeDef.output_name?.[slot] ?? type;
let value = config?.[slot]?.name;
const visible = config?.[slot]?.visible || groupOutputIndex != null;
const groupOutputIndex = groupOutputs?.[slot]
const oldName = innerNodeDef.output_name?.[slot] ?? type
let value = config?.[slot]?.name
const visible = config?.[slot]?.visible || groupOutputIndex != null
if (!value || value === oldName) {
value = "";
value = ''
}
return this.getEditElement(
"output",
'output',
slot,
value,
oldName,
visible,
checkable
);
)
})
.filter(Boolean)
);
return !!outputs.length;
)
return !!outputs.length
}
show(type?) {
const groupNodes = Object.keys(app.graph.extra?.groupNodes ?? {}).sort(
(a, b) => a.localeCompare(b)
);
)
this.innerNodesList = $el(
"ul.comfy-group-manage-list-items"
) as HTMLUListElement;
this.widgetsPage = $el("section.comfy-group-manage-node-page");
this.inputsPage = $el("section.comfy-group-manage-node-page");
this.outputsPage = $el("section.comfy-group-manage-node-page");
const pages = $el("div", [
'ul.comfy-group-manage-list-items'
) as HTMLUListElement
this.widgetsPage = $el('section.comfy-group-manage-node-page')
this.inputsPage = $el('section.comfy-group-manage-node-page')
this.outputsPage = $el('section.comfy-group-manage-node-page')
const pages = $el('div', [
this.widgetsPage,
this.inputsPage,
this.outputsPage,
]);
this.outputsPage
])
this.tabs = [
["Inputs", this.inputsPage],
["Widgets", this.widgetsPage],
["Outputs", this.outputsPage],
['Inputs', this.inputsPage],
['Widgets', this.widgetsPage],
['Outputs', this.outputsPage]
].reduce((p, [name, page]: [string, HTMLElement]) => {
p[name] = {
tab: $el("a", {
tab: $el('a', {
onclick: () => {
this.changeTab(name);
this.changeTab(name)
},
textContent: name,
textContent: name
}),
page,
};
return p;
}, {}) as any;
page
}
return p
}, {}) as any
const outer = $el("div.comfy-group-manage-outer", [
$el("header", [
$el("h2", "Group Nodes"),
const outer = $el('div.comfy-group-manage-outer', [
$el('header', [
$el('h2', 'Group Nodes'),
$el(
"select",
'select',
{
onchange: (e) => {
this.changeGroup(e.target.value);
},
this.changeGroup(e.target.value)
}
},
groupNodes.map((g) =>
$el("option", {
$el('option', {
textContent: g,
selected: "workflow/" + g === type,
value: g,
selected: 'workflow/' + g === type,
value: g
})
)
),
)
]),
$el("main", [
$el("section.comfy-group-manage-list", this.innerNodesList),
$el("section.comfy-group-manage-node", [
$el('main', [
$el('section.comfy-group-manage-list', this.innerNodesList),
$el('section.comfy-group-manage-node', [
$el(
"header",
'header',
Object.values(this.tabs).map((t) => t.tab)
),
pages,
]),
pages
])
]),
$el("footer", [
$el('footer', [
$el(
"button.comfy-btn",
'button.comfy-btn',
{
onclick: (e) => {
// @ts-ignore
const node = app.graph._nodes.find(
(n) => n.type === "workflow/" + this.selectedGroup
);
(n) => n.type === 'workflow/' + this.selectedGroup
)
if (node) {
alert(
"This group node is in use in the current workflow, please first remove these."
);
return;
'This group node is in use in the current workflow, please first remove these.'
)
return
}
if (
confirm(
`Are you sure you want to remove the node: "${this.selectedGroup}"`
)
) {
delete app.graph.extra.groupNodes[this.selectedGroup];
LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup);
delete app.graph.extra.groupNodes[this.selectedGroup]
LiteGraph.unregisterNodeType('workflow/' + this.selectedGroup)
}
this.show();
},
this.show()
}
},
"Delete Group Node"
'Delete Group Node'
),
$el(
"button.comfy-btn",
'button.comfy-btn',
{
onclick: async () => {
let nodesByType;
let recreateNodes = [];
const types = {};
let nodesByType
let recreateNodes = []
const types = {}
for (const g in this.modifications) {
const type = app.graph.extra.groupNodes[g];
let config = (type.config ??= {});
const type = app.graph.extra.groupNodes[g]
let config = (type.config ??= {})
let nodeMods = this.modifications[g]?.nodes;
let nodeMods = this.modifications[g]?.nodes
if (nodeMods) {
const keys = Object.keys(nodeMods);
const keys = Object.keys(nodeMods)
if (nodeMods[keys[0]][ORDER]) {
// If any node is reordered, they will all need sequencing
const orderedNodes = [];
const orderedMods = {};
const orderedConfig = {};
const orderedNodes = []
const orderedMods = {}
const orderedConfig = {}
for (const n of keys) {
const order = nodeMods[n][ORDER].order;
orderedNodes[order] = type.nodes[+n];
orderedMods[order] = nodeMods[n];
orderedNodes[order].index = order;
const order = nodeMods[n][ORDER].order
orderedNodes[order] = type.nodes[+n]
orderedMods[order] = nodeMods[n]
orderedNodes[order].index = order
}
// Rewrite links
for (const l of type.links) {
if (l[0] != null) l[0] = type.nodes[l[0]].index;
if (l[2] != null) l[2] = type.nodes[l[2]].index;
if (l[0] != null) l[0] = type.nodes[l[0]].index
if (l[2] != null) l[2] = type.nodes[l[2]].index
}
// Rewrite externals
if (type.external) {
for (const ext of type.external) {
ext[0] = type.nodes[ext[0]];
ext[0] = type.nodes[ext[0]]
}
}
// Rewrite modifications
for (const id of keys) {
if (config[id]) {
orderedConfig[type.nodes[id].index] = config[id];
orderedConfig[type.nodes[id].index] = config[id]
}
delete config[id];
delete config[id]
}
type.nodes = orderedNodes;
nodeMods = orderedMods;
type.config = config = orderedConfig;
type.nodes = orderedNodes
nodeMods = orderedMods
type.config = config = orderedConfig
}
merge(config, nodeMods);
merge(config, nodeMods)
}
types[g] = type;
types[g] = type
if (!nodesByType) {
// @ts-ignore
nodesByType = app.graph._nodes.reduce((p, n) => {
p[n.type] ??= [];
p[n.type].push(n);
return p;
}, {});
p[n.type] ??= []
p[n.type].push(n)
return p
}, {})
}
const nodes = nodesByType["workflow/" + g];
if (nodes) recreateNodes.push(...nodes);
const nodes = nodesByType['workflow/' + g]
if (nodes) recreateNodes.push(...nodes)
}
await GroupNodeConfig.registerFromWorkflow(types, {});
await GroupNodeConfig.registerFromWorkflow(types, {})
for (const node of recreateNodes) {
node.recreate();
node.recreate()
}
this.modifications = {};
this.app.graph.setDirtyCanvas(true, true);
this.changeGroup(this.selectedGroup, false);
},
this.modifications = {}
this.app.graph.setDirtyCanvas(true, true)
this.changeGroup(this.selectedGroup, false)
}
},
"Save"
'Save'
),
$el(
"button.comfy-btn",
'button.comfy-btn',
{ onclick: () => this.element.close() },
"Close"
),
]),
]);
'Close'
)
])
])
this.element.replaceChildren(outer);
this.element.replaceChildren(outer)
this.changeGroup(
type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]
);
this.element.showModal();
type ? groupNodes.find((g) => 'workflow/' + g === type) : groupNodes[0]
)
this.element.showModal()
this.element.addEventListener("close", () => {
this.draggable?.dispose();
});
this.element.addEventListener('close', () => {
this.draggable?.dispose()
})
}
}

View File

@@ -1,141 +1,141 @@
import { app } from "../../scripts/app";
import { LGraphCanvas, LiteGraph } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
function setNodeMode(node, mode) {
node.mode = mode;
node.graph.change();
node.mode = mode
node.graph.change()
}
function addNodesToGroup(group, nodes = []) {
var x1, y1, x2, y2;
var nx1, ny1, nx2, ny2;
var node;
var x1, y1, x2, y2
var nx1, ny1, nx2, ny2
var node
x1 = y1 = x2 = y2 = -1;
nx1 = ny1 = nx2 = ny2 = -1;
x1 = y1 = x2 = y2 = -1
nx1 = ny1 = nx2 = ny2 = -1
for (var n of [group._nodes, nodes]) {
for (var i in n) {
node = n[i];
node = n[i]
nx1 = node.pos[0];
ny1 = node.pos[1];
nx2 = node.pos[0] + node.size[0];
ny2 = node.pos[1] + node.size[1];
nx1 = node.pos[0]
ny1 = node.pos[1]
nx2 = node.pos[0] + node.size[0]
ny2 = node.pos[1] + node.size[1]
if (node.type != "Reroute") {
ny1 -= LiteGraph.NODE_TITLE_HEIGHT;
if (node.type != 'Reroute') {
ny1 -= LiteGraph.NODE_TITLE_HEIGHT
}
if (node.flags?.collapsed) {
ny2 = ny1 + LiteGraph.NODE_TITLE_HEIGHT;
ny2 = ny1 + LiteGraph.NODE_TITLE_HEIGHT
if (node?._collapsed_width) {
nx2 = nx1 + Math.round(node._collapsed_width);
nx2 = nx1 + Math.round(node._collapsed_width)
}
}
if (x1 == -1 || nx1 < x1) {
x1 = nx1;
x1 = nx1
}
if (y1 == -1 || ny1 < y1) {
y1 = ny1;
y1 = ny1
}
if (x2 == -1 || nx2 > x2) {
x2 = nx2;
x2 = nx2
}
if (y2 == -1 || ny2 > y2) {
y2 = ny2;
y2 = ny2
}
}
}
var padding = 10;
var padding = 10
y1 = y1 - Math.round(group.font_size * 1.4);
y1 = y1 - Math.round(group.font_size * 1.4)
group.pos = [x1 - padding, y1 - padding];
group.size = [x2 - x1 + padding * 2, y2 - y1 + padding * 2];
group.pos = [x1 - padding, y1 - padding]
group.size = [x2 - x1 + padding * 2, y2 - y1 + padding * 2]
}
app.registerExtension({
name: "Comfy.GroupOptions",
name: 'Comfy.GroupOptions',
setup() {
// @ts-ignore
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
const orig = LGraphCanvas.prototype.getCanvasMenuOptions
// graph_mouse
// @ts-ignore
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
const options = orig.apply(this, arguments)
const group = this.graph.getGroupOnPos(
this.graph_mouse[0],
this.graph_mouse[1]
);
)
if (!group) {
options.push({
content: "Add Group For Selected Nodes",
content: 'Add Group For Selected Nodes',
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
callback: () => {
// @ts-ignore
var group = new LiteGraph.LGraphGroup();
addNodesToGroup(group, this.selected_nodes);
app.canvas.graph.add(group);
this.graph.change();
},
});
var group = new LiteGraph.LGraphGroup()
addNodesToGroup(group, this.selected_nodes)
app.canvas.graph.add(group)
this.graph.change()
}
})
return options;
return options
}
// Group nodes aren't recomputed until the group is moved, this ensures the nodes are up-to-date
group.recomputeInsideNodes();
const nodesInGroup = group._nodes;
group.recomputeInsideNodes()
const nodesInGroup = group._nodes
options.push({
content: "Add Selected Nodes To Group",
content: 'Add Selected Nodes To Group',
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
callback: () => {
addNodesToGroup(group, this.selected_nodes);
this.graph.change();
},
});
addNodesToGroup(group, this.selected_nodes)
this.graph.change()
}
})
// No nodes in group, return default options
if (nodesInGroup.length === 0) {
return options;
return options
} else {
// Add a separator between the default options and the group options
options.push(null);
options.push(null)
}
// Check if all nodes are the same mode
let allNodesAreSameMode = true;
let allNodesAreSameMode = true
for (let i = 1; i < nodesInGroup.length; i++) {
if (nodesInGroup[i].mode !== nodesInGroup[0].mode) {
allNodesAreSameMode = false;
break;
allNodesAreSameMode = false
break
}
}
options.push({
content: "Fit Group To Nodes",
content: 'Fit Group To Nodes',
callback: () => {
addNodesToGroup(group);
this.graph.change();
},
});
addNodesToGroup(group)
this.graph.change()
}
})
options.push({
content: "Select Nodes",
content: 'Select Nodes',
callback: () => {
this.selectNodes(nodesInGroup);
this.graph.change();
this.canvas.focus();
},
});
this.selectNodes(nodesInGroup)
this.graph.change()
this.canvas.focus()
}
})
// Modes
// 0: Always
@@ -145,122 +145,122 @@ app.registerExtension({
// 4: Bypass
// If all nodes are the same mode, add a menu option to change the mode
if (allNodesAreSameMode) {
const mode = nodesInGroup[0].mode;
const mode = nodesInGroup[0].mode
switch (mode) {
case 0:
// All nodes are always, option to disable, and bypass
options.push({
content: "Set Group Nodes to Never",
content: 'Set Group Nodes to Never',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 2);
setNodeMode(node, 2)
}
},
});
}
})
options.push({
content: "Bypass Group Nodes",
content: 'Bypass Group Nodes',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 4);
setNodeMode(node, 4)
}
},
});
break;
}
})
break
case 2:
// All nodes are never, option to enable, and bypass
options.push({
content: "Set Group Nodes to Always",
content: 'Set Group Nodes to Always',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 0);
setNodeMode(node, 0)
}
},
});
}
})
options.push({
content: "Bypass Group Nodes",
content: 'Bypass Group Nodes',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 4);
setNodeMode(node, 4)
}
},
});
break;
}
})
break
case 4:
// All nodes are bypass, option to enable, and disable
options.push({
content: "Set Group Nodes to Always",
content: 'Set Group Nodes to Always',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 0);
setNodeMode(node, 0)
}
},
});
}
})
options.push({
content: "Set Group Nodes to Never",
content: 'Set Group Nodes to Never',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 2);
setNodeMode(node, 2)
}
},
});
break;
}
})
break
default:
// All nodes are On Trigger or On Event(Or other?), option to disable, set to always, or bypass
options.push({
content: "Set Group Nodes to Always",
content: 'Set Group Nodes to Always',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 0);
setNodeMode(node, 0)
}
},
});
}
})
options.push({
content: "Set Group Nodes to Never",
content: 'Set Group Nodes to Never',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 2);
setNodeMode(node, 2)
}
},
});
}
})
options.push({
content: "Bypass Group Nodes",
content: 'Bypass Group Nodes',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 4);
setNodeMode(node, 4)
}
},
});
break;
}
})
break
}
} else {
// Nodes are not all the same mode, add a menu option to change the mode to always, never, or bypass
options.push({
content: "Set Group Nodes to Always",
content: 'Set Group Nodes to Always',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 0);
setNodeMode(node, 0)
}
},
});
}
})
options.push({
content: "Set Group Nodes to Never",
content: 'Set Group Nodes to Never',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 2);
setNodeMode(node, 2)
}
},
});
}
})
options.push({
content: "Bypass Group Nodes",
content: 'Bypass Group Nodes',
callback: () => {
for (const node of nodesInGroup) {
setNodeMode(node, 4);
setNodeMode(node, 4)
}
},
});
}
})
}
return options;
};
},
});
return options
}
}
})

View File

@@ -1,23 +1,23 @@
import "./clipspace";
import "./colorPalette";
import "./contextMenuFilter";
import "./dynamicPrompts";
import "./editAttention";
import "./groupNode";
import "./groupNodeManage";
import "./groupOptions";
import "./invertMenuScrolling";
import "./keybinds";
import "./linkRenderMode";
import "./maskeditor";
import "./nodeTemplates";
import "./noteNode";
import "./rerouteNode";
import "./saveImageExtraOutput";
import "./simpleTouchSupport";
import "./slotDefaults";
import "./snapToGrid";
import "./uploadImage";
import "./webcamCapture";
import "./widgetInputs";
import "./uploadAudio";
import './clipspace'
import './colorPalette'
import './contextMenuFilter'
import './dynamicPrompts'
import './editAttention'
import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './invertMenuScrolling'
import './keybinds'
import './linkRenderMode'
import './maskeditor'
import './nodeTemplates'
import './noteNode'
import './rerouteNode'
import './saveImageExtraOutput'
import './simpleTouchSupport'
import './slotDefaults'
import './snapToGrid'
import './uploadImage'
import './webcamCapture'
import './widgetInputs'
import './uploadAudio'

View File

@@ -1,38 +1,38 @@
import { LiteGraph } from "@comfyorg/litegraph";
import { app } from "../../scripts/app";
import { LiteGraph } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
// Inverts the scrolling of context menus
const id = "Comfy.InvertMenuScrolling";
const id = 'Comfy.InvertMenuScrolling'
app.registerExtension({
name: id,
init() {
const ctxMenu = LiteGraph.ContextMenu;
const ctxMenu = LiteGraph.ContextMenu
const replace = () => {
// @ts-ignore
LiteGraph.ContextMenu = function (values, options) {
options = options || {};
options = options || {}
if (options.scroll_speed) {
options.scroll_speed *= -1;
options.scroll_speed *= -1
} else {
options.scroll_speed = -0.1;
options.scroll_speed = -0.1
}
return ctxMenu.call(this, values, options);
};
LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
};
return ctxMenu.call(this, values, options)
}
LiteGraph.ContextMenu.prototype = ctxMenu.prototype
}
app.ui.settings.addSetting({
id,
name: "Invert Menu Scrolling",
type: "boolean",
name: 'Invert Menu Scrolling',
type: 'boolean',
defaultValue: false,
onChange(value) {
if (value) {
replace();
replace()
} else {
LiteGraph.ContextMenu = ctxMenu;
LiteGraph.ContextMenu = ctxMenu
}
},
});
},
});
}
})
}
})

View File

@@ -1,73 +1,73 @@
import { app } from "../../scripts/app";
import { app } from '../../scripts/app'
app.registerExtension({
name: "Comfy.Keybinds",
name: 'Comfy.Keybinds',
init() {
const keybindListener = function (event) {
const modifierPressed = event.ctrlKey || event.metaKey;
const modifierPressed = event.ctrlKey || event.metaKey
// Queue prompt using ctrl or command + enter
if (modifierPressed && event.key === "Enter") {
app.queuePrompt(event.shiftKey ? -1 : 0).then();
return;
if (modifierPressed && event.key === 'Enter') {
app.queuePrompt(event.shiftKey ? -1 : 0).then()
return
}
const target = event.composedPath()[0];
if (["INPUT", "TEXTAREA"].includes(target.tagName)) {
return;
const target = event.composedPath()[0]
if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
return
}
const modifierKeyIdMap = {
s: "#comfy-save-button",
o: "#comfy-file-input",
Backspace: "#comfy-clear-button",
d: "#comfy-load-default-button",
};
s: '#comfy-save-button',
o: '#comfy-file-input',
Backspace: '#comfy-clear-button',
d: '#comfy-load-default-button'
}
const modifierKeybindId = modifierKeyIdMap[event.key];
const modifierKeybindId = modifierKeyIdMap[event.key]
if (modifierPressed && modifierKeybindId) {
event.preventDefault();
event.preventDefault()
const elem = document.querySelector(modifierKeybindId);
elem.click();
return;
const elem = document.querySelector(modifierKeybindId)
elem.click()
return
}
// Finished Handling all modifier keybinds, now handle the rest
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
return
}
// Close out of modals using escape
if (event.key === "Escape") {
const modals = document.querySelectorAll<HTMLElement>(".comfy-modal");
if (event.key === 'Escape') {
const modals = document.querySelectorAll<HTMLElement>('.comfy-modal')
const modal = Array.from(modals).find(
(modal) =>
window.getComputedStyle(modal).getPropertyValue("display") !==
"none"
);
window.getComputedStyle(modal).getPropertyValue('display') !==
'none'
)
if (modal) {
modal.style.display = "none";
modal.style.display = 'none'
}
[...document.querySelectorAll("dialog")].forEach((d) => {
d.close();
});
;[...document.querySelectorAll('dialog')].forEach((d) => {
d.close()
})
}
const keyIdMap = {
q: "#comfy-view-queue-button",
h: "#comfy-view-history-button",
r: "#comfy-refresh-button",
};
const buttonId = keyIdMap[event.key];
if (buttonId) {
const button = document.querySelector(buttonId);
button.click();
q: '#comfy-view-queue-button',
h: '#comfy-view-history-button',
r: '#comfy-refresh-button'
}
};
window.addEventListener("keydown", keybindListener, true);
},
});
const buttonId = keyIdMap[event.key]
if (buttonId) {
const button = document.querySelector(buttonId)
button.click()
}
}
window.addEventListener('keydown', keybindListener, true)
}
})

View File

@@ -1,26 +1,26 @@
import { app } from "../../scripts/app";
import { LiteGraph } from "@comfyorg/litegraph";
const id = "Comfy.LinkRenderMode";
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,
name: "Link Render Mode",
name: 'Link Render Mode',
defaultValue: 2,
type: "combo",
type: 'combo',
// @ts-ignore
options: [...LiteGraph.LINK_RENDER_MODES, "Hidden"].map((m, i) => ({
options: [...LiteGraph.LINK_RENDER_MODES, 'Hidden'].map((m, i) => ({
value: i,
text: m,
selected: i == app.canvas.links_render_mode,
selected: i == app.canvas.links_render_mode
})),
onChange(value) {
app.canvas.links_render_mode = +value;
app.graph.setDirtyCanvas(true);
},
});
},
};
app.canvas.links_render_mode = +value
app.graph.setDirtyCanvas(true)
}
})
}
}
app.registerExtension(ext);
app.registerExtension(ext)

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { app } from "../../scripts/app";
import { api } from "../../scripts/api";
import { ComfyDialog, $el } from "../../scripts/ui";
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode";
import { LGraphCanvas } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import { ComfyDialog, $el } from '../../scripts/ui'
import { GroupNodeConfig, GroupNodeHandler } from './groupNode'
import { LGraphCanvas } from '@comfyorg/litegraph'
// Adds the ability to save and add multiple nodes as a template
// To save:
@@ -21,391 +21,391 @@ import { LGraphCanvas } from "@comfyorg/litegraph";
// To rearrange:
// Open the manage dialog and Drag and drop elements using the "Name:" label as handle
const id = "Comfy.NodeTemplates";
const file = "comfy.templates.json";
const id = 'Comfy.NodeTemplates'
const file = 'comfy.templates.json'
class ManageTemplates extends ComfyDialog {
templates: any[];
draggedEl: HTMLElement | null;
saveVisualCue: number | null;
emptyImg: HTMLImageElement;
importInput: HTMLInputElement;
templates: any[]
draggedEl: HTMLElement | null
saveVisualCue: number | null
emptyImg: HTMLImageElement
importInput: HTMLInputElement
constructor() {
super();
super()
this.load().then((v) => {
this.templates = v;
});
this.templates = v
})
this.element.classList.add("comfy-manage-templates");
this.draggedEl = null;
this.saveVisualCue = null;
this.emptyImg = new Image();
this.element.classList.add('comfy-manage-templates')
this.draggedEl = null
this.saveVisualCue = null
this.emptyImg = new Image()
this.emptyImg.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs='
this.importInput = $el("input", {
type: "file",
accept: ".json",
this.importInput = $el('input', {
type: 'file',
accept: '.json',
multiple: true,
style: { display: "none" },
style: { display: 'none' },
parent: document.body,
onchange: () => this.importAll(),
}) as HTMLInputElement;
onchange: () => this.importAll()
}) as HTMLInputElement
}
createButtons() {
const btns = super.createButtons();
btns[0].textContent = "Close";
const btns = super.createButtons()
btns[0].textContent = 'Close'
btns[0].onclick = (e) => {
clearTimeout(this.saveVisualCue);
this.close();
};
clearTimeout(this.saveVisualCue)
this.close()
}
btns.unshift(
$el("button", {
type: "button",
textContent: "Export",
onclick: () => this.exportAll(),
$el('button', {
type: 'button',
textContent: 'Export',
onclick: () => this.exportAll()
})
);
)
btns.unshift(
$el("button", {
type: "button",
textContent: "Import",
$el('button', {
type: 'button',
textContent: 'Import',
onclick: () => {
this.importInput.click();
},
this.importInput.click()
}
})
);
return btns;
)
return btns
}
async load() {
let templates = [];
if (app.storageLocation === "server") {
let templates = []
if (app.storageLocation === 'server') {
if (app.isNewUserSession) {
// New user so migrate existing templates
const json = localStorage.getItem(id);
const json = localStorage.getItem(id)
if (json) {
templates = JSON.parse(json);
templates = JSON.parse(json)
}
await api.storeUserData(file, json, { stringify: false });
await api.storeUserData(file, json, { stringify: false })
} else {
const res = await api.getUserData(file);
const res = await api.getUserData(file)
if (res.status === 200) {
try {
templates = await res.json();
templates = await res.json()
} catch (error) {}
} else if (res.status !== 404) {
console.error(res.status + " " + res.statusText);
console.error(res.status + ' ' + res.statusText)
}
}
} else {
const json = localStorage.getItem(id);
const json = localStorage.getItem(id)
if (json) {
templates = JSON.parse(json);
templates = JSON.parse(json)
}
}
return templates ?? [];
return templates ?? []
}
async store() {
if (app.storageLocation === "server") {
const templates = JSON.stringify(this.templates, undefined, 4);
localStorage.setItem(id, templates); // Backwards compatibility
if (app.storageLocation === 'server') {
const templates = JSON.stringify(this.templates, undefined, 4)
localStorage.setItem(id, templates) // Backwards compatibility
try {
await api.storeUserData(file, templates, { stringify: false });
await api.storeUserData(file, templates, { stringify: false })
} catch (error) {
console.error(error);
alert(error.message);
console.error(error)
alert(error.message)
}
} else {
localStorage.setItem(id, JSON.stringify(this.templates));
localStorage.setItem(id, JSON.stringify(this.templates))
}
}
async importAll() {
for (const file of this.importInput.files) {
if (file.type === "application/json" || file.name.endsWith(".json")) {
const reader = new FileReader();
if (file.type === 'application/json' || file.name.endsWith('.json')) {
const reader = new FileReader()
reader.onload = async () => {
const importFile = JSON.parse(reader.result as string);
const importFile = JSON.parse(reader.result as string)
if (importFile?.templates) {
for (const template of importFile.templates) {
if (template?.name && template?.data) {
this.templates.push(template);
this.templates.push(template)
}
}
await this.store();
await this.store()
}
};
await reader.readAsText(file);
}
await reader.readAsText(file)
}
}
this.importInput.value = null;
this.importInput.value = null
this.close();
this.close()
}
exportAll() {
if (this.templates.length == 0) {
alert("No templates to export.");
return;
alert('No templates to export.')
return
}
const json = JSON.stringify({ templates: this.templates }, null, 2); // convert the data to a JSON string
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = $el("a", {
const json = JSON.stringify({ templates: this.templates }, null, 2) // convert the data to a JSON string
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: "node_templates.json",
style: { display: "none" },
parent: document.body,
});
a.click();
download: 'node_templates.json',
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
show() {
// Show list of template names + delete button
super.show(
$el(
"div",
'div',
{},
this.templates.flatMap((t, i) => {
let nameInput;
let nameInput
return [
$el(
"div",
'div',
{
dataset: { id: i.toString() },
className: "tempateManagerRow",
className: 'tempateManagerRow',
style: {
display: "grid",
gridTemplateColumns: "1fr auto",
border: "1px dashed transparent",
gap: "5px",
backgroundColor: "var(--comfy-menu-bg)",
display: 'grid',
gridTemplateColumns: '1fr auto',
border: '1px dashed transparent',
gap: '5px',
backgroundColor: 'var(--comfy-menu-bg)'
},
ondragstart: (e) => {
this.draggedEl = e.currentTarget;
e.currentTarget.style.opacity = "0.6";
e.currentTarget.style.border = "1px dashed yellow";
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(this.emptyImg, 0, 0);
this.draggedEl = e.currentTarget
e.currentTarget.style.opacity = '0.6'
e.currentTarget.style.border = '1px dashed yellow'
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setDragImage(this.emptyImg, 0, 0)
},
ondragend: (e) => {
e.target.style.opacity = "1";
e.currentTarget.style.border = "1px dashed transparent";
e.currentTarget.removeAttribute("draggable");
e.target.style.opacity = '1'
e.currentTarget.style.border = '1px dashed transparent'
e.currentTarget.removeAttribute('draggable')
// rearrange the elements
this.element
.querySelectorAll(".tempateManagerRow")
.querySelectorAll('.tempateManagerRow')
.forEach((el: HTMLElement, i) => {
var prev_i = Number.parseInt(el.dataset.id);
var prev_i = Number.parseInt(el.dataset.id)
if (el == this.draggedEl && prev_i != i) {
this.templates.splice(
i,
0,
this.templates.splice(prev_i, 1)[0]
);
)
}
el.dataset.id = i.toString();
});
this.store();
el.dataset.id = i.toString()
})
this.store()
},
ondragover: (e) => {
e.preventDefault();
if (e.currentTarget == this.draggedEl) return;
e.preventDefault()
if (e.currentTarget == this.draggedEl) return
let rect = e.currentTarget.getBoundingClientRect();
let rect = e.currentTarget.getBoundingClientRect()
if (e.clientY > rect.top + rect.height / 2) {
e.currentTarget.parentNode.insertBefore(
this.draggedEl,
e.currentTarget.nextSibling
);
)
} else {
e.currentTarget.parentNode.insertBefore(
this.draggedEl,
e.currentTarget
);
)
}
},
}
},
[
$el(
"label",
'label',
{
textContent: "Name: ",
textContent: 'Name: ',
style: {
cursor: "grab",
cursor: 'grab'
},
onmousedown: (e) => {
// enable dragging only from the label
if (e.target.localName == "label")
e.currentTarget.parentNode.draggable = "true";
},
if (e.target.localName == 'label')
e.currentTarget.parentNode.draggable = 'true'
}
},
[
$el("input", {
$el('input', {
value: t.name,
dataset: { name: t.name },
style: {
transitionProperty: "background-color",
transitionDuration: "0s",
transitionProperty: 'background-color',
transitionDuration: '0s'
},
onchange: (e) => {
clearTimeout(this.saveVisualCue);
var el = e.target;
var row = el.parentNode.parentNode;
clearTimeout(this.saveVisualCue)
var el = e.target
var row = el.parentNode.parentNode
this.templates[row.dataset.id].name =
el.value.trim() || "untitled";
this.store();
el.style.backgroundColor = "rgb(40, 95, 40)";
el.style.transitionDuration = "0s";
el.value.trim() || 'untitled'
this.store()
el.style.backgroundColor = 'rgb(40, 95, 40)'
el.style.transitionDuration = '0s'
// @ts-expect-error
// In browser env the return value is number.
this.saveVisualCue = setTimeout(function () {
el.style.transitionDuration = ".7s";
el.style.backgroundColor = "var(--comfy-input-bg)";
}, 15);
el.style.transitionDuration = '.7s'
el.style.backgroundColor = 'var(--comfy-input-bg)'
}, 15)
},
onkeypress: (e) => {
var el = e.target;
clearTimeout(this.saveVisualCue);
el.style.transitionDuration = "0s";
el.style.backgroundColor = "var(--comfy-input-bg)";
var el = e.target
clearTimeout(this.saveVisualCue)
el.style.transitionDuration = '0s'
el.style.backgroundColor = 'var(--comfy-input-bg)'
},
$: (el) => (nameInput = el),
}),
$: (el) => (nameInput = el)
})
]
),
$el("div", {}, [
$el("button", {
textContent: "Export",
$el('div', {}, [
$el('button', {
textContent: 'Export',
style: {
fontSize: "12px",
fontWeight: "normal",
fontSize: '12px',
fontWeight: 'normal'
},
onclick: (e) => {
const json = JSON.stringify({ templates: [t] }, null, 2); // convert the data to a JSON string
const json = JSON.stringify({ templates: [t] }, null, 2) // convert the data to a JSON string
const blob = new Blob([json], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = $el("a", {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: (nameInput.value || t.name) + ".json",
style: { display: "none" },
parent: document.body,
});
a.click();
download: (nameInput.value || t.name) + '.json',
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
},
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
}),
$el("button", {
textContent: "Delete",
$el('button', {
textContent: 'Delete',
style: {
fontSize: "12px",
color: "red",
fontWeight: "normal",
fontSize: '12px',
color: 'red',
fontWeight: 'normal'
},
onclick: (e) => {
const item = e.target.parentNode.parentNode;
item.parentNode.removeChild(item);
this.templates.splice(item.dataset.id * 1, 1);
this.store();
const item = e.target.parentNode.parentNode
item.parentNode.removeChild(item)
this.templates.splice(item.dataset.id * 1, 1)
this.store()
// update the rows index, setTimeout ensures that the list is updated
var that = this;
var that = this
setTimeout(function () {
that.element
.querySelectorAll(".tempateManagerRow")
.querySelectorAll('.tempateManagerRow')
.forEach((el: HTMLElement, i) => {
el.dataset.id = i.toString();
});
}, 0);
},
}),
]),
el.dataset.id = i.toString()
})
}, 0)
}
})
])
]
),
];
)
]
})
)
);
)
}
}
app.registerExtension({
name: id,
setup() {
const manage = new ManageTemplates();
const manage = new ManageTemplates()
const clipboardAction = async (cb) => {
// We use the clipboard functions but dont want to overwrite the current user clipboard
// Restore it after we've run our callback
const old = localStorage.getItem("litegrapheditor_clipboard");
await cb();
localStorage.setItem("litegrapheditor_clipboard", old);
};
const old = localStorage.getItem('litegrapheditor_clipboard')
await cb()
localStorage.setItem('litegrapheditor_clipboard', old)
}
// @ts-ignore
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
const orig = LGraphCanvas.prototype.getCanvasMenuOptions
// @ts-ignore
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
const options = orig.apply(this, arguments)
options.push(null);
options.push(null)
options.push({
content: `Save Selected as Template`,
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
callback: () => {
const name = prompt("Enter name");
if (!name?.trim()) return;
const name = prompt('Enter name')
if (!name?.trim()) return
clipboardAction(() => {
app.canvas.copyToClipboard();
let data = localStorage.getItem("litegrapheditor_clipboard");
data = JSON.parse(data);
const nodeIds = Object.keys(app.canvas.selected_nodes);
app.canvas.copyToClipboard()
let data = localStorage.getItem('litegrapheditor_clipboard')
data = JSON.parse(data)
const nodeIds = Object.keys(app.canvas.selected_nodes)
for (let i = 0; i < nodeIds.length; i++) {
const node = app.graph.getNodeById(Number.parseInt(nodeIds[i]));
const node = app.graph.getNodeById(Number.parseInt(nodeIds[i]))
// @ts-ignore
const nodeData = node?.constructor.nodeData;
const nodeData = node?.constructor.nodeData
let groupData = GroupNodeHandler.getGroupData(node);
let groupData = GroupNodeHandler.getGroupData(node)
if (groupData) {
groupData = groupData.nodeData;
groupData = groupData.nodeData
// @ts-ignore
if (!data.groupNodes) {
// @ts-ignore
data.groupNodes = {};
data.groupNodes = {}
}
// @ts-ignore
data.groupNodes[nodeData.name] = groupData;
data.groupNodes[nodeData.name] = groupData
// @ts-ignore
data.nodes[i].type = nodeData.name;
data.nodes[i].type = nodeData.name
}
}
manage.templates.push({
name,
data: JSON.stringify(data),
});
manage.store();
});
},
});
data: JSON.stringify(data)
})
manage.store()
})
}
})
// Map each template to a menu item
const subItems = manage.templates.map((t) => {
@@ -413,28 +413,28 @@ app.registerExtension({
content: t.name,
callback: () => {
clipboardAction(async () => {
const data = JSON.parse(t.data);
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {});
localStorage.setItem("litegrapheditor_clipboard", t.data);
app.canvas.pasteFromClipboard();
});
},
};
});
const data = JSON.parse(t.data)
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {})
localStorage.setItem('litegrapheditor_clipboard', t.data)
app.canvas.pasteFromClipboard()
})
}
}
})
subItems.push(null, {
content: "Manage",
callback: () => manage.show(),
});
content: 'Manage',
callback: () => manage.show()
})
options.push({
content: "Node Templates",
content: 'Node Templates',
submenu: {
options: subItems,
},
});
options: subItems
}
})
return options;
};
},
});
return options
}
}
})

View File

@@ -1,53 +1,53 @@
import { LiteGraph, LGraphCanvas } from "@comfyorg/litegraph";
import { app } from "../../scripts/app";
import { ComfyWidgets } from "../../scripts/widgets";
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
import { ComfyWidgets } from '../../scripts/widgets'
// Node that add notes to your project
app.registerExtension({
name: "Comfy.NoteNode",
name: 'Comfy.NoteNode',
registerCustomNodes() {
class NoteNode {
static category: string;
static category: string
color = LGraphCanvas.node_colors.yellow.color;
bgcolor = LGraphCanvas.node_colors.yellow.bgcolor;
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor;
properties: { text: string };
serialize_widgets: boolean;
isVirtualNode: boolean;
collapsable: boolean;
title_mode: number;
color = LGraphCanvas.node_colors.yellow.color
bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
properties: { text: string }
serialize_widgets: boolean
isVirtualNode: boolean
collapsable: boolean
title_mode: number
constructor() {
if (!this.properties) {
this.properties = { text: "" };
this.properties = { text: '' }
}
ComfyWidgets.STRING(
// @ts-ignore
// Should we extends LGraphNode?
this,
"",
["", { default: this.properties.text, multiline: true }],
'',
['', { default: this.properties.text, multiline: true }],
app
);
)
this.serialize_widgets = true;
this.isVirtualNode = true;
this.serialize_widgets = true
this.isVirtualNode = true
}
}
// Load default visibility
LiteGraph.registerNodeType(
"Note",
'Note',
// @ts-ignore
Object.assign(NoteNode, {
title_mode: LiteGraph.NORMAL_TITLE,
title: "Note",
collapsable: true,
title: 'Note',
collapsable: true
})
);
)
NoteNode.category = "utils";
},
});
NoteNode.category = 'utils'
}
})

View File

@@ -1,35 +1,35 @@
import { app } from "../../scripts/app";
import { mergeIfValid, getWidgetConfig, setWidgetConfig } from "./widgetInputs";
import { LiteGraph, LGraphCanvas, LGraphNode } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { mergeIfValid, getWidgetConfig, setWidgetConfig } from './widgetInputs'
import { LiteGraph, LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
// Node that allows you to redirect connections for cleaner graphs
app.registerExtension({
name: "Comfy.RerouteNode",
name: 'Comfy.RerouteNode',
registerCustomNodes(app) {
interface RerouteNode extends LGraphNode {
__outputType?: string;
__outputType?: string
}
class RerouteNode {
static category: string | undefined;
static defaultVisibility = false;
static category: string | undefined
static defaultVisibility = false
constructor() {
if (!this.properties) {
this.properties = {};
this.properties = {}
}
this.properties.showOutputText = RerouteNode.defaultVisibility;
this.properties.horizontal = false;
this.properties.showOutputText = RerouteNode.defaultVisibility
this.properties.horizontal = false
this.addInput("", "*");
this.addOutput(this.properties.showOutputText ? "*" : "", "*");
this.addInput('', '*')
this.addOutput(this.properties.showOutputText ? '*' : '', '*')
this.onAfterGraphConfigured = function () {
requestAnimationFrame(() => {
this.onConnectionsChange(LiteGraph.INPUT, null, true, null);
});
};
this.onConnectionsChange(LiteGraph.INPUT, null, true, null)
})
}
this.onConnectionsChange = function (
type,
@@ -37,7 +37,7 @@ app.registerExtension({
connected,
link_info
) {
this.applyOrientation();
this.applyOrientation()
// Prevent multiple connections to different types when we have no input
if (connected && type === LiteGraph.OUTPUT) {
@@ -45,78 +45,78 @@ app.registerExtension({
const types = new Set(
this.outputs[0].links
.map((l) => app.graph.links[l].type)
.filter((t) => t !== "*")
);
.filter((t) => t !== '*')
)
if (types.size > 1) {
const linksToDisconnect = [];
const linksToDisconnect = []
for (let i = 0; i < this.outputs[0].links.length - 1; i++) {
const linkId = this.outputs[0].links[i];
const link = app.graph.links[linkId];
linksToDisconnect.push(link);
const linkId = this.outputs[0].links[i]
const link = app.graph.links[linkId]
linksToDisconnect.push(link)
}
for (const link of linksToDisconnect) {
const node = app.graph.getNodeById(link.target_id);
node.disconnectInput(link.target_slot);
const node = app.graph.getNodeById(link.target_id)
node.disconnectInput(link.target_slot)
}
}
}
// Find root input
let currentNode = this;
let updateNodes = [];
let inputType = null;
let inputNode = null;
let currentNode = this
let updateNodes = []
let inputType = null
let inputNode = null
while (currentNode) {
updateNodes.unshift(currentNode);
const linkId = currentNode.inputs[0].link;
updateNodes.unshift(currentNode)
const linkId = currentNode.inputs[0].link
if (linkId !== null) {
const link = app.graph.links[linkId];
if (!link) return;
const node = app.graph.getNodeById(link.origin_id);
const type = node.constructor.type;
if (type === "Reroute") {
const link = app.graph.links[linkId]
if (!link) return
const node = app.graph.getNodeById(link.origin_id)
const type = node.constructor.type
if (type === 'Reroute') {
if (node === this) {
// We've found a circle
currentNode.disconnectInput(link.target_slot);
currentNode = null;
currentNode.disconnectInput(link.target_slot)
currentNode = null
} else {
// Move the previous node
currentNode = node;
currentNode = node
}
} else {
// We've found the end
inputNode = currentNode;
inputType = node.outputs[link.origin_slot]?.type ?? null;
break;
inputNode = currentNode
inputType = node.outputs[link.origin_slot]?.type ?? null
break
}
} else {
// This path has no input node
currentNode = null;
break;
currentNode = null
break
}
}
// Find all outputs
const nodes = [this];
let outputType = null;
const nodes = [this]
let outputType = null
while (nodes.length) {
currentNode = nodes.pop();
currentNode = nodes.pop()
const outputs =
(currentNode.outputs ? currentNode.outputs[0].links : []) || [];
(currentNode.outputs ? currentNode.outputs[0].links : []) || []
if (outputs.length) {
for (const linkId of outputs) {
const link = app.graph.links[linkId];
const link = app.graph.links[linkId]
// When disconnecting sometimes the link is still registered
if (!link) continue;
if (!link) continue
const node = app.graph.getNodeById(link.target_id);
const type = node.constructor.type;
const node = app.graph.getNodeById(link.target_id)
const type = node.constructor.type
if (type === "Reroute") {
if (type === 'Reroute') {
// Follow reroute nodes
nodes.push(node);
updateNodes.push(node);
nodes.push(node)
updateNodes.push(node)
} else {
// We've found an output
const nodeOutType =
@@ -124,16 +124,16 @@ app.registerExtension({
node.inputs[link?.target_slot] &&
node.inputs[link.target_slot].type
? node.inputs[link.target_slot].type
: null;
: null
if (
inputType &&
inputType !== "*" &&
inputType !== '*' &&
nodeOutType !== inputType
) {
// The output doesnt match our input so disconnect it
node.disconnectInput(link.target_slot);
node.disconnectInput(link.target_slot)
} else {
outputType = nodeOutType;
outputType = nodeOutType
}
}
}
@@ -142,50 +142,50 @@ app.registerExtension({
}
}
const displayType = inputType || outputType || "*";
const color = LGraphCanvas.link_type_colors[displayType];
const displayType = inputType || outputType || '*'
const color = LGraphCanvas.link_type_colors[displayType]
let widgetConfig;
let targetWidget;
let widgetType;
let widgetConfig
let targetWidget
let widgetType
// Update the types of each node
for (const node of updateNodes) {
// If we dont have an input type we are always wildcard but we'll show the output type
// This lets you change the output link to a different type and all nodes will update
node.outputs[0].type = inputType || "*";
node.__outputType = displayType;
node.outputs[0].type = inputType || '*'
node.__outputType = displayType
node.outputs[0].name = node.properties.showOutputText
? displayType
: "";
node.size = node.computeSize();
node.applyOrientation();
: ''
node.size = node.computeSize()
node.applyOrientation()
for (const l of node.outputs[0].links || []) {
const link = app.graph.links[l];
const link = app.graph.links[l]
if (link) {
link.color = color;
link.color = color
if (app.configuringGraph) continue;
const targetNode = app.graph.getNodeById(link.target_id);
const targetInput = targetNode.inputs?.[link.target_slot];
if (app.configuringGraph) continue
const targetNode = app.graph.getNodeById(link.target_id)
const targetInput = targetNode.inputs?.[link.target_slot]
if (targetInput?.widget) {
const config = getWidgetConfig(targetInput);
const config = getWidgetConfig(targetInput)
if (!widgetConfig) {
widgetConfig = config[1] ?? {};
widgetType = config[0];
widgetConfig = config[1] ?? {}
widgetType = config[0]
}
if (!targetWidget) {
targetWidget = targetNode.widgets?.find(
(w) => w.name === targetInput.widget.name
);
)
}
const merged = mergeIfValid(targetInput, [
config[0],
widgetConfig,
]);
widgetConfig
])
if (merged.customConfig) {
widgetConfig = merged.customConfig;
widgetConfig = merged.customConfig
}
}
}
@@ -194,64 +194,64 @@ app.registerExtension({
for (const node of updateNodes) {
if (widgetConfig && outputType) {
node.inputs[0].widget = { name: "value" };
node.inputs[0].widget = { name: 'value' }
setWidgetConfig(
node.inputs[0],
[widgetType ?? displayType, widgetConfig],
targetWidget
);
)
} else {
setWidgetConfig(node.inputs[0], null);
setWidgetConfig(node.inputs[0], null)
}
}
if (inputNode) {
const link = app.graph.links[inputNode.inputs[0].link];
const link = app.graph.links[inputNode.inputs[0].link]
if (link) {
link.color = color;
link.color = color
}
}
};
}
this.clone = function () {
const cloned = RerouteNode.prototype.clone.apply(this);
cloned.removeOutput(0);
cloned.addOutput(this.properties.showOutputText ? "*" : "", "*");
cloned.size = cloned.computeSize();
return cloned;
};
const cloned = RerouteNode.prototype.clone.apply(this)
cloned.removeOutput(0)
cloned.addOutput(this.properties.showOutputText ? '*' : '', '*')
cloned.size = cloned.computeSize()
return cloned
}
// This node is purely frontend and does not impact the resulting prompt so should not be serialized
this.isVirtualNode = true;
this.isVirtualNode = true
}
getExtraMenuOptions(_, options) {
options.unshift(
{
content:
(this.properties.showOutputText ? "Hide" : "Show") + " Type",
(this.properties.showOutputText ? 'Hide' : 'Show') + ' Type',
callback: () => {
this.properties.showOutputText = !this.properties.showOutputText;
this.properties.showOutputText = !this.properties.showOutputText
if (this.properties.showOutputText) {
this.outputs[0].name =
this.__outputType || (this.outputs[0].type as string);
this.__outputType || (this.outputs[0].type as string)
} else {
this.outputs[0].name = "";
this.outputs[0].name = ''
}
this.size = this.computeSize();
this.applyOrientation();
app.graph.setDirtyCanvas(true, true);
},
this.size = this.computeSize()
this.applyOrientation()
app.graph.setDirtyCanvas(true, true)
}
},
{
content:
(RerouteNode.defaultVisibility ? "Hide" : "Show") +
" Type By Default",
(RerouteNode.defaultVisibility ? 'Hide' : 'Show') +
' Type By Default',
callback: () => {
RerouteNode.setDefaultTextVisibility(
!RerouteNode.defaultVisibility
);
},
)
}
},
{
// naming is inverted with respect to LiteGraphNode.horizontal
@@ -259,25 +259,25 @@ app.registerExtension({
// each slot in the inputs and outputs are layed out horizontally,
// which is the opposite of the visual orientation of the inputs and outputs as a node
content:
"Set " + (this.properties.horizontal ? "Horizontal" : "Vertical"),
'Set ' + (this.properties.horizontal ? 'Horizontal' : 'Vertical'),
callback: () => {
this.properties.horizontal = !this.properties.horizontal;
this.applyOrientation();
},
this.properties.horizontal = !this.properties.horizontal
this.applyOrientation()
}
}
);
)
}
applyOrientation() {
this.horizontal = this.properties.horizontal;
this.horizontal = this.properties.horizontal
if (this.horizontal) {
// we correct the input position, because LiteGraphNode.horizontal
// doesn't account for title presence
// which reroute nodes don't have
this.inputs[0].pos = [this.size[0] / 2, 0];
this.inputs[0].pos = [this.size[0] / 2, 0]
} else {
delete this.inputs[0].pos;
delete this.inputs[0].pos
}
app.graph.setDirtyCanvas(true, true);
app.graph.setDirtyCanvas(true, true)
}
computeSize(): [number, number] {
@@ -289,34 +289,34 @@ app.registerExtension({
40
)
: 75,
26,
];
26
]
}
static setDefaultTextVisibility(visible) {
RerouteNode.defaultVisibility = visible;
RerouteNode.defaultVisibility = visible
if (visible) {
localStorage["Comfy.RerouteNode.DefaultVisibility"] = "true";
localStorage['Comfy.RerouteNode.DefaultVisibility'] = 'true'
} else {
delete localStorage["Comfy.RerouteNode.DefaultVisibility"];
delete localStorage['Comfy.RerouteNode.DefaultVisibility']
}
}
}
// Load default visibility
RerouteNode.setDefaultTextVisibility(
!!localStorage["Comfy.RerouteNode.DefaultVisibility"]
);
!!localStorage['Comfy.RerouteNode.DefaultVisibility']
)
LiteGraph.registerNodeType(
"Reroute",
'Reroute',
Object.assign(RerouteNode, {
title_mode: LiteGraph.NO_TITLE,
title: "Reroute",
collapsable: false,
title: 'Reroute',
collapsable: false
})
);
)
RerouteNode.category = "utils";
},
});
RerouteNode.category = 'utils'
}
})

View File

@@ -1,43 +1,39 @@
import { app } from "../../scripts/app";
import { applyTextReplacements } from "../../scripts/utils";
import { app } from '../../scripts/app'
import { applyTextReplacements } from '../../scripts/utils'
// Use widget values and dates in output filenames
app.registerExtension({
name: "Comfy.SaveImageExtraOutput",
name: 'Comfy.SaveImageExtraOutput',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SaveImage") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
if (nodeData.name === 'SaveImage') {
const onNodeCreated = nodeType.prototype.onNodeCreated
// When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated
? onNodeCreated.apply(this, arguments)
: undefined;
: undefined
const widget = this.widgets.find((w) => w.name === "filename_prefix");
const widget = this.widgets.find((w) => w.name === 'filename_prefix')
widget.serializeValue = () => {
return applyTextReplacements(app, widget.value);
};
return applyTextReplacements(app, widget.value)
}
return r;
};
return r
}
} else {
// When any other node is created add a property to alias the node
const onNodeCreated = nodeType.prototype.onNodeCreated;
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated
? onNodeCreated.apply(this, arguments)
: undefined;
: undefined
if (!this.properties || !("Node name for S&R" in this.properties)) {
this.addProperty(
"Node name for S&R",
this.constructor.type,
"string"
);
if (!this.properties || !('Node name for S&R' in this.properties)) {
this.addProperty('Node name for S&R', this.constructor.type, 'string')
}
return r;
};
return r
}
}
},
});
}
})

View File

@@ -1,115 +1,115 @@
import { app } from "../../scripts/app";
import { LGraphCanvas, LiteGraph } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
let touchZooming;
let touchCount = 0;
let touchZooming
let touchCount = 0
app.registerExtension({
name: "Comfy.SimpleTouchSupport",
name: 'Comfy.SimpleTouchSupport',
setup() {
let zoomPos;
let touchTime;
let lastTouch;
let zoomPos
let touchTime
let lastTouch
function getMultiTouchPos(e) {
return Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
)
}
app.canvasEl.addEventListener(
"touchstart",
'touchstart',
(e) => {
touchCount++;
lastTouch = null;
touchCount++
lastTouch = null
if (e.touches?.length === 1) {
// Store start time for press+hold for context menu
touchTime = new Date();
lastTouch = e.touches[0];
touchTime = new Date()
lastTouch = e.touches[0]
} else {
touchTime = null;
touchTime = null
if (e.touches?.length === 2) {
// Store center pos for zoom
zoomPos = getMultiTouchPos(e);
app.canvas.pointer_is_down = false;
zoomPos = getMultiTouchPos(e)
app.canvas.pointer_is_down = false
}
}
},
true
);
)
app.canvasEl.addEventListener("touchend", (e: TouchEvent) => {
touchZooming = false;
touchCount = e.touches?.length ?? touchCount - 1;
app.canvasEl.addEventListener('touchend', (e: TouchEvent) => {
touchZooming = false
touchCount = e.touches?.length ?? touchCount - 1
if (touchTime && !e.touches?.length) {
if (new Date().getTime() - touchTime > 600) {
try {
// hack to get litegraph to use this event
e.constructor = CustomEvent;
e.constructor = CustomEvent
} catch (error) {}
// @ts-ignore
e.clientX = lastTouch.clientX;
e.clientX = lastTouch.clientX
// @ts-ignore
e.clientY = lastTouch.clientY;
e.clientY = lastTouch.clientY
app.canvas.pointer_is_down = true;
app.canvas.pointer_is_down = true
// @ts-ignore
app.canvas._mousedown_callback(e);
app.canvas._mousedown_callback(e)
}
touchTime = null;
touchTime = null
}
});
})
app.canvasEl.addEventListener(
"touchmove",
'touchmove',
(e) => {
touchTime = null;
touchTime = null
if (e.touches?.length === 2) {
app.canvas.pointer_is_down = false;
touchZooming = true;
app.canvas.pointer_is_down = false
touchZooming = true
// @ts-ignore
LiteGraph.closeAllContextMenus();
LiteGraph.closeAllContextMenus()
// @ts-ignore
app.canvas.search_box?.close();
const newZoomPos = getMultiTouchPos(e);
app.canvas.search_box?.close()
const newZoomPos = getMultiTouchPos(e)
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2
let scale = app.canvas.ds.scale;
const diff = zoomPos - newZoomPos;
let scale = app.canvas.ds.scale
const diff = zoomPos - newZoomPos
if (diff > 0.5) {
scale *= 1 / 1.07;
scale *= 1 / 1.07
} else if (diff < -0.5) {
scale *= 1.07;
scale *= 1.07
}
app.canvas.ds.changeScale(scale, [midX, midY]);
app.canvas.setDirty(true, true);
zoomPos = newZoomPos;
app.canvas.ds.changeScale(scale, [midX, midY])
app.canvas.setDirty(true, true)
zoomPos = newZoomPos
}
},
true
);
},
});
)
}
})
// @ts-ignore
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
const processMouseDown = LGraphCanvas.prototype.processMouseDown
// @ts-ignore
LGraphCanvas.prototype.processMouseDown = function (e) {
if (touchZooming || touchCount) {
return;
return
}
return processMouseDown.apply(this, arguments);
};
return processMouseDown.apply(this, arguments)
}
// @ts-ignore
const processMouseMove = LGraphCanvas.prototype.processMouseMove;
const processMouseMove = LGraphCanvas.prototype.processMouseMove
// @ts-ignore
LGraphCanvas.prototype.processMouseMove = function (e) {
if (touchZooming || touchCount > 1) {
return;
return
}
return processMouseMove.apply(this, arguments);
};
return processMouseMove.apply(this, arguments)
}

View File

@@ -1,97 +1,97 @@
import { app } from "../../scripts/app";
import { ComfyWidgets } from "../../scripts/widgets";
import { LiteGraph } from "@comfyorg/litegraph";
import { app } from '../../scripts/app'
import { ComfyWidgets } from '../../scripts/widgets'
import { LiteGraph } from '@comfyorg/litegraph'
// Adds defaults for quickly adding nodes with middle click on the input/output
app.registerExtension({
name: "Comfy.SlotDefaults",
name: 'Comfy.SlotDefaults',
suggestionsNumber: null,
init() {
LiteGraph.search_filter_enabled = true;
LiteGraph.middle_click_slot_add_default_node = true;
LiteGraph.search_filter_enabled = true
LiteGraph.middle_click_slot_add_default_node = true
this.suggestionsNumber = app.ui.settings.addSetting({
id: "Comfy.NodeSuggestions.number",
name: "Number of nodes suggestions",
type: "slider",
id: 'Comfy.NodeSuggestions.number',
name: 'Number of nodes suggestions',
type: 'slider',
attrs: {
min: 1,
max: 100,
step: 1,
step: 1
},
defaultValue: 5,
onChange: (newVal, oldVal) => {
this.setDefaults(newVal);
},
});
this.setDefaults(newVal)
}
})
},
slot_types_default_out: {},
slot_types_default_in: {},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
var nodeId = nodeData.name;
var inputs = [];
inputs = nodeData["input"]["required"]; //only show required inputs to reduce the mess also not logical to create node with optional inputs
var nodeId = nodeData.name
var inputs = []
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;
var input = inputs[inputKey]
if (typeof input[0] !== 'string') continue
var type = input[0];
var type = input[0]
if (type in ComfyWidgets) {
var customProperties = input[1];
if (!customProperties?.forceInput) continue; //ignore widgets that don't force input
var customProperties = input[1]
if (!customProperties?.forceInput) continue //ignore widgets that don't force input
}
if (!(type in this.slot_types_default_out)) {
this.slot_types_default_out[type] = ["Reroute"];
this.slot_types_default_out[type] = ['Reroute']
}
if (this.slot_types_default_out[type].includes(nodeId)) continue;
this.slot_types_default_out[type].push(nodeId);
if (this.slot_types_default_out[type].includes(nodeId)) continue
this.slot_types_default_out[type].push(nodeId)
// Input types have to be stored as lower case
// Store each node that can handle this input type
const lowerType = type.toLocaleLowerCase();
const lowerType = type.toLocaleLowerCase()
if (!(lowerType in LiteGraph.registered_slot_in_types)) {
LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] };
LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] }
}
LiteGraph.registered_slot_in_types[lowerType].nodes.push(
nodeType.comfyClass
);
)
}
var outputs = nodeData["output"];
var outputs = nodeData['output']
for (const key in outputs) {
var type = outputs[key] as string;
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 :'()
this.slot_types_default_in[type] = ['Reroute'] // ["Reroute", "Primitive"]; primitive doesn't always work :'()
}
this.slot_types_default_in[type].push(nodeId);
this.slot_types_default_in[type].push(nodeId)
// Store each node that can handle this output type
if (!(type in LiteGraph.registered_slot_out_types)) {
LiteGraph.registered_slot_out_types[type] = { nodes: [] };
LiteGraph.registered_slot_out_types[type] = { nodes: [] }
}
LiteGraph.registered_slot_out_types[type].nodes.push(nodeType.comfyClass);
LiteGraph.registered_slot_out_types[type].nodes.push(nodeType.comfyClass)
if (!LiteGraph.slot_types_out.includes(type)) {
LiteGraph.slot_types_out.push(type);
LiteGraph.slot_types_out.push(type)
}
}
var maxNum = this.suggestionsNumber.value;
this.setDefaults(maxNum);
var maxNum = this.suggestionsNumber.value
this.setDefaults(maxNum)
},
setDefaults(maxNum) {
LiteGraph.slot_types_default_out = {};
LiteGraph.slot_types_default_in = {};
LiteGraph.slot_types_default_out = {}
LiteGraph.slot_types_default_in = {}
for (const type in this.slot_types_default_out) {
LiteGraph.slot_types_default_out[type] = this.slot_types_default_out[
type
].slice(0, maxNum);
].slice(0, maxNum)
}
for (const type in this.slot_types_default_in) {
LiteGraph.slot_types_default_in[type] = this.slot_types_default_in[
type
].slice(0, maxNum);
].slice(0, maxNum)
}
},
});
}
})

View File

@@ -1,75 +1,73 @@
import { app } from "../../scripts/app";
import { app } from '../../scripts/app'
import {
LGraphCanvas,
LGraphNode,
LGraphGroup,
LiteGraph,
} from "@comfyorg/litegraph";
LiteGraph
} from '@comfyorg/litegraph'
// Shift + drag/resize to snap to grid
/** Rounds a Vector2 in-place to the current CANVAS_GRID_SIZE. */
function roundVectorToGrid(vec) {
vec[0] =
LiteGraph.CANVAS_GRID_SIZE *
Math.round(vec[0] / LiteGraph.CANVAS_GRID_SIZE);
LiteGraph.CANVAS_GRID_SIZE * Math.round(vec[0] / LiteGraph.CANVAS_GRID_SIZE)
vec[1] =
LiteGraph.CANVAS_GRID_SIZE *
Math.round(vec[1] / LiteGraph.CANVAS_GRID_SIZE);
return vec;
LiteGraph.CANVAS_GRID_SIZE * Math.round(vec[1] / LiteGraph.CANVAS_GRID_SIZE)
return vec
}
app.registerExtension({
name: "Comfy.SnapToGrid",
name: 'Comfy.SnapToGrid',
init() {
// Add setting to control grid size
app.ui.settings.addSetting({
id: "Comfy.SnapToGrid.GridSize",
name: "Grid Size",
type: "slider",
id: 'Comfy.SnapToGrid.GridSize',
name: 'Grid Size',
type: 'slider',
attrs: {
min: 1,
max: 500,
max: 500
},
tooltip:
"When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.",
'When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.',
defaultValue: LiteGraph.CANVAS_GRID_SIZE,
onChange(value) {
LiteGraph.CANVAS_GRID_SIZE = +value;
},
});
LiteGraph.CANVAS_GRID_SIZE = +value
}
})
// After moving a node, if the shift key is down align it to grid
const onNodeMoved = app.canvas.onNodeMoved;
const onNodeMoved = app.canvas.onNodeMoved
app.canvas.onNodeMoved = function (node) {
const r = onNodeMoved?.apply(this, arguments);
const r = onNodeMoved?.apply(this, arguments)
if (app.shiftDown) {
// Ensure all selected nodes are realigned
for (const id in this.selected_nodes) {
this.selected_nodes[id].alignToGrid();
this.selected_nodes[id].alignToGrid()
}
}
return r;
};
return r
}
// When a node is added, add a resize handler to it so we can fix align the size with the grid
const onNodeAdded = app.graph.onNodeAdded;
const onNodeAdded = app.graph.onNodeAdded
app.graph.onNodeAdded = function (node) {
const onResize = node.onResize;
const onResize = node.onResize
node.onResize = function () {
if (app.shiftDown) {
roundVectorToGrid(node.size);
roundVectorToGrid(node.size)
}
return onResize?.apply(this, arguments);
};
return onNodeAdded?.apply(this, arguments);
};
return onResize?.apply(this, arguments)
}
return onNodeAdded?.apply(this, arguments)
}
// Draw a preview of where the node will go if holding shift and the node is selected
// @ts-ignore
const origDrawNode = LGraphCanvas.prototype.drawNode;
const origDrawNode = LGraphCanvas.prototype.drawNode
// @ts-ignore
LGraphCanvas.prototype.drawNode = function (node, ctx) {
if (
@@ -77,53 +75,53 @@ app.registerExtension({
this.node_dragged &&
node.id in this.selected_nodes
) {
const [x, y] = roundVectorToGrid([...node.pos]);
const shiftX = x - node.pos[0];
let shiftY = y - node.pos[1];
const [x, y] = roundVectorToGrid([...node.pos])
const shiftX = x - node.pos[0]
let shiftY = y - node.pos[1]
let w, h;
let w, h
if (node.flags.collapsed) {
// @ts-ignore
w = node._collapsed_width;
h = LiteGraph.NODE_TITLE_HEIGHT;
shiftY -= LiteGraph.NODE_TITLE_HEIGHT;
w = node._collapsed_width
h = LiteGraph.NODE_TITLE_HEIGHT
shiftY -= LiteGraph.NODE_TITLE_HEIGHT
} else {
w = node.size[0];
h = node.size[1];
w = node.size[0]
h = node.size[1]
// @ts-ignore
let titleMode = node.constructor.title_mode;
let titleMode = node.constructor.title_mode
if (
titleMode !== LiteGraph.TRANSPARENT_TITLE &&
titleMode !== LiteGraph.NO_TITLE
) {
h += LiteGraph.NODE_TITLE_HEIGHT;
shiftY -= LiteGraph.NODE_TITLE_HEIGHT;
h += LiteGraph.NODE_TITLE_HEIGHT
shiftY -= LiteGraph.NODE_TITLE_HEIGHT
}
}
const f = ctx.fillStyle;
ctx.fillStyle = "rgba(100, 100, 100, 0.5)";
ctx.fillRect(shiftX, shiftY, w, h);
ctx.fillStyle = f;
const f = ctx.fillStyle
ctx.fillStyle = 'rgba(100, 100, 100, 0.5)'
ctx.fillRect(shiftX, shiftY, w, h)
ctx.fillStyle = f
}
return origDrawNode.apply(this, arguments);
};
return origDrawNode.apply(this, arguments)
}
/**
* The currently moving, selected group only. Set after the `selected_group` has actually started
* moving.
*/
let selectedAndMovingGroup: LGraphGroup | null = null;
let selectedAndMovingGroup: LGraphGroup | null = null
/**
* Handles moving a group; tracking when a group has been moved (to show the ghost in `drawGroups`
* below) as well as handle the last move call from LiteGraph's `processMouseUp`.
*/
// @ts-ignore
const groupMove = LGraphGroup.prototype.move;
const groupMove = LGraphGroup.prototype.move
// @ts-ignore
LGraphGroup.prototype.move = function (deltax, deltay, ignore_nodes) {
const v = groupMove.apply(this, arguments);
const v = groupMove.apply(this, arguments)
// When we've started moving, set `selectedAndMovingGroup` as LiteGraph sets `selected_group`
// too eagerly and we don't want to behave like we're moving until we get a delta.
if (
@@ -131,7 +129,7 @@ app.registerExtension({
app.canvas.selected_group === this &&
(deltax || deltay)
) {
selectedAndMovingGroup = this;
selectedAndMovingGroup = this
}
// LiteGraph will call group.move both on mouse-move as well as mouse-up though we only want
@@ -141,15 +139,15 @@ app.registerExtension({
if (app.canvas.last_mouse_dragging === false && app.shiftDown) {
// After moving a group (while app.shiftDown), snap all the child nodes and, finally,
// align the group itself.
this.recomputeInsideNodes();
this.recomputeInsideNodes()
for (const node of this._nodes) {
node.alignToGrid();
node.alignToGrid()
}
// @ts-ignore
LGraphNode.prototype.alignToGrid.apply(this);
LGraphNode.prototype.alignToGrid.apply(this)
}
return v;
};
return v
}
/**
* Handles drawing a group when, snapping the size when one is actively being resized tracking and/or
@@ -157,50 +155,50 @@ app.registerExtension({
* both.
*/
// @ts-ignore
const drawGroups = LGraphCanvas.prototype.drawGroups;
const drawGroups = LGraphCanvas.prototype.drawGroups
// @ts-ignore
LGraphCanvas.prototype.drawGroups = function (canvas, ctx) {
if (this.selected_group && app.shiftDown) {
if (this.selected_group_resizing) {
// @ts-ignore
roundVectorToGrid(this.selected_group.size);
roundVectorToGrid(this.selected_group.size)
} else if (selectedAndMovingGroup) {
// @ts-ignore
const [x, y] = roundVectorToGrid([...selectedAndMovingGroup.pos]);
const f = ctx.fillStyle;
const s = ctx.strokeStyle;
ctx.fillStyle = "rgba(100, 100, 100, 0.33)";
ctx.strokeStyle = "rgba(100, 100, 100, 0.66)";
const [x, y] = roundVectorToGrid([...selectedAndMovingGroup.pos])
const f = ctx.fillStyle
const s = ctx.strokeStyle
ctx.fillStyle = 'rgba(100, 100, 100, 0.33)'
ctx.strokeStyle = 'rgba(100, 100, 100, 0.66)'
// @ts-ignore
ctx.rect(x, y, ...selectedAndMovingGroup.size);
ctx.fill();
ctx.stroke();
ctx.fillStyle = f;
ctx.strokeStyle = s;
ctx.rect(x, y, ...selectedAndMovingGroup.size)
ctx.fill()
ctx.stroke()
ctx.fillStyle = f
ctx.strokeStyle = s
}
} else if (!this.selected_group) {
selectedAndMovingGroup = null;
selectedAndMovingGroup = null
}
return drawGroups.apply(this, arguments);
};
return drawGroups.apply(this, arguments)
}
/** Handles adding a group in a snapping-enabled state. */
// @ts-ignore
const onGroupAdd = LGraphCanvas.onGroupAdd;
const onGroupAdd = LGraphCanvas.onGroupAdd
// @ts-ignore
LGraphCanvas.onGroupAdd = function () {
const v = onGroupAdd.apply(app.canvas, arguments);
const v = onGroupAdd.apply(app.canvas, arguments)
if (app.shiftDown) {
// @ts-ignore
const lastGroup = app.graph._groups[app.graph._groups.length - 1];
const lastGroup = app.graph._groups[app.graph._groups.length - 1]
if (lastGroup) {
// @ts-ignore
roundVectorToGrid(lastGroup.pos);
roundVectorToGrid(lastGroup.pos)
// @ts-ignore
roundVectorToGrid(lastGroup.size);
roundVectorToGrid(lastGroup.size)
}
}
return v;
};
},
});
return v
}
}
})

View File

@@ -1,36 +1,35 @@
import { app } from "../../scripts/app";
import { api } from "../../scripts/api";
import type { IWidget } from "@comfyorg/litegraph";
import type { DOMWidget } from "@/scripts/domWidget";
import { ComfyNodeDef } from "@/types/apiTypes";
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import type { IWidget } from '@comfyorg/litegraph'
import type { DOMWidget } from '@/scripts/domWidget'
import { ComfyNodeDef } from '@/types/apiTypes'
type FolderType = "input" | "output" | "temp";
type FolderType = 'input' | 'output' | 'temp'
function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf("/");
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
return ["", path];
return ['', path]
}
return [
path.substring(0, folder_separator),
path.substring(folder_separator + 1),
];
path.substring(folder_separator + 1)
]
}
function getResourceURL(
subfolder: string,
filename: string,
type: FolderType = "input"
type: FolderType = 'input'
): string {
const params = [
"filename=" + encodeURIComponent(filename),
"type=" + type,
"subfolder=" + subfolder,
app.getPreviewFormatParam().substring(1),
app.getRandParam().substring(1),
].join("&");
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`;
return `/view?${params}`
}
async function uploadFile(
@@ -42,109 +41,109 @@ async function uploadFile(
) {
try {
// Wrap file in formdata so it includes filename
const body = new FormData();
body.append("image", file);
if (pasted) body.append("subfolder", "pasted");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
const body = new FormData()
body.append('image', file)
if (pasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json();
const data = await resp.json()
// Add the file to the dropdown list and update the widget value
let path = data.name;
if (data.subfolder) path = data.subfolder + "/" + path;
let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path
if (!audioWidget.options.values.includes(path)) {
audioWidget.options.values.push(path);
audioWidget.options.values.push(path)
}
if (updateNode) {
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(path))
);
audioWidget.value = path;
)
audioWidget.value = path
}
} else {
alert(resp.status + " - " + resp.statusText);
alert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
alert(error);
alert(error)
}
}
// AudioWidget MUST be registered first, as AUDIOUPLOAD depends on AUDIO_UI to be
// present.
app.registerExtension({
name: "Comfy.AudioWidget",
name: 'Comfy.AudioWidget',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (
["LoadAudio", "SaveAudio", "PreviewAudio"].includes(nodeType.comfyClass)
['LoadAudio', 'SaveAudio', 'PreviewAudio'].includes(nodeType.comfyClass)
) {
nodeData.input.required.audioUI = ["AUDIO_UI"];
nodeData.input.required.audioUI = ['AUDIO_UI']
}
},
getCustomWidgets() {
return {
AUDIO_UI(node, inputName: string) {
const audio = document.createElement("audio");
audio.controls = true;
audio.classList.add("comfy-audio");
audio.setAttribute("name", "media");
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement> = node.addDOMWidget(
inputName,
/* name=*/ "audioUI",
/* name=*/ 'audioUI',
audio
);
)
// @ts-ignore
// TODO: Sort out the DOMWidget type.
audioUIWidget.serialize = false;
audioUIWidget.serialize = false
const isOutputNode = node.constructor.nodeData.output_node;
const isOutputNode = node.constructor.nodeData.output_node
if (isOutputNode) {
// Hide the audio widget when there is no audio initially.
audioUIWidget.element.classList.add("empty-audio-widget");
audioUIWidget.element.classList.add('empty-audio-widget')
// Populate the audio widget UI on node execution.
const onExecuted = node.onExecuted;
const onExecuted = node.onExecuted
node.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
const audios = message.audio;
if (!audios) return;
const audio = audios[0];
onExecuted?.apply(this, arguments)
const audios = message.audio
if (!audios) return
const audio = audios[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
);
audioUIWidget.element.classList.remove("empty-audio-widget");
};
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
return { widget: audioUIWidget };
},
};
},
onNodeOutputsUpdated(nodeOutputs: Record<number, any>) {
for (const [nodeId, output] of Object.entries(nodeOutputs)) {
const node = app.graph.getNodeById(Number.parseInt(nodeId));
if ("audio" in output) {
const audioUIWidget = node.widgets.find(
(w) => w.name === "audioUI"
) as unknown as DOMWidget<HTMLAudioElement>;
const audio = output.audio[0];
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
);
audioUIWidget.element.classList.remove("empty-audio-widget");
return { widget: audioUIWidget }
}
}
},
});
onNodeOutputsUpdated(nodeOutputs: Record<number, any>) {
for (const [nodeId, output] of Object.entries(nodeOutputs)) {
const node = app.graph.getNodeById(Number.parseInt(nodeId))
if ('audio' in output) {
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement>
const audio = output.audio[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
}
})
app.registerExtension({
name: "Comfy.UploadAudio",
name: 'Comfy.UploadAudio',
async beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef) {
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
nodeData.input.required.upload = ["AUDIOUPLOAD"];
nodeData.input.required.upload = ['AUDIOUPLOAD']
}
},
getCustomWidgets() {
@@ -152,46 +151,55 @@ app.registerExtension({
AUDIOUPLOAD(node, inputName: string) {
// The widget that allows user to select file.
const audioWidget: IWidget = node.widgets.find(
(w: IWidget) => w.name === "audio"
);
(w: IWidget) => w.name === 'audio'
)
const audioUIWidget: DOMWidget<HTMLAudioElement> = node.widgets.find(
(w: IWidget) => w.name === "audioUI"
);
(w: IWidget) => w.name === 'audioUI'
)
const onAudioWidgetUpdate = () => {
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value))
);
};
)
}
// Initially load default audio file to audioUIWidget.
if (audioWidget.value) {
onAudioWidgetUpdate();
onAudioWidgetUpdate()
}
audioWidget.callback = onAudioWidgetUpdate;
audioWidget.callback = onAudioWidgetUpdate
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "audio/*";
fileInput.style.display = "none";
// Load saved audio file widget values if restoring from workflow
const onGraphConfigured = node.onGraphConfigured
node.onGraphConfigured = function () {
onGraphConfigured?.apply(this, arguments)
if (audioWidget.value) {
onAudioWidgetUpdate()
}
}
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'audio/*'
fileInput.style.display = 'none'
fileInput.onchange = () => {
if (fileInput.files.length) {
uploadFile(audioWidget, audioUIWidget, fileInput.files[0], true);
uploadFile(audioWidget, audioUIWidget, fileInput.files[0], true)
}
};
}
// The widget to pop up the upload dialog.
const uploadWidget = node.addWidget(
"button",
'button',
inputName,
/* value=*/ "",
/* value=*/ '',
() => {
fileInput.click();
fileInput.click()
}
);
uploadWidget.label = "choose file to upload";
uploadWidget.serialize = false;
)
uploadWidget.label = 'choose file to upload'
uploadWidget.serialize = false
return { widget: uploadWidget };
},
};
},
});
return { widget: uploadWidget }
}
}
}
})

View File

@@ -1,13 +1,13 @@
import { app } from "../../scripts/app";
import { ComfyNodeDef } from "@/types/apiTypes";
import { app } from '../../scripts/app'
import { ComfyNodeDef } from '@/types/apiTypes'
// Adds an upload button to the nodes
app.registerExtension({
name: "Comfy.UploadImage",
name: 'Comfy.UploadImage',
async beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef, app) {
if (nodeData?.input?.required?.image?.[1]?.image_upload === true) {
nodeData.input.required.upload = ["IMAGEUPLOAD"];
nodeData.input.required.upload = ['IMAGEUPLOAD']
}
},
});
}
})

View File

@@ -1,140 +1,140 @@
import { app } from "../../scripts/app";
import { api } from "../../scripts/api";
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
const WEBCAM_READY = Symbol();
const WEBCAM_READY = Symbol()
app.registerExtension({
name: "Comfy.WebcamCapture",
name: 'Comfy.WebcamCapture',
getCustomWidgets(app) {
return {
WEBCAM(node, inputName) {
let res;
node[WEBCAM_READY] = new Promise((resolve) => (res = resolve));
let res
node[WEBCAM_READY] = new Promise((resolve) => (res = resolve))
const container = document.createElement("div");
container.style.background = "rgba(0,0,0,0.25)";
container.style.textAlign = "center";
const container = document.createElement('div')
container.style.background = 'rgba(0,0,0,0.25)'
container.style.textAlign = 'center'
const video = document.createElement("video");
video.style.height = video.style.width = "100%";
const video = document.createElement('video')
video.style.height = video.style.width = '100%'
const loadVideo = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
container.replaceChildren(video);
audio: false
})
container.replaceChildren(video)
setTimeout(() => res(video), 500); // Fallback as loadedmetadata doesnt fire sometimes?
video.addEventListener("loadedmetadata", () => res(video), false);
video.srcObject = stream;
video.play();
setTimeout(() => res(video), 500) // Fallback as loadedmetadata doesnt fire sometimes?
video.addEventListener('loadedmetadata', () => res(video), false)
video.srcObject = stream
video.play()
} catch (error) {
const label = document.createElement("div");
label.style.color = "red";
label.style.overflow = "auto";
label.style.maxHeight = "100%";
label.style.whiteSpace = "pre-wrap";
const label = document.createElement('div')
label.style.color = 'red'
label.style.overflow = 'auto'
label.style.maxHeight = '100%'
label.style.whiteSpace = 'pre-wrap'
if (window.isSecureContext) {
label.textContent =
"Unable to load webcam, please ensure access is granted:\n" +
error.message;
'Unable to load webcam, please ensure access is granted:\n' +
error.message
} else {
label.textContent =
"Unable to load webcam. A secure context is required, if you are not accessing ComfyUI on localhost (127.0.0.1) you will have to enable TLS (https)\n\n" +
error.message;
'Unable to load webcam. A secure context is required, if you are not accessing ComfyUI on localhost (127.0.0.1) you will have to enable TLS (https)\n\n' +
error.message
}
container.replaceChildren(label);
container.replaceChildren(label)
}
};
}
loadVideo();
loadVideo()
return { widget: node.addDOMWidget(inputName, "WEBCAM", container) };
},
};
return { widget: node.addDOMWidget(inputName, 'WEBCAM', container) }
}
}
},
nodeCreated(node) {
if ((node.type, node.constructor.comfyClass !== "WebcamCapture")) return;
if ((node.type, node.constructor.comfyClass !== 'WebcamCapture')) return
let video;
const camera = node.widgets.find((w) => w.name === "image");
const w = node.widgets.find((w) => w.name === "width");
const h = node.widgets.find((w) => w.name === "height");
let video
const camera = node.widgets.find((w) => w.name === 'image')
const w = node.widgets.find((w) => w.name === 'width')
const h = node.widgets.find((w) => w.name === 'height')
const captureOnQueue = node.widgets.find(
(w) => w.name === "capture_on_queue"
);
(w) => w.name === 'capture_on_queue'
)
const canvas = document.createElement("canvas");
const canvas = document.createElement('canvas')
const capture = () => {
canvas.width = w.value;
canvas.height = h.value;
const ctx = canvas.getContext("2d");
ctx.drawImage(video, 0, 0, w.value, h.value);
const data = canvas.toDataURL("image/png");
canvas.width = w.value
canvas.height = h.value
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0, w.value, h.value)
const data = canvas.toDataURL('image/png')
const img = new Image();
const img = new Image()
img.onload = () => {
node.imgs = [img];
app.graph.setDirtyCanvas(true);
node.imgs = [img]
app.graph.setDirtyCanvas(true)
requestAnimationFrame(() => {
node.setSizeForImage?.();
});
};
img.src = data;
};
node.setSizeForImage?.()
})
}
img.src = data
}
const btn = node.addWidget(
"button",
"waiting for camera...",
"capture",
'button',
'waiting for camera...',
'capture',
capture
);
btn.disabled = true;
btn.serializeValue = () => undefined;
)
btn.disabled = true
btn.serializeValue = () => undefined
camera.serializeValue = async () => {
if (captureOnQueue.value) {
capture();
capture()
} else if (!node.imgs?.length) {
const err = `No webcam image captured`;
alert(err);
throw new Error(err);
const err = `No webcam image captured`
alert(err)
throw new Error(err)
}
// Upload image to temp storage
const blob = await new Promise<Blob>((r) => canvas.toBlob(r));
const name = `${+new Date()}.png`;
const file = new File([blob], name);
const body = new FormData();
body.append("image", file);
body.append("subfolder", "webcam");
body.append("type", "temp");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
const blob = await new Promise<Blob>((r) => canvas.toBlob(r))
const name = `${+new Date()}.png`
const file = new File([blob], name)
const body = new FormData()
body.append('image', file)
body.append('subfolder', 'webcam')
body.append('type', 'temp')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}`;
alert(err);
throw new Error(err);
const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}`
alert(err)
throw new Error(err)
}
return `webcam/${name} [temp]`;
};
return `webcam/${name} [temp]`
}
node[WEBCAM_READY].then((v) => {
video = v;
video = v
// If width isnt specified then use video output resolution
if (!w.value) {
w.value = video.videoWidth || 640;
h.value = video.videoHeight || 480;
w.value = video.videoWidth || 640
h.value = video.videoHeight || 480
}
btn.disabled = false;
btn.label = "capture";
});
},
});
btn.disabled = false
btn.label = 'capture'
})
}
})

File diff suppressed because it is too large Load Diff

29
src/i18n.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
sideToolBar: {
settings: 'Settings',
themeToggle: 'Toggle Theme',
queue: 'Queue',
nodeLibrary: 'Node Library'
}
},
zh: {
sideToolBar: {
settings: '设置',
themeToggle: '主题切换',
queue: '队列',
nodeLibrary: '节点库'
}
}
// TODO: Add more languages
}
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages
})

View File

@@ -1,28 +1,47 @@
import { createApp } from "vue";
import PrimeVue from "primevue/config";
import Aura from "@primevue/themes/aura";
import "primeicons/primeicons.css";
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import { definePreset } from '@primevue/themes'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import 'primeicons/primeicons.css'
import App from "./App.vue";
import { app as comfyApp } from "@/scripts/app";
import App from './App.vue'
import { app as comfyApp } from '@/scripts/app'
import { createPinia } from 'pinia'
import { i18n } from './i18n'
const app = createApp(App);
const ComfyUIPreset = definePreset(Aura, {
semantic: {
// @ts-ignore
primary: Aura.primitive.blue
}
})
const app = createApp(App)
const pinia = createPinia()
app.directive('tooltip', Tooltip)
app
.use(PrimeVue, {
theme: {
preset: Aura,
preset: ComfyUIPreset,
options: {
prefix: "p",
prefix: 'p',
cssLayer: false,
// This is a workaround for the issue with the dark mode selector
// https://github.com/primefaces/primevue/issues/5515
darkModeSelector: ".dark-theme, :root:has(.dark-theme)",
},
},
darkModeSelector: '.dark-theme, :root:has(.dark-theme)'
}
}
})
.mount("#vue-app");
.use(ConfirmationService)
.use(ToastService)
.use(pinia)
.use(i18n)
.mount('#vue-app')
comfyApp.setup().then(() => {
window["app"] = comfyApp;
window["graph"] = comfyApp.graph;
});
window['app'] = comfyApp
window['graph'] = comfyApp.graph
})

View File

@@ -1,63 +1,64 @@
import { ComfyWorkflowJSON } from "@/types/comfyWorkflow";
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import {
HistoryTaskItem,
PendingTaskItem,
RunningTaskItem,
ComfyNodeDef,
} from "@/types/apiTypes";
validateComfyNodeDef
} from '@/types/apiTypes'
interface QueuePromptRequestBody {
client_id: string;
client_id: string
// Mapping from node id to node info + input values
// TODO: Type this.
prompt: Record<number, any>;
prompt: Record<number, any>
extra_data: {
extra_pnginfo: {
workflow: ComfyWorkflowJSON;
};
};
front?: boolean;
number?: number;
workflow: ComfyWorkflowJSON
}
}
front?: boolean
number?: number
}
class ComfyApi extends EventTarget {
#registered = new Set();
api_host: string;
api_base: string;
initialClientId: string;
user: string;
socket?: WebSocket;
clientId?: string;
#registered = new Set()
api_host: string
api_base: string
initialClientId: string
user: string
socket?: WebSocket
clientId?: string
constructor() {
super();
this.api_host = location.host;
this.api_base = location.pathname.split("/").slice(0, -1).join("/");
this.initialClientId = sessionStorage.getItem("clientId");
super()
this.api_host = location.host
this.api_base = location.pathname.split('/').slice(0, -1).join('/')
this.initialClientId = sessionStorage.getItem('clientId')
}
apiURL(route: string): string {
return this.api_base + "/api" + route;
return this.api_base + '/api' + route
}
fileURL(route: string): string {
return this.api_base + route;
return this.api_base + route
}
fetchApi(route, options?) {
if (!options) {
options = {};
options = {}
}
if (!options.headers) {
options.headers = {};
options.headers = {}
}
options.headers["Comfy-User"] = this.user;
return fetch(this.apiURL(route), options);
options.headers['Comfy-User'] = this.user
return fetch(this.apiURL(route), options)
}
addEventListener(type, callback, options?) {
super.addEventListener(type, callback, options);
this.#registered.add(type);
super.addEventListener(type, callback, options)
this.#registered.add(type)
}
/**
@@ -66,13 +67,13 @@ class ComfyApi extends EventTarget {
#pollQueue() {
setInterval(async () => {
try {
const resp = await this.fetchApi("/prompt");
const status = await resp.json();
this.dispatchEvent(new CustomEvent("status", { detail: status }));
const resp = await this.fetchApi('/prompt')
const status = await resp.json()
this.dispatchEvent(new CustomEvent('status', { detail: status }))
} catch (error) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
this.dispatchEvent(new CustomEvent('status', { detail: null }))
}
}, 1000);
}, 1000)
}
/**
@@ -81,139 +82,144 @@ class ComfyApi extends EventTarget {
*/
#createSocket(isReconnect?) {
if (this.socket) {
return;
return
}
let opened = false;
let existingSession = window.name;
let opened = false
let existingSession = window.name
if (existingSession) {
existingSession = "?clientId=" + existingSession;
existingSession = '?clientId=' + existingSession
}
this.socket = new WebSocket(
`ws${window.location.protocol === "https:" ? "s" : ""}://${this.api_host}${this.api_base}/ws${existingSession}`
);
this.socket.binaryType = "arraybuffer";
`ws${window.location.protocol === 'https:' ? 's' : ''}://${this.api_host}${this.api_base}/ws${existingSession}`
)
this.socket.binaryType = 'arraybuffer'
this.socket.addEventListener("open", () => {
opened = true;
this.socket.addEventListener('open', () => {
opened = true
if (isReconnect) {
this.dispatchEvent(new CustomEvent("reconnected"));
this.dispatchEvent(new CustomEvent('reconnected'))
}
});
})
this.socket.addEventListener("error", () => {
if (this.socket) this.socket.close();
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
if (!isReconnect && !opened) {
this.#pollQueue();
this.#pollQueue()
}
});
})
this.socket.addEventListener("close", () => {
this.socket.addEventListener('close', () => {
setTimeout(() => {
this.socket = null;
this.#createSocket(true);
}, 300);
this.socket = null
this.#createSocket(true)
}, 300)
if (opened) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
this.dispatchEvent(new CustomEvent("reconnecting"));
this.dispatchEvent(new CustomEvent('status', { detail: null }))
this.dispatchEvent(new CustomEvent('reconnecting'))
}
});
})
this.socket.addEventListener("message", (event) => {
this.socket.addEventListener('message', (event) => {
try {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const eventType = view.getUint32(0);
const buffer = event.data.slice(4);
const view = new DataView(event.data)
const eventType = view.getUint32(0)
const buffer = event.data.slice(4)
switch (eventType) {
case 1:
const view2 = new DataView(event.data);
const imageType = view2.getUint32(0);
let imageMime;
const view2 = new DataView(event.data)
const imageType = view2.getUint32(0)
let imageMime
switch (imageType) {
case 1:
default:
imageMime = "image/jpeg";
break;
imageMime = 'image/jpeg'
break
case 2:
imageMime = "image/png";
imageMime = 'image/png'
}
const imageBlob = new Blob([buffer.slice(4)], {
type: imageMime,
});
type: imageMime
})
this.dispatchEvent(
new CustomEvent("b_preview", { detail: imageBlob })
);
break;
new CustomEvent('b_preview', { detail: imageBlob })
)
break
default:
throw new Error(
`Unknown binary websocket message of type ${eventType}`
);
)
}
} else {
const msg = JSON.parse(event.data);
const msg = JSON.parse(event.data)
switch (msg.type) {
case "status":
case 'status':
if (msg.data.sid) {
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.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 })
);
break;
case "progress":
new CustomEvent('status', { detail: msg.data.status })
)
break
case 'progress':
this.dispatchEvent(
new CustomEvent("progress", { detail: msg.data })
);
break;
case "executing":
new CustomEvent('progress', { detail: msg.data })
)
break
case 'executing':
this.dispatchEvent(
new CustomEvent("executing", { detail: msg.data.node })
);
break;
case "executed":
new CustomEvent('executing', { detail: msg.data.node })
)
break
case 'executed':
this.dispatchEvent(
new CustomEvent("executed", { detail: msg.data })
);
break;
case "execution_start":
new CustomEvent('executed', { detail: msg.data })
)
break
case 'execution_start':
this.dispatchEvent(
new CustomEvent("execution_start", { detail: msg.data })
);
break;
case "execution_error":
new CustomEvent('execution_start', { detail: msg.data })
)
break
case 'execution_success':
this.dispatchEvent(
new CustomEvent("execution_error", { detail: msg.data })
);
break;
case "execution_cached":
new CustomEvent('execution_success', { detail: msg.data })
)
break
case 'execution_error':
this.dispatchEvent(
new CustomEvent("execution_cached", { detail: msg.data })
);
break;
new CustomEvent('execution_error', { detail: msg.data })
)
break
case 'execution_cached':
this.dispatchEvent(
new CustomEvent('execution_cached', { detail: msg.data })
)
break
default:
if (this.#registered.has(msg.type)) {
this.dispatchEvent(
new CustomEvent(msg.type, { detail: msg.data })
);
)
} else {
throw new Error(`Unknown message type ${msg.type}`);
throw new Error(`Unknown message type ${msg.type}`)
}
}
}
} catch (error) {
console.warn("Unhandled message:", event.data, error);
console.warn('Unhandled message:', event.data, error)
}
});
})
}
/**
* Initialises sockets and realtime updates
*/
init() {
this.#createSocket();
this.#createSocket()
}
/**
@@ -221,8 +227,8 @@ class ComfyApi extends EventTarget {
* @returns An array of script urls to import
*/
async getExtensions() {
const resp = await this.fetchApi("/extensions", { cache: "no-store" });
return await resp.json();
const resp = await this.fetchApi('/extensions', { cache: 'no-store' })
return await resp.json()
}
/**
@@ -230,8 +236,8 @@ class ComfyApi extends EventTarget {
* @returns An array of script urls to import
*/
async getEmbeddings() {
const resp = await this.fetchApi("/embeddings", { cache: "no-store" });
return await resp.json();
const resp = await this.fetchApi('/embeddings', { cache: 'no-store' })
return await resp.json()
}
/**
@@ -239,8 +245,18 @@ class ComfyApi extends EventTarget {
* @returns The node definitions
*/
async getNodeDefs(): Promise<Record<string, ComfyNodeDef>> {
const resp = await this.fetchApi("/object_info", { cache: "no-store" });
return await resp.json();
const resp = await this.fetchApi('/object_info', { cache: 'no-store' })
const objectInfoUnsafe = await resp.json()
const objectInfo: Record<string, ComfyNodeDef> = {}
for (const key in objectInfoUnsafe) {
try {
objectInfo[key] = validateComfyNodeDef(objectInfoUnsafe[key])
} catch (e) {
console.warn('Ignore node definition: ', key)
console.error(e)
}
}
return objectInfo
}
/**
@@ -252,30 +268,30 @@ class ComfyApi extends EventTarget {
const body: QueuePromptRequestBody = {
client_id: this.clientId,
prompt: output,
extra_data: { extra_pnginfo: { workflow } },
};
if (number === -1) {
body.front = true;
} else if (number != 0) {
body.number = number;
extra_data: { extra_pnginfo: { workflow } }
}
const res = await this.fetchApi("/prompt", {
method: "POST",
if (number === -1) {
body.front = true
} else if (number != 0) {
body.number = number
}
const res = await this.fetchApi('/prompt', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
});
body: JSON.stringify(body)
})
if (res.status !== 200) {
throw {
response: await res.json(),
};
response: await res.json()
}
}
return await res.json();
return await res.json()
}
/**
@@ -284,10 +300,10 @@ class ComfyApi extends EventTarget {
* @returns The items of the specified type grouped by their status
*/
async getItems(type) {
if (type === "queue") {
return this.getQueue();
if (type === 'queue') {
return this.getQueue()
}
return this.getHistory();
return this.getHistory()
}
/**
@@ -295,23 +311,27 @@ class ComfyApi extends EventTarget {
* @returns The currently running and queued items
*/
async getQueue(): Promise<{
Running: RunningTaskItem[];
Pending: PendingTaskItem[];
Running: RunningTaskItem[]
Pending: PendingTaskItem[]
}> {
try {
const res = await this.fetchApi("/queue");
const data = await res.json();
const res = await this.fetchApi('/queue')
const data = await res.json()
return {
// Running action uses a different endpoint for cancelling
Running: data.queue_running.map((prompt) => ({
taskType: 'Running',
prompt,
remove: { name: "Cancel", cb: () => api.interrupt() },
remove: { name: 'Cancel', cb: () => api.interrupt() }
})),
Pending: data.queue_pending.map((prompt) => ({ prompt })),
};
Pending: data.queue_pending.map((prompt) => ({
taskType: 'Pending',
prompt
}))
}
} catch (error) {
console.error(error);
return { Running: [], Pending: [] };
console.error(error)
return { Running: [], Pending: [] }
}
}
@@ -323,11 +343,15 @@ class ComfyApi extends EventTarget {
max_items: number = 200
): Promise<{ History: HistoryTaskItem[] }> {
try {
const res = await this.fetchApi(`/history?max_items=${max_items}`);
return { History: Object.values(await res.json()) };
const res = await this.fetchApi(`/history?max_items=${max_items}`)
return {
History: Object.values(await res.json()).map(
(item: HistoryTaskItem) => ({ ...item, taskType: 'History' })
)
}
} catch (error) {
console.error(error);
return { History: [] };
console.error(error)
return { History: [] }
}
}
@@ -336,8 +360,8 @@ class ComfyApi extends EventTarget {
* @returns System stats such as python version, OS, per device info
*/
async getSystemStats() {
const res = await this.fetchApi("/system_stats");
return await res.json();
const res = await this.fetchApi('/system_stats')
return await res.json()
}
/**
@@ -347,15 +371,15 @@ class ComfyApi extends EventTarget {
*/
async #postItem(type, body) {
try {
await this.fetchApi("/" + type, {
method: "POST",
await this.fetchApi('/' + type, {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined,
});
body: body ? JSON.stringify(body) : undefined
})
} catch (error) {
console.error(error);
console.error(error)
}
}
@@ -365,7 +389,7 @@ class ComfyApi extends EventTarget {
* @param {number} id The id of the item to delete
*/
async deleteItem(type, id) {
await this.#postItem(type, { delete: [id] });
await this.#postItem(type, { delete: [id] })
}
/**
@@ -373,14 +397,14 @@ class ComfyApi extends EventTarget {
* @param {string} type The type of list to clear, queue or history
*/
async clearItems(type) {
await this.#postItem(type, { clear: true });
await this.#postItem(type, { clear: true })
}
/**
* Interrupts the execution of the running prompt
*/
async interrupt() {
await this.#postItem("interrupt", null);
await this.#postItem('interrupt', null)
}
/**
@@ -388,7 +412,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> }
*/
async getUserConfig() {
return (await this.fetchApi("/users")).json();
return (await this.fetchApi('/users')).json()
}
/**
@@ -397,13 +421,13 @@ class ComfyApi extends EventTarget {
* @returns The fetch response
*/
createUser(username) {
return this.fetchApi("/users", {
method: "POST",
return this.fetchApi('/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: JSON.stringify({ username }),
});
body: JSON.stringify({ username })
})
}
/**
@@ -411,7 +435,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<string, unknown> } A dictionary of id -> value
*/
async getSettings() {
return (await this.fetchApi("/settings")).json();
return (await this.fetchApi('/settings')).json()
}
/**
@@ -420,7 +444,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<unknown> } The setting value
*/
async getSetting(id) {
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json();
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json()
}
/**
@@ -430,9 +454,9 @@ class ComfyApi extends EventTarget {
*/
async storeSettings(settings) {
return this.fetchApi(`/settings`, {
method: "POST",
body: JSON.stringify(settings),
});
method: 'POST',
body: JSON.stringify(settings)
})
}
/**
@@ -443,9 +467,9 @@ class ComfyApi extends EventTarget {
*/
async storeSetting(id, value) {
return this.fetchApi(`/settings/${encodeURIComponent(id)}`, {
method: "POST",
body: JSON.stringify(value),
});
method: 'POST',
body: JSON.stringify(value)
})
}
/**
@@ -455,7 +479,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<unknown> } The fetch response object
*/
async getUserData(file, options?) {
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options);
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options)
}
/**
@@ -469,26 +493,26 @@ class ComfyApi extends EventTarget {
file: string,
data: unknown,
options: RequestInit & {
overwrite?: boolean;
stringify?: boolean;
throwOnError?: boolean;
overwrite?: boolean
stringify?: boolean
throwOnError?: boolean
} = { overwrite: true, stringify: true, throwOnError: true }
): Promise<Response> {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(file)}?overwrite=${options.overwrite}`,
{
method: "POST",
method: 'POST',
body: options?.stringify ? JSON.stringify(data) : data,
...options,
...options
}
);
)
if (resp.status !== 200 && options.throwOnError !== false) {
throw new Error(
`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`
);
)
}
return resp;
return resp
}
/**
@@ -497,12 +521,12 @@ class ComfyApi extends EventTarget {
*/
async deleteUserData(file) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
method: "DELETE",
});
method: 'DELETE'
})
if (resp.status !== 204) {
throw new Error(
`Error removing user data file '${file}': ${resp.status} ${resp.statusText}`
);
)
}
}
@@ -515,10 +539,10 @@ class ComfyApi extends EventTarget {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`,
{
method: "POST",
method: 'POST'
}
);
return resp;
)
return resp
}
/**
@@ -542,17 +566,17 @@ class ComfyApi extends EventTarget {
`/userdata?${new URLSearchParams({
recurse,
dir,
split,
split
})}`
);
if (resp.status === 404) return [];
)
if (resp.status === 404) return []
if (resp.status !== 200) {
throw new Error(
`Error getting user data list '${dir}': ${resp.status} ${resp.statusText}`
);
)
}
return resp.json();
return resp.json()
}
}
export const api = new ComfyApi();
export const api = new ComfyApi()

File diff suppressed because it is too large Load Diff

View File

@@ -1,278 +1,278 @@
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 { ComfyApp } from './app'
import { api } from './api'
import { clone } from './utils'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { ComfyWorkflow } from './workflows'
export class ChangeTracker {
static MAX_HISTORY = 50;
#app: ComfyApp;
undo = [];
redo = [];
activeState = null;
isOurLoad = false;
workflow: ComfyWorkflow | null;
static MAX_HISTORY = 50
#app: ComfyApp
undo = []
redo = []
activeState = null
isOurLoad = false
workflow: ComfyWorkflow | null
ds: { scale: number; offset: [number, number]; };
nodeOutputs: any;
ds: { scale: number; offset: [number, number] }
nodeOutputs: any
get app() {
return this.#app ?? this.workflow.manager.app;
return this.#app ?? this.workflow.manager.app
}
constructor(workflow: ComfyWorkflow) {
this.workflow = workflow;
this.workflow = workflow
}
#setApp(app) {
this.#app = app;
this.#app = app
}
store() {
this.ds = {
scale: this.app.canvas.ds.scale,
offset: [...this.app.canvas.ds.offset],
};
offset: [...this.app.canvas.ds.offset]
}
}
restore() {
if (this.ds) {
this.app.canvas.ds.scale = this.ds.scale;
this.app.canvas.ds.offset = this.ds.offset;
this.app.canvas.ds.scale = this.ds.scale
this.app.canvas.ds.offset = this.ds.offset
}
if (this.nodeOutputs) {
this.app.nodeOutputs = this.nodeOutputs;
this.app.nodeOutputs = this.nodeOutputs
}
}
checkState() {
if (!this.app.graph) return;
if (!this.app.graph) return
const currentState = this.app.graph.serialize();
const currentState = this.app.graph.serialize()
if (!this.activeState) {
this.activeState = clone(currentState);
return;
this.activeState = clone(currentState)
return
}
if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
this.undo.push(this.activeState);
this.undo.push(this.activeState)
if (this.undo.length > ChangeTracker.MAX_HISTORY) {
this.undo.shift();
this.undo.shift()
}
this.activeState = clone(currentState);
this.redo.length = 0;
this.workflow.unsaved = true;
this.activeState = clone(currentState)
this.redo.length = 0
this.workflow.unsaved = true
api.dispatchEvent(
new CustomEvent("graphChanged", { detail: this.activeState })
);
new CustomEvent('graphChanged', { detail: this.activeState })
)
}
}
async updateState(source, target) {
const prevState = source.pop();
const prevState = source.pop()
if (prevState) {
target.push(this.activeState);
this.isOurLoad = true;
await this.app.loadGraphData(prevState, false, false, this.workflow);
this.activeState = prevState;
target.push(this.activeState)
this.isOurLoad = true
await this.app.loadGraphData(prevState, false, false, this.workflow)
this.activeState = prevState
}
}
async undoRedo(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === "y") {
this.updateState(this.redo, this.undo);
return true;
} else if (e.key === "z") {
this.updateState(this.undo, this.redo);
return true;
if (e.key === 'y') {
this.updateState(this.redo, this.undo)
return true
} else if (e.key === 'z') {
this.updateState(this.undo, this.redo)
return true
}
}
}
static init(app: ComfyApp) {
const changeTracker = () =>
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker;
globalTracker.#setApp(app);
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
globalTracker.#setApp(app)
const loadGraphData = app.loadGraphData;
const loadGraphData = app.loadGraphData
app.loadGraphData = async function () {
const v = await loadGraphData.apply(this, arguments);
const ct = changeTracker();
const v = await loadGraphData.apply(this, arguments)
const ct = changeTracker()
if (ct.isOurLoad) {
ct.isOurLoad = false;
ct.isOurLoad = false
} else {
ct.checkState();
ct.checkState()
}
return v;
};
return v
}
let keyIgnored = false;
let keyIgnored = false
window.addEventListener(
"keydown",
'keydown',
(e) => {
requestAnimationFrame(async () => {
let activeEl;
let activeEl
// If we are auto queue in change mode then we do want to trigger on inputs
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
activeEl = document.activeElement;
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === 'instant') {
activeEl = document.activeElement
if (
activeEl?.tagName === "INPUT" ||
activeEl?.["type"] === "textarea"
activeEl?.tagName === 'INPUT' ||
activeEl?.['type'] === 'textarea'
) {
// Ignore events on inputs, they have their native history
return;
return
}
}
keyIgnored =
e.key === "Control" ||
e.key === "Shift" ||
e.key === "Alt" ||
e.key === "Meta";
if (keyIgnored) return;
e.key === 'Control' ||
e.key === 'Shift' ||
e.key === 'Alt' ||
e.key === 'Meta'
if (keyIgnored) return
// Check if this is a ctrl+z ctrl+y
if (await changeTracker().undoRedo(e)) return;
if (await changeTracker().undoRedo(e)) return
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(app, activeEl)) return;
changeTracker().checkState();
});
if (ChangeTracker.bindInput(app, activeEl)) return
changeTracker().checkState()
})
},
true
);
)
window.addEventListener("keyup", (e) => {
window.addEventListener('keyup', (e) => {
if (keyIgnored) {
keyIgnored = false;
changeTracker().checkState();
keyIgnored = false
changeTracker().checkState()
}
});
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener("mouseup", () => {
changeTracker().checkState();
});
window.addEventListener('mouseup', () => {
changeTracker().checkState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener("promptQueued", () => {
changeTracker().checkState();
});
api.addEventListener('promptQueued', () => {
changeTracker().checkState()
})
api.addEventListener("graphCleared", () => {
changeTracker().checkState();
});
api.addEventListener('graphCleared', () => {
changeTracker().checkState()
})
// Handle litegraph clicks
// @ts-ignore
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
const processMouseUp = LGraphCanvas.prototype.processMouseUp
// @ts-ignore
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, arguments);
changeTracker().checkState();
return v;
};
const v = processMouseUp.apply(this, arguments)
changeTracker().checkState()
return v
}
// @ts-ignore
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
const processMouseDown = LGraphCanvas.prototype.processMouseDown
// @ts-ignore
LGraphCanvas.prototype.processMouseDown = function (e) {
const v = processMouseDown.apply(this, arguments);
changeTracker().checkState();
return v;
};
const v = processMouseDown.apply(this, arguments)
changeTracker().checkState()
return v
}
// Handle litegraph context menu for COMBO widgets
const close = LiteGraph.ContextMenu.prototype.close;
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e) {
const v = close.apply(this, arguments);
changeTracker().checkState();
return v;
};
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;
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded
LiteGraph.LGraph.prototype.onNodeAdded = function () {
const v = onNodeAdded?.apply(this, arguments);
const v = onNodeAdded?.apply(this, arguments)
if (!app?.configuringGraph) {
const ct = changeTracker();
const ct = changeTracker()
if (!ct.isOurLoad) {
ct.checkState();
ct.checkState()
}
}
return v;
};
return v
}
// Store node outputs
api.addEventListener("executed", ({ detail }) => {
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id];
if (!prompt?.workflow) return;
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {});
const output = nodeOutputs[detail.node];
api.addEventListener('executed', ({ detail }) => {
const prompt = app.workflowManager.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 ?? {}) {
const v = output[k];
const v = output[k]
if (v instanceof Array) {
output[k] = v.concat(detail.output[k]);
output[k] = v.concat(detail.output[k])
} else {
output[k] = detail.output[k];
output[k] = detail.output[k]
}
}
} else {
nodeOutputs[detail.node] = detail.output;
nodeOutputs[detail.node] = detail.output
}
});
})
}
static bindInput(app, activeEl) {
if (
activeEl &&
activeEl.tagName !== "CANVAS" &&
activeEl.tagName !== "BODY"
activeEl.tagName !== 'CANVAS' &&
activeEl.tagName !== 'BODY'
) {
for (const evt of ["change", "input", "blur"]) {
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;
app.workflowManager.activeWorkflow.changeTracker.checkState()
activeEl.removeEventListener(evt, listener)
}
activeEl.addEventListener(evt, listener)
return true
}
}
}
}
static graphEqual(a, b, path = "") {
if (a === b) return true;
static graphEqual(a, b, path = '') {
if (a === b) return true
if (typeof a == "object" && a && typeof b == "object" && b) {
const keys = Object.getOwnPropertyNames(a);
if (typeof a == 'object' && a && typeof b == 'object' && b) {
const keys = Object.getOwnPropertyNames(a)
if (keys.length != Object.getOwnPropertyNames(b).length) {
return false;
return false
}
for (const key of keys) {
let av = a[key];
let bv = b[key];
if (!path && key === "nodes") {
let av = a[key]
let bv = b[key]
if (!path && key === 'nodes') {
// Nodes need to be sorted as the order changes when selecting nodes
av = [...av].sort((a, b) => a.id - b.id);
bv = [...bv].sort((a, b) => a.id - b.id);
} else if (path === "extra.ds") {
av = [...av].sort((a, b) => a.id - b.id)
bv = [...bv].sort((a, b) => a.id - b.id)
} else if (path === 'extra.ds') {
// Ignore view changes
continue;
continue
}
if (!ChangeTracker.graphEqual(av, bv, path + (path ? "." : "") + key)) {
return false;
if (!ChangeTracker.graphEqual(av, bv, path + (path ? '.' : '') + key)) {
return false
}
}
return true;
return true
}
return false;
return false
}
}
const globalTracker = new ChangeTracker({} as ComfyWorkflow);
const globalTracker = new ChangeTracker({} as ComfyWorkflow)

View File

@@ -1,4 +1,4 @@
import type { ComfyWorkflowJSON } from "@/types/comfyWorkflow";
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
export const defaultGraph: ComfyWorkflowJSON = {
last_node_id: 9,
@@ -6,132 +6,132 @@ export const defaultGraph: ComfyWorkflowJSON = {
nodes: [
{
id: 7,
type: "CLIPTextEncode",
type: 'CLIPTextEncode',
pos: [413, 389],
size: { 0: 425.27801513671875, 1: 180.6060791015625 },
size: [425.27801513671875, 180.6060791015625],
flags: {},
order: 3,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
inputs: [{ name: 'clip', type: 'CLIP', link: 5 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [6],
slot_index: 0,
},
slot_index: 0
}
],
properties: {},
widgets_values: ["text, watermark"],
widgets_values: ['text, watermark']
},
{
id: 6,
type: "CLIPTextEncode",
type: 'CLIPTextEncode',
pos: [415, 186],
size: { 0: 422.84503173828125, 1: 164.31304931640625 },
size: [422.84503173828125, 164.31304931640625],
flags: {},
order: 2,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
inputs: [{ name: 'clip', type: 'CLIP', link: 3 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [4],
slot_index: 0,
},
slot_index: 0
}
],
properties: {},
widgets_values: [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
],
'beautiful scenery nature glass bottle landscape, , purple galaxy bottle,'
]
},
{
id: 5,
type: "EmptyLatentImage",
type: 'EmptyLatentImage',
pos: [473, 609],
size: { 0: 315, 1: 106 },
size: [315, 106],
flags: {},
order: 1,
mode: 0,
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [2], slot_index: 0 }],
properties: {},
widgets_values: [512, 512, 1],
widgets_values: [512, 512, 1]
},
{
id: 3,
type: "KSampler",
type: 'KSampler',
pos: [863, 186],
size: { 0: 315, 1: 262 },
size: [315, 262],
flags: {},
order: 4,
mode: 0,
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "positive", type: "CONDITIONING", link: 4 },
{ name: "negative", type: "CONDITIONING", link: 6 },
{ name: "latent_image", type: "LATENT", link: 2 },
{ name: 'model', type: 'MODEL', link: 1 },
{ name: 'positive', type: 'CONDITIONING', link: 4 },
{ name: 'negative', type: 'CONDITIONING', link: 6 },
{ name: 'latent_image', type: 'LATENT', link: 2 }
],
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [7], slot_index: 0 }],
properties: {},
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
widgets_values: [156680208700286, true, 20, 8, 'euler', 'normal', 1]
},
{
id: 8,
type: "VAEDecode",
type: 'VAEDecode',
pos: [1209, 188],
size: { 0: 210, 1: 46 },
size: [210, 46],
flags: {},
order: 5,
mode: 0,
inputs: [
{ name: "samples", type: "LATENT", link: 7 },
{ name: "vae", type: "VAE", link: 8 },
{ name: 'samples', type: 'LATENT', link: 7 },
{ name: 'vae', type: 'VAE', link: 8 }
],
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
properties: {},
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [9], slot_index: 0 }],
properties: {}
},
{
id: 9,
type: "SaveImage",
type: 'SaveImage',
pos: [1451, 189],
size: { 0: 210, 1: 26 },
size: [210, 26],
flags: {},
order: 6,
mode: 0,
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
properties: {},
inputs: [{ name: 'images', type: 'IMAGE', link: 9 }],
properties: {}
},
{
id: 4,
type: "CheckpointLoaderSimple",
type: 'CheckpointLoaderSimple',
pos: [26, 474],
size: { 0: 315, 1: 98 },
size: [315, 98],
flags: {},
order: 0,
mode: 0,
outputs: [
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
{ name: 'MODEL', type: 'MODEL', links: [1], slot_index: 0 },
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
],
properties: {},
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
},
widgets_values: ['v1-5-pruned-emaonly.ckpt']
}
],
links: [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[1, 4, 0, 3, 0, 'MODEL'],
[2, 5, 0, 3, 3, 'LATENT'],
[3, 4, 1, 6, 0, 'CLIP'],
[4, 6, 0, 3, 1, 'CONDITIONING'],
[5, 4, 1, 7, 0, 'CLIP'],
[6, 7, 0, 3, 2, 'CONDITIONING'],
[7, 3, 0, 8, 0, 'LATENT'],
[8, 4, 2, 8, 1, 'VAE'],
[9, 8, 0, 9, 0, 'IMAGE']
],
groups: [],
config: {},
extra: {},
version: 0.4,
};
version: 0.4
}

View File

@@ -1,60 +1,60 @@
import { app, ANIM_PREVIEW_WIDGET } from "./app";
import { LGraphCanvas, LGraphNode, LiteGraph } from "@comfyorg/litegraph";
import type { Vector4 } from "@comfyorg/litegraph";
import { app, ANIM_PREVIEW_WIDGET } from './app'
import { LGraphCanvas, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type { Vector4 } from '@comfyorg/litegraph'
const SIZE = Symbol();
const SIZE = Symbol()
interface Rect {
height: number;
width: number;
x: number;
y: number;
height: number
width: number
x: number
y: number
}
export interface DOMWidget<T = HTMLElement> {
type: string;
name: string;
computedHeight?: number;
element?: T;
options: any;
value?: any;
y?: number;
callback?: (value: any) => void;
type: string
name: string
computedHeight?: number
element?: T
options: any
value?: any
y?: number
callback?: (value: any) => void
draw?: (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number,
widgetHeight: number
) => void;
onRemove?: () => void;
) => void
onRemove?: () => void
}
function intersect(a: Rect, b: Rect): Vector4 | null {
const x = Math.max(a.x, b.x);
const num1 = Math.min(a.x + a.width, b.x + b.width);
const y = Math.max(a.y, b.y);
const num2 = Math.min(a.y + a.height, b.y + b.height);
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y];
else return null;
const x = Math.max(a.x, b.x)
const num1 = Math.min(a.x + a.width, b.x + b.width)
const y = Math.max(a.y, b.y)
const num2 = Math.min(a.y + a.height, b.y + b.height)
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
else return null
}
function getClipPath(node: LGraphNode, element: HTMLElement): string {
const selectedNode: LGraphNode = Object.values(
app.canvas.selected_nodes
)[0] as LGraphNode;
)[0] as LGraphNode
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect();
const MARGIN = 7;
const scale = app.canvas.ds.scale;
const elRect = element.getBoundingClientRect()
const MARGIN = 7
const scale = app.canvas.ds.scale
const bounding = selectedNode.getBounding();
const bounding = selectedNode.getBounding()
const intersection = intersect(
{
x: elRect.x / scale,
y: elRect.y / scale,
width: elRect.width / scale,
height: elRect.height / scale,
height: elRect.height / scale
},
{
x: selectedNode.pos[0] + app.canvas.ds.offset[0] - MARGIN,
@@ -64,197 +64,197 @@ function getClipPath(node: LGraphNode, element: HTMLElement): string {
LiteGraph.NODE_TITLE_HEIGHT -
MARGIN,
width: bounding[2] + MARGIN + MARGIN,
height: bounding[3] + MARGIN + MARGIN,
height: bounding[3] + MARGIN + MARGIN
}
);
)
if (!intersection) {
return "";
return ''
}
const widgetRect = element.getBoundingClientRect();
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + "px";
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + "px";
const clipWidth = intersection[2] + "px";
const clipHeight = intersection[3] + "px";
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`;
return path;
const widgetRect = element.getBoundingClientRect()
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + 'px'
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + 'px'
const clipWidth = intersection[2] + 'px'
const clipHeight = intersection[3] + 'px'
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
return path
}
return "";
return ''
}
function computeSize(size: [number, number]): void {
if (this.widgets?.[0]?.last_y == null) return;
if (this.widgets?.[0]?.last_y == null) return
let y = this.widgets[0].last_y;
let freeSpace = size[1] - y;
let y = this.widgets[0].last_y
let freeSpace = size[1] - y
let widgetHeight = 0;
let dom = [];
let widgetHeight = 0
let dom = []
for (const w of this.widgets) {
if (w.type === "converted-widget") {
if (w.type === 'converted-widget') {
// Ignore
delete w.computedHeight;
delete w.computedHeight
} else if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
widgetHeight += w.computeSize()[1] + 4
} else if (w.element) {
// Extract DOM widget size info
const styles = getComputedStyle(w.element);
const styles = getComputedStyle(w.element)
let minHeight =
w.options.getMinHeight?.() ??
parseInt(styles.getPropertyValue("--comfy-widget-min-height"));
parseInt(styles.getPropertyValue('--comfy-widget-min-height'))
let maxHeight =
w.options.getMaxHeight?.() ??
parseInt(styles.getPropertyValue("--comfy-widget-max-height"));
parseInt(styles.getPropertyValue('--comfy-widget-max-height'))
let prefHeight =
w.options.getHeight?.() ??
styles.getPropertyValue("--comfy-widget-height");
if (prefHeight.endsWith?.("%")) {
styles.getPropertyValue('--comfy-widget-height')
if (prefHeight.endsWith?.('%')) {
prefHeight =
size[1] *
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100);
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100)
} else {
prefHeight = parseInt(prefHeight);
prefHeight = parseInt(prefHeight)
if (isNaN(minHeight)) {
minHeight = prefHeight;
minHeight = prefHeight
}
}
if (isNaN(minHeight)) {
minHeight = 50;
minHeight = 50
}
if (!isNaN(maxHeight)) {
if (!isNaN(prefHeight)) {
prefHeight = Math.min(prefHeight, maxHeight);
prefHeight = Math.min(prefHeight, maxHeight)
} else {
prefHeight = maxHeight;
prefHeight = maxHeight
}
}
dom.push({
minHeight,
prefHeight,
w,
});
w
})
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4;
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4
}
}
freeSpace -= widgetHeight;
freeSpace -= widgetHeight
// Calculate sizes with all widgets at their min height
const prefGrow = []; // Nodes that want to grow to their prefd size
const canGrow = []; // Nodes that can grow to auto size
let growBy = 0;
const prefGrow = [] // Nodes that want to grow to their prefd size
const canGrow = [] // Nodes that can grow to auto size
let growBy = 0
for (const d of dom) {
freeSpace -= d.minHeight;
freeSpace -= d.minHeight
if (isNaN(d.prefHeight)) {
canGrow.push(d);
d.w.computedHeight = d.minHeight;
canGrow.push(d)
d.w.computedHeight = d.minHeight
} else {
const diff = d.prefHeight - d.minHeight;
const diff = d.prefHeight - d.minHeight
if (diff > 0) {
prefGrow.push(d);
growBy += diff;
d.diff = diff;
prefGrow.push(d)
growBy += diff
d.diff = diff
} else {
d.w.computedHeight = d.minHeight;
d.w.computedHeight = d.minHeight
}
}
}
if (this.imgs && !this.widgets.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
// Allocate space for image
freeSpace -= 220;
freeSpace -= 220
}
this.freeWidgetSpace = freeSpace;
this.freeWidgetSpace = freeSpace
if (freeSpace < 0) {
// Not enough space for all widgets so we need to grow
size[1] -= freeSpace;
this.graph.setDirtyCanvas(true);
size[1] -= freeSpace
this.graph.setDirtyCanvas(true)
} else {
// Share the space between each
const growDiff = freeSpace - growBy;
const growDiff = freeSpace - growBy
if (growDiff > 0) {
// All pref sizes can be fulfilled
freeSpace = growDiff;
freeSpace = growDiff
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight;
d.w.computedHeight = d.prefHeight
}
} else {
// We need to grow evenly
const shared = -growDiff / prefGrow.length;
const shared = -growDiff / prefGrow.length
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight - shared;
d.w.computedHeight = d.prefHeight - shared
}
freeSpace = 0;
freeSpace = 0
}
if (freeSpace > 0 && canGrow.length) {
// Grow any that are auto height
const shared = freeSpace / canGrow.length;
const shared = freeSpace / canGrow.length
for (const d of canGrow) {
d.w.computedHeight += shared;
d.w.computedHeight += shared
}
}
}
// Position each of the widgets
for (const w of this.widgets) {
w.y = y;
w.y = y
if (w.computedHeight) {
y += w.computedHeight;
y += w.computedHeight
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
y += w.computeSize()[1] + 4
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
y += LiteGraph.NODE_WIDGET_HEIGHT + 4
}
}
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set();
const elementWidgets = new Set()
//@ts-ignore
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes;
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
//@ts-ignore
LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
const visibleNodes = computeVisibleNodes.apply(this, arguments);
const visibleNodes = computeVisibleNodes.apply(this, arguments)
// @ts-ignore
for (const node of app.graph._nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1;
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets) {
// @ts-ignore
if (w.element) {
// @ts-ignore
w.element.hidden = hidden;
w.element.hidden = hidden
// @ts-ignore
w.element.style.display = hidden ? "none" : undefined;
w.element.style.display = hidden ? 'none' : undefined
if (hidden) {
w.options.onHide?.(w);
w.options.onHide?.(w)
}
}
}
}
}
return visibleNodes;
};
return visibleNodes
}
let enableDomClipping = true;
let enableDomClipping = true
export function addDomClippingSetting(): void {
app.ui.settings.addSetting({
id: "Comfy.DOMClippingEnabled",
name: "Enable DOM element clipping (enabling may reduce performance)",
type: "boolean",
id: 'Comfy.DOMClippingEnabled',
name: 'Enable DOM element clipping (enabling may reduce performance)',
type: 'boolean',
defaultValue: enableDomClipping,
onChange(value) {
enableDomClipping = !!value;
},
});
enableDomClipping = !!value
}
})
}
//@ts-ignore
@@ -264,33 +264,33 @@ LGraphNode.prototype.addDOMWidget = function (
element: HTMLElement,
options: Record<string, any>
): DOMWidget {
options = { hideOnZoom: true, selectOn: ["focus", "click"], ...options };
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
if (!element.parentElement) {
document.body.append(element);
document.body.append(element)
}
element.hidden = true;
element.style.display = "none";
element.hidden = true
element.style.display = 'none'
let mouseDownHandler;
let mouseDownHandler
if (element.blur) {
mouseDownHandler = (event) => {
if (!element.contains(event.target)) {
element.blur();
element.blur()
}
};
document.addEventListener("mousedown", mouseDownHandler);
}
document.addEventListener('mousedown', mouseDownHandler)
}
const widget: DOMWidget = {
type,
name,
get value() {
return options.getValue?.() ?? undefined;
return options.getValue?.() ?? undefined
},
set value(v) {
options.setValue?.(v);
widget.callback?.(widget.value);
options.setValue?.(v)
widget.callback?.(widget.value)
},
draw: function (
ctx: CanvasRenderingContext2D,
@@ -300,99 +300,99 @@ LGraphNode.prototype.addDOMWidget = function (
widgetHeight: number
) {
if (widget.computedHeight == null) {
computeSize.call(node, node.size);
computeSize.call(node, node.size)
}
const hidden =
node.flags?.collapsed ||
(!!options.hideOnZoom && app.canvas.ds.scale < 0.5) ||
widget.computedHeight <= 0 ||
widget.type === "converted-widget" ||
widget.type === "hidden";
element.hidden = hidden;
element.style.display = hidden ? "none" : null;
widget.type === 'converted-widget' ||
widget.type === 'hidden'
element.hidden = hidden
element.style.display = hidden ? 'none' : null
if (hidden) {
widget.options.onHide?.(widget);
return;
widget.options.onHide?.(widget)
return
}
const margin = 10;
const elRect = ctx.canvas.getBoundingClientRect();
const margin = 10
const elRect = ctx.canvas.getBoundingClientRect()
const transform = new DOMMatrix()
.scaleSelf(
elRect.width / ctx.canvas.width,
elRect.height / ctx.canvas.height
)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + y);
.translateSelf(margin, margin + y)
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d)
Object.assign(element.style, {
transformOrigin: "0 0",
transformOrigin: '0 0',
transform: scale,
left: `${transform.a + transform.e + elRect.left}px`,
top: `${transform.d + transform.f + elRect.top}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
position: "absolute",
position: 'absolute',
// @ts-ignore
zIndex: app.graph._nodes.indexOf(node),
});
zIndex: app.graph._nodes.indexOf(node)
})
if (enableDomClipping) {
element.style.clipPath = getClipPath(node, element);
element.style.willChange = "clip-path";
element.style.clipPath = getClipPath(node, element)
element.style.willChange = 'clip-path'
}
this.options.onDraw?.(widget);
this.options.onDraw?.(widget)
},
element,
options,
onRemove() {
if (mouseDownHandler) {
document.removeEventListener("mousedown", mouseDownHandler);
document.removeEventListener('mousedown', mouseDownHandler)
}
element.remove();
},
};
element.remove()
}
}
for (const evt of options.selectOn) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this);
app.canvas.bringToFront(this);
});
app.canvas.selectNode(this)
app.canvas.bringToFront(this)
})
}
this.addCustomWidget(widget);
elementWidgets.add(this);
this.addCustomWidget(widget)
elementWidgets.add(this)
const collapse = this.collapse;
const collapse = this.collapse
this.collapse = function () {
collapse.apply(this, arguments);
collapse.apply(this, arguments)
if (this.flags?.collapsed) {
element.hidden = true;
element.style.display = "none";
element.hidden = true
element.style.display = 'none'
}
};
}
const onRemoved = this.onRemoved;
const onRemoved = this.onRemoved
this.onRemoved = function () {
element.remove();
elementWidgets.delete(this);
onRemoved?.apply(this, arguments);
};
element.remove()
elementWidgets.delete(this)
onRemoved?.apply(this, arguments)
}
if (!this[SIZE]) {
this[SIZE] = true;
const onResize = this.onResize;
this[SIZE] = true
const onResize = this.onResize
this.onResize = function (size) {
options.beforeResize?.call(widget, this);
computeSize.call(this, size);
onResize?.apply(this, arguments);
options.afterResize?.call(widget, this);
};
options.beforeResize?.call(widget, this)
computeSize.call(this, size)
onResize?.apply(this, arguments)
options.afterResize?.call(widget, this)
}
}
return widget;
};
return widget
}

View File

@@ -1,8 +1,8 @@
import { $el, ComfyDialog } from "./ui";
import { api } from "./api";
import type { ComfyApp } from "./app";
import { $el, ComfyDialog } from './ui'
import { api } from './api'
import type { ComfyApp } from './app'
$el("style", {
$el('style', {
textContent: `
.comfy-logging-logs {
display: grid;
@@ -23,17 +23,17 @@ $el("style", {
padding: 5px;
}
`,
parent: document.body,
});
parent: document.body
})
// Stringify function supporting max depth and removal of circular references
// https://stackoverflow.com/a/57193345
function stringify(val, depth, replacer, space, onGetObjID?) {
depth = isNaN(+depth) ? 1 : depth;
var recursMap = new WeakMap();
depth = isNaN(+depth) ? 1 : depth
var recursMap = new WeakMap()
function _build(val, depth, o?, a?, r?) {
// (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
return !val || typeof val != "object"
return !val || typeof val != 'object'
? val
: ((r = recursMap.has(val)),
recursMap.set(val, true),
@@ -42,201 +42,201 @@ function stringify(val, depth, replacer, space, onGetObjID?) {
? (o = (onGetObjID && onGetObjID(val)) || null)
: JSON.stringify(val, function (k, v) {
if (a || depth > 0) {
if (replacer) v = replacer(k, v);
if (!k) return (a = Array.isArray(v)), (val = v);
!o && (o = a ? [] : {});
o[k] = _build(v, a ? depth : depth - 1);
if (replacer) v = replacer(k, v)
if (!k) return (a = Array.isArray(v)), (val = v)
!o && (o = a ? [] : {})
o[k] = _build(v, a ? depth : depth - 1)
}
}),
o === void 0 ? (a ? [] : {}) : o);
o === void 0 ? (a ? [] : {}) : o)
}
return JSON.stringify(_build(val, depth), null, space);
return JSON.stringify(_build(val, depth), null, space)
}
const jsonReplacer = (k, v, ui) => {
if (v instanceof Array && v.length === 1) {
v = v[0];
v = v[0]
}
if (v instanceof Date) {
v = v.toISOString();
v = v.toISOString()
if (ui) {
v = v.split("T")[1];
v = v.split('T')[1]
}
}
if (v instanceof Error) {
let err = "";
if (v.name) err += v.name + "\n";
if (v.message) err += v.message + "\n";
if (v.stack) err += v.stack + "\n";
let err = ''
if (v.name) err += v.name + '\n'
if (v.message) err += v.message + '\n'
if (v.stack) err += v.stack + '\n'
if (!err) {
err = v.toString();
err = v.toString()
}
v = err;
v = err
}
return v;
};
return v
}
const fileInput: HTMLInputElement = $el("input", {
type: "file",
accept: ".json",
style: { display: "none" },
parent: document.body,
}) as HTMLInputElement;
const fileInput: HTMLInputElement = $el('input', {
type: 'file',
accept: '.json',
style: { display: 'none' },
parent: document.body
}) as HTMLInputElement
class ComfyLoggingDialog extends ComfyDialog {
logging: any;
logging: any
constructor(logging) {
super();
this.logging = logging;
super()
this.logging = logging
}
clear() {
this.logging.clear();
this.show();
this.logging.clear()
this.show()
}
export() {
const blob = new Blob(
[stringify([...this.logging.entries], 20, jsonReplacer, "\t")],
[stringify([...this.logging.entries], 20, jsonReplacer, '\t')],
{
type: "application/json",
type: 'application/json'
}
);
const url = URL.createObjectURL(blob);
const a = $el("a", {
)
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: `comfyui-logs-${Date.now()}.json`,
style: { display: "none" },
parent: document.body,
});
a.click();
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
import() {
fileInput.onchange = () => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = () => {
fileInput.remove();
fileInput.remove()
try {
const obj = JSON.parse(reader.result as string);
const obj = JSON.parse(reader.result as string)
if (obj instanceof Array) {
this.show(obj);
this.show(obj)
} else {
throw new Error("Invalid file selected.");
throw new Error('Invalid file selected.')
}
} catch (error) {
alert("Unable to load logs: " + error.message);
alert('Unable to load logs: ' + error.message)
}
};
reader.readAsText(fileInput.files[0]);
};
fileInput.click();
}
reader.readAsText(fileInput.files[0])
}
fileInput.click()
}
createButtons() {
return [
$el("button", {
type: "button",
textContent: "Clear",
onclick: () => this.clear(),
$el('button', {
type: 'button',
textContent: 'Clear',
onclick: () => this.clear()
}),
$el("button", {
type: "button",
textContent: "Export logs...",
onclick: () => this.export(),
$el('button', {
type: 'button',
textContent: 'Export logs...',
onclick: () => this.export()
}),
$el("button", {
type: "button",
textContent: "View exported logs...",
onclick: () => this.import(),
$el('button', {
type: 'button',
textContent: 'View exported logs...',
onclick: () => this.import()
}),
...super.createButtons(),
];
...super.createButtons()
]
}
getTypeColor(type) {
switch (type) {
case "error":
return "red";
case "warn":
return "orange";
case "debug":
return "dodgerblue";
case 'error':
return 'red'
case 'warn':
return 'orange'
case 'debug':
return 'dodgerblue'
}
}
show(entries?: any[]) {
if (!entries) entries = this.logging.entries;
this.element.style.width = "100%";
if (!entries) entries = this.logging.entries
this.element.style.width = '100%'
const cols = {
source: "Source",
type: "Type",
timestamp: "Timestamp",
message: "Message",
};
const keys = Object.keys(cols);
source: 'Source',
type: 'Type',
timestamp: 'Timestamp',
message: 'Message'
}
const keys = Object.keys(cols)
const headers = Object.values(cols).map((title) =>
$el("div.comfy-logging-title", {
textContent: title,
$el('div.comfy-logging-title', {
textContent: title
})
);
)
const rows = entries.map((entry, i) => {
return $el(
"div.comfy-logging-log",
'div.comfy-logging-log',
{
$: (el) =>
el.style.setProperty(
"--row-bg",
`var(--tr-${i % 2 ? "even" : "odd"}-bg-color)`
),
'--row-bg',
`var(--tr-${i % 2 ? 'even' : 'odd'}-bg-color)`
)
},
keys.map((key) => {
let v = entry[key];
let color;
if (key === "type") {
color = this.getTypeColor(v);
let v = entry[key]
let color
if (key === 'type') {
color = this.getTypeColor(v)
} else {
v = jsonReplacer(key, v, true);
v = jsonReplacer(key, v, true)
if (typeof v === "object") {
v = stringify(v, 5, jsonReplacer, " ");
if (typeof v === 'object') {
v = stringify(v, 5, jsonReplacer, ' ')
}
}
return $el("div", {
return $el('div', {
style: {
color,
color
},
textContent: v,
});
textContent: v
})
})
);
});
)
})
const grid = $el(
"div.comfy-logging-logs",
'div.comfy-logging-logs',
{
style: {
gridTemplateColumns: `repeat(${headers.length}, 1fr)`,
},
gridTemplateColumns: `repeat(${headers.length}, 1fr)`
}
},
[...headers, ...rows]
);
const els = [grid];
)
const els = [grid]
if (!this.logging.enabled) {
els.unshift(
$el("h3", {
style: { textAlign: "center" },
textContent: "Logging is disabled",
$el('h3', {
style: { textAlign: 'center' },
textContent: 'Logging is disabled'
})
);
)
}
super.show($el("div", els));
super.show($el('div', els))
}
}
@@ -244,118 +244,118 @@ export class ComfyLogging {
/**
* @type Array<{ source: string, type: string, timestamp: Date, message: any }>
*/
entries = [];
entries = []
#enabled;
#console = {};
#enabled
#console = {}
app: ComfyApp;
dialog: ComfyLoggingDialog;
app: ComfyApp
dialog: ComfyLoggingDialog
get enabled() {
return this.#enabled;
return this.#enabled
}
set enabled(value) {
if (value === this.#enabled) return;
if (value === this.#enabled) return
if (value) {
this.patchConsole();
this.patchConsole()
} else {
this.unpatchConsole();
this.unpatchConsole()
}
this.#enabled = value;
this.#enabled = value
}
constructor(app) {
this.app = app;
this.app = app
this.dialog = new ComfyLoggingDialog(this);
this.addSetting();
this.catchUnhandled();
this.addInitData();
this.dialog = new ComfyLoggingDialog(this)
this.addSetting()
this.catchUnhandled()
this.addInitData()
}
addSetting() {
const settingId: string = "Comfy.Logging.Enabled";
const htmlSettingId = settingId.replaceAll(".", "-");
const settingId: string = 'Comfy.Logging.Enabled'
const htmlSettingId = settingId.replaceAll('.', '-')
const setting = this.app.ui.settings.addSetting({
id: settingId,
name: settingId,
defaultValue: true,
onChange: (value) => {
this.enabled = value;
this.enabled = value
},
type: (name, setter, value) => {
return $el("tr", [
$el("td", [
$el("label", {
textContent: "Logging",
for: htmlSettingId,
}),
return $el('tr', [
$el('td', [
$el('label', {
textContent: 'Logging',
for: htmlSettingId
})
]),
$el("td", [
$el("input", {
$el('td', [
$el('input', {
id: htmlSettingId,
type: "checkbox",
type: 'checkbox',
checked: value,
onchange: (event) => {
setter(event.target.checked);
},
setter(event.target.checked)
}
}),
$el("button", {
textContent: "View Logs",
$el('button', {
textContent: 'View Logs',
onclick: () => {
this.app.ui.settings.element.close();
this.dialog.show();
this.app.ui.settings.element.close()
this.dialog.show()
},
style: {
fontSize: "14px",
display: "block",
marginTop: "5px",
},
}),
]),
]);
},
});
this.enabled = setting.value;
fontSize: '14px',
display: 'block',
marginTop: '5px'
}
})
])
])
}
})
this.enabled = setting.value
}
patchConsole() {
// Capture common console outputs
const self = this;
for (const type of ["log", "warn", "error", "debug"]) {
const orig = console[type];
this.#console[type] = orig;
const self = this
for (const type of ['log', 'warn', 'error', 'debug']) {
const orig = console[type]
this.#console[type] = orig
console[type] = function () {
orig.apply(console, arguments);
self.addEntry("console", type, ...arguments);
};
orig.apply(console, arguments)
self.addEntry('console', type, ...arguments)
}
}
}
unpatchConsole() {
// Restore original console functions
for (const type of Object.keys(this.#console)) {
console[type] = this.#console[type];
console[type] = this.#console[type]
}
this.#console = {};
this.#console = {}
}
catchUnhandled() {
// Capture uncaught errors
window.addEventListener("error", (e) => {
this.addEntry("window", "error", e.error ?? "Unknown error");
return false;
});
window.addEventListener('error', (e) => {
this.addEntry('window', 'error', e.error ?? 'Unknown error')
return false
})
window.addEventListener("unhandledrejection", (e) => {
this.addEntry("unhandledrejection", "error", e.reason ?? "Unknown error");
});
window.addEventListener('unhandledrejection', (e) => {
this.addEntry('unhandledrejection', 'error', e.reason ?? 'Unknown error')
})
}
clear() {
this.entries = [];
this.entries = []
}
addEntry(source, type, ...args) {
@@ -364,20 +364,20 @@ export class ComfyLogging {
source,
type,
timestamp: new Date(),
message: args,
});
message: args
})
}
}
log(source, ...args) {
this.addEntry(source, "log", ...args);
this.addEntry(source, 'log', ...args)
}
async addInitData() {
if (!this.enabled) return;
const source = "ComfyUI.Logging";
this.addEntry(source, "debug", { UserAgent: navigator.userAgent });
const systemStats = await api.getSystemStats();
this.addEntry(source, "debug", systemStats);
if (!this.enabled) return
const source = 'ComfyUI.Logging'
this.addEntry(source, 'debug', { UserAgent: navigator.userAgent })
const systemStats = await api.getSystemStats()
this.addEntry(source, 'debug', systemStats)
}
}

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