Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e128f39760 | ||
|
|
f22713aeb0 | ||
|
|
74fd1c9abe | ||
|
|
f4f0c960a3 | ||
|
|
c0875d066a | ||
|
|
980ed0083d | ||
|
|
76be537351 | ||
|
|
424a5f7a86 | ||
|
|
542e1c1f59 | ||
|
|
972ffe73e3 | ||
|
|
b4d7735855 | ||
|
|
9bcc08d7ab | ||
|
|
a1750212e5 | ||
|
|
4dba1d3ab0 | ||
|
|
ee6eed1c1c | ||
|
|
8e1d3f3baa | ||
|
|
dc13ed102b | ||
|
|
9d56bb4e0e | ||
|
|
c97ff6fd85 | ||
|
|
0ec15ba101 | ||
|
|
55d5ec8c25 | ||
|
|
c6d2767af1 | ||
|
|
e179f75387 | ||
|
|
19c70d95d3 | ||
|
|
ebdd7b8e40 | ||
|
|
b73fe80761 | ||
|
|
84d8c5fc16 | ||
|
|
5b4e96f6c5 | ||
|
|
1b7db43f8a | ||
|
|
648e52e39c | ||
|
|
89b195dc13 | ||
|
|
69d95f6e46 | ||
|
|
609d3fe279 | ||
|
|
9b36c6b254 | ||
|
|
d87058babf | ||
|
|
a71f7671ae | ||
|
|
bd68617c82 | ||
|
|
189662cd7c | ||
|
|
fc327fe071 | ||
|
|
65740a30c5 | ||
|
|
1521cd47c8 | ||
|
|
f18740d5e4 | ||
|
|
3fbffc1eb6 | ||
|
|
cb9042f9f9 | ||
|
|
a99a833c38 | ||
|
|
a2afdd74b2 | ||
|
|
396e0c9525 | ||
|
|
050fd4eb32 | ||
|
|
631a060fff | ||
|
|
d0030e1185 | ||
|
|
71ac0dcccc | ||
|
|
ab7436f87c | ||
|
|
9961be1bc7 | ||
|
|
2568746071 | ||
|
|
dea9af8650 | ||
|
|
c2e7ef11ec | ||
|
|
54246d37b0 | ||
|
|
bb02f935ff | ||
|
|
3dfef8a73e | ||
|
|
24cdb6ad2d | ||
|
|
7619e9159b | ||
|
|
05d5896c82 | ||
|
|
1706476dca | ||
|
|
31f4ee332a | ||
|
|
6b2acc146d | ||
|
|
edb1349bb5 | ||
|
|
3fbaeb1fb8 | ||
|
|
2ab3c2bca1 | ||
|
|
13cda7de41 | ||
|
|
e216fa82c5 | ||
|
|
900c4d9ca5 | ||
|
|
d49c68e7bf | ||
|
|
93a0c1012f | ||
|
|
5fac7c9365 | ||
|
|
f1acdf976a | ||
|
|
15900cd523 | ||
|
|
55431d1e4f |
@@ -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
@@ -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"
|
||||
2
.github/workflows/test-browser-exp.yaml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/test-ui.yaml
vendored
@@ -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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5"
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
16
README.md
@@ -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 
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
164
browser_tests/assets/snap_to_slot.json
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
@@ -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
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
95
browser_tests/menu.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
28
browser_tests/textWidgetTruncate.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 96 KiB |
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
15
package.json
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
// },
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
137
src/App.vue
@@ -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>
|
||||
|
||||
62
src/components/LiteGraphCanvasSplitterOverlay.vue
Normal 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>
|
||||
@@ -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">◀</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">▶</div>
|
||||
<div
|
||||
v-for="widgetInput in widgetInputDefs"
|
||||
class="_sb_row _long_field"
|
||||
:key="widgetInput.name"
|
||||
>
|
||||
<div class="_sb_col _sb_arrow">◀</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">▶</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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
106
src/components/primevueOverride/TreePlus.vue
Normal 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>
|
||||
60
src/components/sidebar/SideBarIcon.vue
Normal 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>
|
||||
16
src/components/sidebar/SideBarSettingsToggleIcon.vue
Normal 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>
|
||||
28
src/components/sidebar/SideBarThemeToggleIcon.vue
Normal 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>
|
||||
101
src/components/sidebar/SideToolBar.vue
Normal 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>
|
||||
99
src/components/sidebar/tabs/NodeLibrarySideBarTab.vue
Normal 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>
|
||||
171
src/components/sidebar/tabs/QueueSideBarTab.vue
Normal 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>
|
||||
@@ -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!')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
29
src/i18n.ts
Normal 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
|
||||
})
|
||||
51
src/main.ts
@@ -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
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
2404
src/scripts/app.ts
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||