Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
244cd3c920 | ||
|
|
0cf5e647af | ||
|
|
f0f867481d | ||
|
|
d02b074fa3 | ||
|
|
e14d84526a | ||
|
|
2aa9166079 | ||
|
|
3baa07e0a9 | ||
|
|
c494cd211e | ||
|
|
c00e2fd208 | ||
|
|
d77343da83 | ||
|
|
c611c15d40 | ||
|
|
269686eebb | ||
|
|
0e3590d017 | ||
|
|
7d2d6df57b | ||
|
|
4462dabc63 | ||
|
|
53bfc0c95a | ||
|
|
b78682689e | ||
|
|
6d1dce8255 | ||
|
|
73f4e5143d | ||
|
|
7d75cc99ba | ||
|
|
0aa7d0b99a | ||
|
|
66b690e5c8 | ||
|
|
6e27b884fc | ||
|
|
561162fb3e | ||
|
|
7c6bd7ed71 | ||
|
|
fc5bdf80b3 | ||
|
|
033f242e43 | ||
|
|
304429b967 | ||
|
|
6dbdb9baa6 | ||
|
|
3e3e909e36 | ||
|
|
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 | ||
|
|
dc22765c8f | ||
|
|
5a998cd7fb | ||
|
|
605faf0a93 | ||
|
|
3ac793b931 | ||
|
|
dbd0e3ef68 | ||
|
|
04aad417fc | ||
|
|
86ee0767c3 | ||
|
|
ffc4f0c98e | ||
|
|
9b276a6237 | ||
|
|
99193e4b52 | ||
|
|
a28ac0c0fa |
@@ -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"
|
||||
4
.github/workflows/test-browser-exp.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "huchenlei/ComfyUI_frontend"
|
||||
repository: "Comfy-Org/ComfyUI_frontend"
|
||||
path: "ComfyUI_frontend"
|
||||
ref: ${{ github.head_ref }}
|
||||
- uses: actions/setup-node@v3
|
||||
@@ -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
|
||||
|
||||
16
.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:
|
||||
@@ -30,7 +36,9 @@ jobs:
|
||||
with:
|
||||
repository: "comfyanonymous/ComfyUI_examples"
|
||||
path: "ComfyUI_frontend/tests-ui/ComfyUI_examples"
|
||||
ref: master
|
||||
# Re-enable tracking latest master branch after fixing the issue
|
||||
# https://github.com/Comfy-Org/ComfyUI_frontend/issues/277
|
||||
ref: 58b2e103bb8e424b66044fd07f1d3a6d80834ed4
|
||||
- name: Skip CI
|
||||
if: contains(steps.commit-message.outputs.message, '[skip ci]')
|
||||
run: echo "Skipping CI as commit contains '[skip ci]'"
|
||||
@@ -68,7 +76,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"
|
||||
}
|
||||
108
README.md
@@ -2,6 +2,102 @@
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Release Summary
|
||||
|
||||
### Major features
|
||||
|
||||
<details>
|
||||
<summary>v1.2.4: Node library sidebar tab</summary>
|
||||
|
||||
#### Drag & Drop
|
||||
https://github.com/user-attachments/assets/853e20b7-bc0e-49c9-bbce-a2ba7566f92f
|
||||
|
||||
#### Filter
|
||||
https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.2.0: Queue/History sidebar tab</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/86e264fe-4d26-4f07-aa9a-83bdd2d02b8f
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.1.0: Node search box</summary>
|
||||
|
||||
#### Fuzzy search & Node preview
|
||||

|
||||
|
||||
#### Release link with shift
|
||||
https://github.com/user-attachments/assets/a1b2b5c3-10d1-4256-b620-345de6858f25
|
||||
</details>
|
||||
|
||||
### QoL changes
|
||||
|
||||
<details>
|
||||
<summary>v1.2.7: **Litegraph** drags multiple links with shift pressed</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/68826715-bb55-4b2a-be6e-675cfc424afe
|
||||
|
||||
https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.2.2: **Litegraph** auto connects to correct slot</summary>
|
||||
|
||||
#### Before
|
||||
https://github.com/user-attachments/assets/c253f778-82d5-4e6f-aec0-ea2ccf421651
|
||||
|
||||
#### After
|
||||
https://github.com/user-attachments/assets/b6360ac0-f0d2-447c-9daa-8a2e20c0dc1d
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.1.8: **Litegraph** hides text overflow on widget value</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
|
||||
</details>
|
||||
|
||||
### Node developers API
|
||||
<details>
|
||||
<summary>v1.2.4: Extension API to register custom side bar tab</summary>
|
||||
|
||||
Extensions now can call following API to register a sidebar tab.
|
||||
|
||||
```js
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "search",
|
||||
icon: "pi pi-search",
|
||||
title: "search",
|
||||
tooltip: "search",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
el.innerHTML = "<div>Custom search tab</div>";
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The list of supported icons can be find here: https://primevue.org/icons/#list
|
||||
|
||||
We will support custom icon later.
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
|
||||
## Road Map
|
||||
|
||||
### What has been done
|
||||
@@ -12,19 +108,19 @@ Front-end of [ComfyUI](https://github.com/comfyanonymous/ComfyUI) modernized. Th
|
||||
- Front-end dev server.
|
||||
- Zod schema for input validation on ComfyUI workflow.
|
||||
- Make litegraph a npm dependency. <https://github.com/Comfy-Org/ComfyUI_frontend/pull/89>
|
||||
- Introduce Vue to start managing part of the UI.
|
||||
- Easy install and version management (<https://github.com/comfyanonymous/ComfyUI/pull/3897>).
|
||||
- Better node management. Sherlock <https://github.com/Nuked88/ComfyUI-N-Sidebar>.
|
||||
|
||||
|
||||
### 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 Vue to start managing part of the UI.
|
||||
|
||||
- Starting with node search box revamp
|
||||
- Introduce a UI library to add more widget types for node developers.
|
||||
- Add more widget types for node developers.
|
||||
- LLM streaming node.
|
||||
- Linear mode (Similar to InvokeAI's linear mode).
|
||||
- Better node search. Sherlock https://github.com/Nuked88/ComfyUI-N-Sidebar.
|
||||
- Keybinding settings management. Register keybindings API for custom nodes.
|
||||
- New extensions API for adding UI-related features.
|
||||
|
||||
|
||||
@@ -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,46 +1,204 @@
|
||||
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
|
||||
|
||||
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'
|
||||
)
|
||||
}
|
||||
|
||||
async fillAndSelectFirstNode(nodeName: string) {
|
||||
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
|
||||
|
||||
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' });
|
||||
// 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.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() {
|
||||
@@ -49,8 +207,8 @@ export class ComfyPage {
|
||||
x: 618,
|
||||
y: 191
|
||||
}
|
||||
});
|
||||
await this.nextFrame();
|
||||
})
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickTextEncodeNode2() {
|
||||
@@ -59,8 +217,8 @@ export class ComfyPage {
|
||||
x: 622,
|
||||
y: 400
|
||||
}
|
||||
});
|
||||
await this.nextFrame();
|
||||
})
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickEmptySpace() {
|
||||
@@ -69,84 +227,81 @@ export class ComfyPage {
|
||||
x: 35,
|
||||
y: 31
|
||||
}
|
||||
});
|
||||
await this.nextFrame();
|
||||
})
|
||||
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;
|
||||
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();
|
||||
})
|
||||
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() {
|
||||
@@ -154,10 +309,10 @@ export class ComfyPage {
|
||||
position: {
|
||||
x: 724,
|
||||
y: 625
|
||||
},
|
||||
});
|
||||
this.page.mouse.move(10, 10);
|
||||
await this.nextFrame();
|
||||
}
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async rightClickEmptyLatentNode() {
|
||||
@@ -167,60 +322,127 @@ export class ComfyPage {
|
||||
y: 645
|
||||
},
|
||||
button: 'right'
|
||||
});
|
||||
this.page.mouse.move(10, 10);
|
||||
await this.nextFrame();
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
193
browser_tests/assets/batch_move_links.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
0,
|
||||
92
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 98
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"3Guofeng3_v32Light.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
460,
|
||||
92
|
||||
],
|
||||
"size": {
|
||||
"0": 422.84503173828125,
|
||||
"1": 164.31304931640625
|
||||
},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
460,
|
||||
368
|
||||
],
|
||||
"size": {
|
||||
"0": 425.27801513671875,
|
||||
"1": 180.6060791015625
|
||||
},
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"text, watermark"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
0,
|
||||
276
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 98
|
||||
},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"3Guofeng3_v32Light.safetensors"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
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,89 @@
|
||||
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)
|
||||
})
|
||||
|
||||
test('Can copy and paste widget value', async ({ comfyPage }) => {
|
||||
// Copy width value (512) from empty latent node to KSampler's seed.
|
||||
// Empty latent node's width
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 718,
|
||||
y: 643
|
||||
}
|
||||
})
|
||||
await comfyPage.ctrlC()
|
||||
// KSampler's seed
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 1005,
|
||||
y: 281
|
||||
}
|
||||
})
|
||||
await comfyPage.ctrlV()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
|
||||
})
|
||||
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
|
||||
test('Copy node by dragging + alt', async ({ comfyPage }) => {
|
||||
// TextEncodeNode1
|
||||
await comfyPage.page.mouse.move(618, 191)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(100, 100)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('drag-copy-copied-node.png')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -1,54 +1,97 @@
|
||||
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('Can batch move links by drag with shift', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('batch_move_links')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
|
||||
|
||||
const outputSlot1Pos = {
|
||||
x: 304,
|
||||
y: 127
|
||||
}
|
||||
const outputSlot2Pos = {
|
||||
x: 307,
|
||||
y: 310
|
||||
}
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'batch_move_links_moved.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: 92 KiB After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 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: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 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)
|
||||
})
|
||||
})
|
||||
57
browser_tests/nodeSearchBox.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './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('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('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 batch moved node', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('batch_move_links')
|
||||
|
||||
const outputSlot1Pos = {
|
||||
x: 304,
|
||||
y: 127
|
||||
}
|
||||
const emptySpacePos = {
|
||||
x: 5,
|
||||
y: 5
|
||||
}
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(outputSlot1Pos, emptySpacePos)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'auto-linked-node-batch.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 91 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: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 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: 93 KiB After Width: | Height: | Size: 93 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 |
11
index.html
@@ -12,19 +12,18 @@
|
||||
font-family: 'Roboto Mono', 'Noto Color Emoji';
|
||||
}
|
||||
</style> -->
|
||||
<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__);
|
||||
import { app } from "./src/scripts/app";
|
||||
(async () => {
|
||||
await app.setup();
|
||||
window.app = app;
|
||||
window.graph = app.graph;
|
||||
})();
|
||||
</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>
|
||||
<body class="litegraph">
|
||||
<div id="vue-app"></div>
|
||||
<div id="comfy-user-selection" class="comfy-user-selection" style="display: none;">
|
||||
<main class="comfy-user-selection-inner">
|
||||
<h1>ComfyUI</h1>
|
||||
|
||||
@@ -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
|
||||
|
||||
1252
package-lock.json
generated
26
package.json
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.2.9",
|
||||
"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}'",
|
||||
"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",
|
||||
@@ -22,7 +22,9 @@
|
||||
"@babel/preset-env": "^7.22.20",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.8",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||
"babel-plugin-transform-rename-import": "^2.3.0",
|
||||
"chalk": "^5.3.0",
|
||||
@@ -32,7 +34,9 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.15.6",
|
||||
@@ -42,13 +46,25 @@
|
||||
"zip-dir": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@comfyorg/litegraph": "^0.7.23",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
|
||||
"@comfyorg/litegraph": "^0.7.35",
|
||||
"@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}": [
|
||||
"./**/*.{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,7 +36,8 @@ export default defineConfig({
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
timeout: 5000
|
||||
}
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
@@ -67,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: {
|
||||
@@ -75,4 +76,4 @@ export default defineConfig({
|
||||
// url: 'http://127.0.0.1:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
||||
})
|
||||
|
||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
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.')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
72
src/App.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<ProgressSpinner v-if="isLoading" class="spinner"></ProgressSpinner>
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<GraphCanvas />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onMounted, watch } from 'vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import QueueSideBarTab from '@/components/sidebar/tabs/QueueSideBarTab.vue'
|
||||
import { app } from './scripts/app'
|
||||
import { useSettingStore } from './stores/settingStore'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWorkspaceStore } from './stores/workspaceStateStore'
|
||||
import NodeLibrarySideBarTab from './components/sidebar/tabs/NodeLibrarySideBarTab.vue'
|
||||
|
||||
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
|
||||
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 { t } = useI18n()
|
||||
const init = () => {
|
||||
useSettingStore().addSettings(app.ui.settings)
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
init()
|
||||
} catch (e) {
|
||||
console.error('Failed to init Vue app', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spinner {
|
||||
@apply absolute inset-0 flex justify-center items-center h-screen;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
229
src/components/NodePreview.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<!-- Reference:
|
||||
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="_sb_node_preview">
|
||||
<div class="_sb_table">
|
||||
<div class="node_header">
|
||||
<div class="_sb_dot headdot"></div>
|
||||
{{ nodeDef.display_name }}
|
||||
</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"
|
||||
:key="(slotInput?.name || '') + (slotOutput?.index.toString() || '')"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
: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">
|
||||
{{ truncateDefaultValue(widgetInput.default) }}
|
||||
</div>
|
||||
<div class="_sb_col _sb_arrow">▶</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_sb_description" v-if="nodeDef.description">
|
||||
{{ nodeDef.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import _ from 'lodash'
|
||||
|
||||
const props = defineProps({
|
||||
nodeDef: {
|
||||
type: ComfyNodeDefImpl,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
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)
|
||||
)
|
||||
const truncateDefaultValue = (value: any): string => {
|
||||
if (value instanceof Object) {
|
||||
return _.truncate(JSON.stringify(value), { length: 20 })
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slot_row {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Original N-SideBar styles */
|
||||
._sb_dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: grey;
|
||||
}
|
||||
|
||||
.node_header {
|
||||
line-height: 1;
|
||||
padding: 8px 13px 7px;
|
||||
background: var(--comfy-input-bg);
|
||||
margin-bottom: 5px;
|
||||
font-size: 15px;
|
||||
text-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headdot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
float: inline-start;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.IMAGE {
|
||||
background-color: #64b5f6;
|
||||
}
|
||||
|
||||
.VAE {
|
||||
background-color: #ff6e6e;
|
||||
}
|
||||
|
||||
.LATENT {
|
||||
background-color: #ff9cf9;
|
||||
}
|
||||
|
||||
.MASK {
|
||||
background-color: #81c784;
|
||||
}
|
||||
|
||||
.CONDITIONING {
|
||||
background-color: #ffa931;
|
||||
}
|
||||
|
||||
.CLIP {
|
||||
background-color: #ffd500;
|
||||
}
|
||||
|
||||
.MODEL {
|
||||
background-color: #b39ddb;
|
||||
}
|
||||
|
||||
.CONTROL_NET {
|
||||
background-color: #a5d6a7;
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: small;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
min-width: 300px;
|
||||
width: min-content;
|
||||
height: fit-content;
|
||||
z-index: 9999;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
._sb_node_preview ._sb_description {
|
||||
margin: 10px;
|
||||
padding: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 5px;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
._sb_table {
|
||||
display: grid;
|
||||
|
||||
grid-column-gap: 10px;
|
||||
/* Spazio tra le colonne */
|
||||
width: 100%;
|
||||
/* Imposta la larghezza della tabella al 100% del contenitore */
|
||||
}
|
||||
|
||||
._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;
|
||||
}
|
||||
|
||||
._sb_row_string {
|
||||
grid-template-columns: 10px 1fr 1fr 10fr 1fr;
|
||||
}
|
||||
|
||||
._sb_col {
|
||||
border: 0px solid #000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
flex-wrap: nowrap;
|
||||
align-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
._sb_inherit {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
._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 {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
._sb_preview_badge {
|
||||
text-align: center;
|
||||
background: var(--comfy-input-bg);
|
||||
font-weight: bold;
|
||||
color: var(--error-text);
|
||||
}
|
||||
</style>
|
||||
178
src/components/NodeSearchBox.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="comfy-vue-node-search-container">
|
||||
<div class="comfy-vue-node-preview-container">
|
||||
<NodePreview
|
||||
:nodeDef="hoveredSuggestion"
|
||||
:key="hoveredSuggestion?.name || ''"
|
||||
v-if="hoveredSuggestion"
|
||||
/>
|
||||
</div>
|
||||
<NodeSearchFilter @addFilter="onAddFilter" />
|
||||
<AutoCompletePlus
|
||||
:model-value="props.filters"
|
||||
class="comfy-vue-node-search-box"
|
||||
scrollHeight="40vh"
|
||||
:placeholder="placeholder"
|
||||
:input-id="inputId"
|
||||
append-to="self"
|
||||
:suggestions="suggestions"
|
||||
:min-length="0"
|
||||
@complete="search($event.query)"
|
||||
@option-select="emit('addNode', $event.value)"
|
||||
@focused-option-changed="setHoverSuggestion($event)"
|
||||
complete-on-focus
|
||||
auto-option-focus
|
||||
force-selection
|
||||
multiple
|
||||
>
|
||||
<template v-slot:option="{ option }">
|
||||
<div class="option-container">
|
||||
<div class="option-display-name">
|
||||
{{ option.display_name }}
|
||||
<NodeSourceChip
|
||||
v-if="option.python_module !== undefined"
|
||||
:python_module="option.python_module"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="option.description" class="option-description">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- FilterAndValue -->
|
||||
<template v-slot:chip="{ value }">
|
||||
<Chip removable @remove="onRemoveFilter($event, value)">
|
||||
<Badge size="small" :class="value[0].invokeSequence + '-badge'">
|
||||
{{ value[0].invokeSequence.toUpperCase() }}
|
||||
</Badge>
|
||||
{{ value[1] }}
|
||||
</Chip>
|
||||
</template>
|
||||
</AutoCompletePlus>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
},
|
||||
searchLimit: {
|
||||
type: Number,
|
||||
default: 64
|
||||
}
|
||||
})
|
||||
|
||||
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' : ''
|
||||
})
|
||||
|
||||
const search = (query: string) => {
|
||||
suggestions.value = useNodeDefStore().nodeSearchService.searchNode(
|
||||
query,
|
||||
props.filters,
|
||||
{
|
||||
limit: props.searchLimit
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
|
||||
|
||||
const reFocusInput = () => {
|
||||
const inputElement = document.getElementById(inputId) as HTMLInputElement
|
||||
if (inputElement) {
|
||||
inputElement.blur()
|
||||
inputElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reFocusInput)
|
||||
const onAddFilter = (filterAndValue: FilterAndValue) => {
|
||||
emit('addFilter', filterAndValue)
|
||||
reFocusInput()
|
||||
}
|
||||
const onRemoveFilter = (event: Event, filterAndValue: FilterAndValue) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
emit('removeFilter', filterAndValue)
|
||||
reFocusInput()
|
||||
}
|
||||
const setHoverSuggestion = (index: number) => {
|
||||
if (index === -1) {
|
||||
hoveredSuggestion.value = null
|
||||
return
|
||||
}
|
||||
const value = suggestions.value[index]
|
||||
hoveredSuggestion.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-vue-node-search-container {
|
||||
@apply flex justify-center items-center w-full min-w-96;
|
||||
}
|
||||
|
||||
.comfy-vue-node-search-container * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.comfy-vue-node-preview-container {
|
||||
position: absolute;
|
||||
left: -350px;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.comfy-vue-node-search-box {
|
||||
@apply z-10 flex-grow;
|
||||
}
|
||||
|
||||
.option-container {
|
||||
@apply flex flex-col px-4 py-2 cursor-pointer overflow-hidden w-full;
|
||||
}
|
||||
|
||||
.option-container:hover .option-description {
|
||||
@apply overflow-visible;
|
||||
/* Allows text to wrap */
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.option-display-name {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
@apply text-sm text-gray-400 overflow-hidden text-ellipsis;
|
||||
/* Keeps the text on a single line by default */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.i-badge {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
|
||||
.o-badge {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
|
||||
.c-badge {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
|
||||
.s-badge {
|
||||
@apply bg-yellow-500;
|
||||
}
|
||||
</style>
|
||||
140
src/components/NodeSearchBoxPopover.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
pt:root="invisible-dialog-root"
|
||||
pt:mask="node-search-box-dialog-mask"
|
||||
modal
|
||||
:dismissable-mask="dismissable"
|
||||
@hide="clearFilters"
|
||||
>
|
||||
<template #container>
|
||||
<NodeSearchBox
|
||||
:filters="nodeFilters"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { app } from '@/scripts/app'
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { LiteGraphCanvasEvent, ConnectingLink } from '@comfyorg/litegraph'
|
||||
import { FilterAndValue } from '@/services/nodeSearchService'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { ConnectingLinkImpl } from '@/types/litegraphTypes'
|
||||
|
||||
interface LiteGraphPointerEvent extends Event {
|
||||
canvasX: number
|
||||
canvasY: number
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
const originalEvent = triggerEvent.value.detail
|
||||
.originalEvent as LiteGraphPointerEvent
|
||||
return [originalEvent.canvasX, originalEvent.canvasY]
|
||||
}
|
||||
const nodeFilters = reactive([])
|
||||
const addFilter = (filter: FilterAndValue) => {
|
||||
nodeFilters.push(filter)
|
||||
}
|
||||
const removeFilter = (filter: FilterAndValue) => {
|
||||
const index = nodeFilters.findIndex((f) => f === filter)
|
||||
if (index !== -1) {
|
||||
nodeFilters.splice(index, 1)
|
||||
}
|
||||
}
|
||||
const clearFilters = () => {
|
||||
nodeFilters.splice(0, nodeFilters.length)
|
||||
}
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
closeDialog()
|
||||
|
||||
const node = app.addNodeOnGraph(nodeDef, { pos: getNewNodeLocation() })
|
||||
|
||||
const eventDetail = triggerEvent.value.detail
|
||||
if (eventDetail.subType === 'empty-release') {
|
||||
eventDetail.linkReleaseContext.links.forEach((link: ConnectingLink) => {
|
||||
ConnectingLinkImpl.createFromPlainObject(link).connectTo(node)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
|
||||
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') {
|
||||
const context = e.detail.linkReleaseContext
|
||||
if (context.links.length === 0) {
|
||||
console.warn('Empty release with no links! This should never happen')
|
||||
return
|
||||
}
|
||||
const firstLink = ConnectingLinkImpl.createFromPlainObject(context.links[0])
|
||||
const filter = useNodeDefStore().nodeSearchService.getFilterById(
|
||||
firstLink.releaseSlotType
|
||||
)
|
||||
const dataType = firstLink.type
|
||||
addFilter([filter, dataType])
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('litegraph:canvas', canvasEventHandler)
|
||||
document.addEventListener('keydown', handleEscapeKeyPress)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('litegraph:canvas', canvasEventHandler)
|
||||
document.removeEventListener('keydown', handleEscapeKeyPress)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.invisible-dialog-root {
|
||||
width: 30%;
|
||||
min-width: 24rem;
|
||||
max-width: 48rem;
|
||||
border: 0 !important;
|
||||
background-color: transparent !important;
|
||||
margin-top: 25vh;
|
||||
}
|
||||
|
||||
.node-search-box-dialog-mask {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
</style>
|
||||
97
src/components/NodeSearchFilter.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<Button
|
||||
icon="pi pi-filter"
|
||||
severity="secondary"
|
||||
class="_filter-button"
|
||||
@click="showModal"
|
||||
/>
|
||||
<Dialog v-model:visible="visible" class="_dialog">
|
||||
<template #header>
|
||||
<h3>Add node filter condition</h3>
|
||||
</template>
|
||||
<div class="_dialog-body">
|
||||
<SelectButton
|
||||
v-model="selectedFilter"
|
||||
:options="filters"
|
||||
:allowEmpty="false"
|
||||
optionLabel="name"
|
||||
@change="updateSelectedFilterValue"
|
||||
/>
|
||||
<AutoComplete
|
||||
v-model="selectedFilterValue"
|
||||
:suggestions="filterValues"
|
||||
:min-length="0"
|
||||
@complete="(event) => updateFilterValues(event.query)"
|
||||
completeOnFocus
|
||||
forceSelection
|
||||
dropdown
|
||||
></AutoComplete>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="button" label="Add" @click="submit"></Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 filters = ref<NodeFilter[]>([])
|
||||
const selectedFilter = ref<NodeFilter>()
|
||||
const filterValues = ref<string[]>([])
|
||||
const selectedFilterValue = ref<string>('')
|
||||
|
||||
onMounted(() => {
|
||||
const nodeSearchService = useNodeDefStore().nodeSearchService
|
||||
filters.value = nodeSearchService.nodeFilters
|
||||
selectedFilter.value = nodeSearchService.nodeFilters[0]
|
||||
})
|
||||
|
||||
const emit = defineEmits(['addFilter'])
|
||||
|
||||
const updateSelectedFilterValue = () => {
|
||||
updateFilterValues('')
|
||||
if (filterValues.value.includes(selectedFilterValue.value)) {
|
||||
return
|
||||
}
|
||||
selectedFilterValue.value = filterValues.value[0]
|
||||
}
|
||||
|
||||
const updateFilterValues = (query: string) => {
|
||||
filterValues.value = selectedFilter.value.fuseSearch.search(query)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
visible.value = false
|
||||
emit('addFilter', [
|
||||
selectedFilter.value,
|
||||
selectedFilterValue.value
|
||||
] as FilterAndValue)
|
||||
}
|
||||
|
||||
const showModal = () => {
|
||||
updateSelectedFilterValue()
|
||||
visible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
._filter-button {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
._dialog {
|
||||
@apply min-w-96;
|
||||
}
|
||||
|
||||
._dialog-body {
|
||||
@apply flex flex-col space-y-2;
|
||||
}
|
||||
</style>
|
||||
29
src/components/NodeSourceChip.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<Chip :class="nodeSource.className">
|
||||
{{ nodeSource.displayText }}
|
||||
</Chip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getNodeSource } from '@/types/nodeSource'
|
||||
import Chip from 'primevue/chip'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
python_module: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const nodeSource = computed(() => getNodeSource(props.python_module))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-core,
|
||||
.comfy-custom-nodes,
|
||||
.comfy-unknown {
|
||||
font-size: small;
|
||||
font-weight: lighter;
|
||||
}
|
||||
</style>
|
||||
72
src/components/graph/GraphCanvas.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<teleport to=".graph-canvas-container">
|
||||
<LiteGraphCanvasSplitterOverlay v-if="betaMenuEnabled">
|
||||
<template #side-bar-panel>
|
||||
<SideToolBar />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
<canvas ref="canvasRef" id="graph-canvas" tabindex="1" />
|
||||
</teleport>
|
||||
<NodeSearchboxPopover v-if="nodeSearchEnabled" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SideToolBar from '@/components/sidebar/SideToolBar.vue'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import NodeSearchboxPopover from '@/components/NodeSearchBoxPopover.vue'
|
||||
import { ref, computed, onUnmounted, watch, onMounted } from 'vue'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
|
||||
|
||||
const emit = defineEmits(['ready'])
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
const nodeSearchEnabled = computed<boolean>(
|
||||
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
|
||||
)
|
||||
watch(nodeSearchEnabled, (newVal) => {
|
||||
if (comfyApp.canvas) comfyApp.canvas.allow_searchbox = !newVal
|
||||
})
|
||||
|
||||
let dropTargetCleanup = () => {}
|
||||
|
||||
onMounted(async () => {
|
||||
comfyApp.vueAppReady = true
|
||||
|
||||
workspaceStore.spinner = true
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
comfyApp.canvas.allow_searchbox = !nodeSearchEnabled.value
|
||||
workspaceStore.spinner = false
|
||||
|
||||
window['app'] = comfyApp
|
||||
window['graph'] = comfyApp.graph
|
||||
|
||||
dropTargetCleanup = dropTargetForElements({
|
||||
element: canvasRef.value,
|
||||
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 = comfyApp.clientPosToCanvasPos([loc.clientX - 20, loc.clientY])
|
||||
const comfyNodeName = event.source.element.getAttribute(
|
||||
'data-comfy-node-name'
|
||||
)
|
||||
const nodeDef = useNodeDefStore().nodeDefsByName[comfyNodeName]
|
||||
comfyApp.addNodeOnGraph(nodeDef, { pos })
|
||||
}
|
||||
})
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
dropTargetCleanup()
|
||||
})
|
||||
</script>
|
||||
24
src/components/primevueOverride/AutoCompletePlus.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- Auto complete with extra event "focused-option-changed" -->
|
||||
<script>
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
|
||||
export default {
|
||||
name: 'AutoCompletePlus',
|
||||
extends: AutoComplete,
|
||||
emits: ['focused-option-changed'],
|
||||
mounted() {
|
||||
if (typeof AutoComplete.mounted === 'function') {
|
||||
AutoComplete.mounted.call(this)
|
||||
}
|
||||
|
||||
// Add a watcher on the focusedOptionIndex property
|
||||
this.$watch(
|
||||
() => this.focusedOptionIndex,
|
||||
(newVal, oldVal) => {
|
||||
// Emit a custom event when focusedOptionIndex changes
|
||||
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>
|
||||
120
src/components/sidebar/tabs/NodeLibrarySideBarTab.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<SideBarTabTemplate :title="$t('sideToolBar.nodeLibrary')">
|
||||
<template #tool-buttons>
|
||||
<ToggleButton
|
||||
v-model:model-value="alphabeticalSort"
|
||||
on-icon="pi pi-sort-alpha-down"
|
||||
off-icon="pi pi-sort-alt"
|
||||
aria-label="Sort"
|
||||
:pt="{
|
||||
label: { style: { display: 'none' } }
|
||||
}"
|
||||
v-tooltip="$t('sideToolBar.nodeLibraryTab.sortOrder')"
|
||||
>
|
||||
</ToggleButton>
|
||||
</template>
|
||||
<template #body>
|
||||
<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>
|
||||
</SideBarTabTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Badge from 'primevue/badge'
|
||||
import ToggleButton from 'primevue/togglebutton'
|
||||
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'
|
||||
import SideBarTabTemplate from '@/components/sidebar/tabs/SideBarTabTemplate.vue'
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const alphabeticalSort = ref(false)
|
||||
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(() =>
|
||||
alphabeticalSort.value ? nodeDefStore.sortedNodeTree : nodeDefStore.nodeTree
|
||||
)
|
||||
const renderedRoot = computed(() => {
|
||||
return fillNodeInfo(root.value)
|
||||
})
|
||||
const fillNodeInfo = (node: TreeNode): TreeNode => {
|
||||
const isExpanded = expandedKeys.value[node.key]
|
||||
const icon = node.leaf
|
||||
? 'pi pi-circle-fill'
|
||||
: isExpanded
|
||||
? 'pi pi-folder-open'
|
||||
: 'pi pi-folder'
|
||||
const children = node.children?.map(fillNodeInfo)
|
||||
|
||||
return {
|
||||
...node,
|
||||
icon,
|
||||
children,
|
||||
type: node.leaf ? 'node' : 'folder',
|
||||
totalNodes: node.leaf
|
||||
? 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 v-else>
|
||||
<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>
|
||||
65
src/components/sidebar/tabs/SideBarTabTemplate.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="comfy-vue-side-bar-container">
|
||||
<Toolbar class="comfy-vue-side-bar-header">
|
||||
<template #start>
|
||||
<span class="comfy-vue-side-bar-header-span">{{
|
||||
props.title.toUpperCase()
|
||||
}}</span>
|
||||
</template>
|
||||
<template #end>
|
||||
<slot name="tool-buttons"></slot>
|
||||
</template>
|
||||
</Toolbar>
|
||||
<div class="comfy-vue-side-bar-body">
|
||||
<slot name="body"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Toolbar from 'primevue/toolbar'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-vue-side-bar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comfy-vue-side-bar-header {
|
||||
flex-shrink: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.comfy-vue-side-bar-header-span {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.comfy-vue-side-bar-body {
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.comfy-vue-side-bar-body::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.comfy-vue-side-bar-body::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
</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)
|
||||
|
||||