Compare commits

..

3 Commits

Author SHA1 Message Date
Austin Mroz
72535eb88d Type cleanup, annotation as function, fix import
Naming now uniformly refers to the widget as an Annotated Number and the
widget is now implemented as a proper typescript class

annotatedNumber widgets can optionally have an annotation function set.
This allows for extensions to set annotations on ranges of values.

Fixed the app import incorrectly having an extension
2024-09-26 15:17:06 -05:00
Austin Mroz
330633355a Type correctness, widget conversion
Type checking is satisfied aside from the forced widget type

Widgets defined in python code as INT or FLOAT are now converted if the
options includes relevant keys. This grants functionality similar to the
multiline option on strings

Colors now correctly follow themes
2024-09-26 03:25:32 -05:00
Austin Mroz
9c3cf86381 POC for an annotated number widget 2024-09-26 03:25:28 -05:00
216 changed files with 2141 additions and 6944 deletions

View File

@@ -12,10 +12,6 @@ DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# to ComfyUI launch script to serve the custom web version.
DEPLOY_COMFYUI_DIR=/home/ComfyUI/web
# The directory containing the ComfyUI installation used to run Playwright tests.
# If you aren't using a separate install for testing, point this to your regular install.
TEST_COMFYUI_DIR=/home/ComfyUI
# The directory containing the ComfyUI_examples repo used to extract test workflows.
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples

53
.github/workflows/update-main.yaml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Update Main Repo from PR
on:
pull_request:
types: [labeled]
jobs:
update-main-repo:
if: github.event.label.name == 'Update Main Repo'
runs-on: ubuntu-latest
steps:
- name: Checkout frontend repo PR
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Checkout ComfyUI
uses: actions/checkout@v4
with:
repository: "comfyanonymous/ComfyUI"
path: ComfyUI
ref: master
- name: Copy compiled assets
run: |
rm -rf ./ComfyUI/web/*
cp -R dist/* ./ComfyUI/web/
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.PAT }}
commit-message: 'Update frontend assets from PR #${{ github.event.pull_request.number }}'
title: 'Update frontend assets from PR #${{ github.event.pull_request.number }}'
body: |
This PR updates the compiled frontend assets from PR #${{ github.event.pull_request.number }} in the frontend repo.
Frontend PR: ${{ github.event.pull_request.html_url }}
branch: update-frontend-assets-pr-${{ github.event.pull_request.number }}
base: main
path: ComfyUI

View File

@@ -2,6 +2,5 @@
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80
"trailingComma": "none"
}

134
README.md
View File

@@ -33,11 +33,11 @@
### Nightly Release
Nightly releases are published daily at [https://github.com/Comfy-Org/ComfyUI_frontend/releases](https://github.com/Comfy-Org/ComfyUI_frontend/releases).
Nightly releases are published daily at [https://github.com/Comfy-Org/ComfyUI_frontend/releases](https://github.com/Comfy-Org/ComfyUI_frontend/releases).
To use the latest nightly release, add the following command line argument to your ComfyUI launch script:
```bat
```
--front-end-version Comfy-Org/ComfyUI_frontend@latest
```
@@ -62,23 +62,6 @@ There will be a 2-day feature freeze before each stable release. During this per
### Major features
<details>
<summary>v1.3.7: Keybinding customization</summary>
## Basic UI
![image](https://github.com/user-attachments/assets/c84a1609-3880-48e0-a746-011f36beda68)
## Reset button
![image](https://github.com/user-attachments/assets/4d2922da-bb4f-4f90-8017-a8e4a0db07c7)
## Edit Keybinding
![image](https://github.com/user-attachments/assets/77626b7a-cb46-48f8-9465-e03120aac66a)
![image](https://github.com/user-attachments/assets/79131a4e-75c6-4715-bd11-c6aaed887779)
[rec.webm](https://github.com/user-attachments/assets/a3984ed9-eb28-4d47-86c0-7fc3efc2b5d0)
</details>
<details>
<summary>v1.2.4: Node library sidebar tab</summary>
@@ -107,46 +90,6 @@ https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
### QoL changes
<details>
<summary>v1.3.6: **Litegraph** Toggle link visibility</summary>
[rec.webm](https://github.com/user-attachments/assets/34e460ac-fbbc-44ef-bfbb-99a84c2ae2be)
</details>
<details>
<summary>v1.3.4: **Litegraph** Auto widget to input conversion</summary>
Dropping a link of correct type on node widget will automatically convert the widget to input.
[rec.webm](https://github.com/user-attachments/assets/15cea0b0-b225-4bec-af50-2cdb16dc46bf)
</details>
<details>
<summary>v1.3.4: **Litegraph** Canvas pan mode</summary>
The canvas becomes readonly in pan mode. Pan mode is activated by clicking the pan mode button on the canvas menu
or by holding the space key.
[rec.webm](https://github.com/user-attachments/assets/c7872532-a2ac-44c1-9e7d-9e03b5d1a80b)
</details>
<details>
<summary>v1.3.1: **Litegraph** Shift drag link to create a new link</summary>
[rec.webm](https://github.com/user-attachments/assets/7e73aaf9-79e2-4c3c-a26a-911cba3b85e4)
</details>
<details>
<summary>v1.2.62: **Litegraph** Show optional input slots as donuts</summary>
![GYEIRidb0AYGO-v](https://github.com/user-attachments/assets/e6cde0b6-654b-4afd-a117-133657a410b1)
</details>
<details>
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
@@ -195,59 +138,13 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
### Node developers API
<details>
<summary>v1.3.7: Register commands and keybindings</summary>
Extensions can call the following API to register commands and keybindings. Do
note that keybindings defined in core cannot be overwritten, and some keybindings
are reserved by the browser.
```js
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'TestCommand',
function: () => {
alert('TestCommand')
}
}
],
keybindings: [
{
combo: { key: 'k' },
commandId: 'TestCommand'
}
]
})
```
</details>
<details>
<summary>v1.3.1: Extension API to register custom topbar menu items</summary>
Extensions can call the following API to register custom topbar menu items.
```js
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo-id',
label: 'foo',
function: () => {
alert(1)
}
}
],
menuCommands: [
{
path: ['ext', 'ext2'],
commands: ['foo-id']
}
]
})
app.extensionManager.menu.registerTopbarCommands(["ext", "ext2"], [{id:"foo", label: "foo", function: () => alert(1)}])
```
![image](https://github.com/user-attachments/assets/ae7b082f-7ce9-4549-a446-4563567102fe)
@@ -296,6 +193,31 @@ We will support custom icons later.
![image](https://github.com/user-attachments/assets/7bff028a-bf91-4cab-bf97-55c243b3f5e0)
</details>
## Road Map
### What has been done
- Migrate all code to TypeScript with minimal change modification to the original logic.
- Bundle all code with Vite's rollup build.
- Added a shim layer to be backward compatible with the existing extension system. <https://github.com/huchenlei/ComfyUI_frontend/pull/15>
- 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>.
- Replace the existing ComfyUI front-end implementation. <https://github.com/comfyanonymous/ComfyUI/pull/4379>
### What to be done
- Remove `@ts-ignore`s.
- Turn on `strict` on `tsconfig.json`.
- Add more widget types for node developers.
- LLM streaming node.
- Linear mode (Similar to InvokeAI's linear mode).
- Keybinding settings management. Register keybindings API for custom nodes.
## Development
### Git pre-commit hooks

View File

@@ -1,13 +1,12 @@
import type { Page, Locator, APIRequestContext } from '@playwright/test'
import { expect } from '@playwright/test'
import { test as base } from '@playwright/test'
import { ComfyActionbar } from './helpers/actionbar'
import { ComfyAppMenu } from './helpers/appMenu'
import dotenv from 'dotenv'
dotenv.config()
import * as fs from 'fs'
import { NodeBadgeMode } from '../src/types/nodeSource'
import type { NodeId } from '../src/types/comfyWorkflow'
import type { KeyCombo } from '../src/types/keyBindingTypes'
import { NodeId } from '../src/types/comfyWorkflow'
import { ManageGroupNode } from './helpers/manageGroupNode'
import { ComfyTemplates } from './helpers/templates'
@@ -192,8 +191,12 @@ class WorkflowsSidebarTab extends SidebarTab {
return this.page.locator('.new-blank-workflow-button')
}
get openWorkflowButton() {
return this.page.locator('.open-workflow-button')
get browseWorkflowsButton() {
return this.page.locator('.browse-workflows-button')
}
get newDefaultWorkflowButton() {
return this.page.locator('.new-default-workflow-button')
}
async getOpenedWorkflowNames() {
@@ -227,33 +230,6 @@ class Topbar {
.allInnerTexts()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
async getMenuItem(itemLabel: string): Promise<Locator> {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
async getWorkflowTab(tabName: string): Promise<Locator> {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
.locator('..')
}
async closeWorkflowTab(tabName: string) {
const tab = await this.getWorkflowTab(tabName)
await tab.locator('.close-button').click({ force: true })
}
async saveWorkflow(workflowName: string) {
await this.triggerTopbarCommand(['Workflow', 'Save'])
await this.page.locator('.p-dialog-content input').fill(workflowName)
await this.page.keyboard.press('Enter')
// Wait for the dialog to close.
await this.page.waitForTimeout(300)
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
@@ -261,18 +237,16 @@ class Topbar {
const tabName = path[0]
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
`.top-menubar .p-menubar-item:has-text("${tabName}")`
)
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.click()
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = this.page
.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
)
.first()
const menuItem = this.page.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
)
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
@@ -296,6 +270,21 @@ class ComfyMenu {
.nth(0)
}
async saveWorkflow(name: string) {
const acceptDialog = async (dialog) => {
await dialog.accept(name)
}
this.page.on('dialog', acceptDialog)
await this.saveButton.click()
// Wait a moment to ensure the dialog has been handled
await this.page.waitForTimeout(300)
// Remove the dialog listener
this.page.off('dialog', acceptDialog)
}
get nodeLibraryTab() {
return new NodeLibrarySidebarTab(this.page)
}
@@ -352,7 +341,7 @@ export class ComfyPage {
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
public readonly actionbar: ComfyActionbar
public readonly appMenu: ComfyAppMenu
public readonly templates: ComfyTemplates
constructor(
@@ -367,7 +356,7 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.appMenu = new ComfyAppMenu(page)
this.templates = new ComfyTemplates(page)
}
@@ -392,16 +381,6 @@ export class ComfyPage {
})
}
async getSelectedGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return (
window['app']?.graph?.nodes?.filter(
(node: any) => node.is_selected === true
).length || 0
)
})
}
async setupWorkflowsDirectory(structure: FolderStructure) {
const resp = await this.request.post(
`${this.url}/api/devtools/setup_folder_structure`,
@@ -459,72 +438,12 @@ export class ComfyPage {
'Comfy.NodeBadge.NodeSourceBadgeMode',
NodeBadgeMode.None
)
// Hide canvas menu by default.
await this.setSetting('Comfy.Graph.CanvasMenu', false)
}
public assetPath(fileName: string) {
return `./browser_tests/assets/${fileName}`
}
async executeCommand(commandId: string) {
await this.page.evaluate((id: string) => {
return window['app'].extensionManager.command.execute(id)
}, commandId)
}
async registerCommand(
commandId: string,
command: (() => void) | (() => Promise<void>)
) {
await this.page.evaluate(
({ commandId, commandStr }) => {
const app = window['app']
const randomSuffix = Math.random().toString(36).substring(2, 8)
const extensionName = `TestExtension_${randomSuffix}`
app.registerExtension({
name: extensionName,
commands: [
{
id: commandId,
function: eval(commandStr)
}
]
})
},
{ commandId, commandStr: command.toString() }
)
}
async registerKeybinding(keyCombo: KeyCombo, command: () => void) {
await this.page.evaluate(
({ keyCombo, commandStr }) => {
const app = window['app']
const randomSuffix = Math.random().toString(36).substring(2, 8)
const extensionName = `TestExtension_${randomSuffix}`
const commandId = `TestCommand_${randomSuffix}`
app.registerExtension({
name: extensionName,
keybindings: [
{
combo: keyCombo,
commandId: commandId
}
],
commands: [
{
id: commandId,
function: eval(commandStr)
}
]
})
},
{ keyCombo, commandStr: command.toString() }
)
}
async setSetting(settingId: string, settingValue: any) {
return await this.page.evaluate(
async ({ id, value }) => {
@@ -575,10 +494,6 @@ export class ComfyPage {
await this.nextFrame()
}
async getVisibleToastCount() {
return await this.page.locator('.p-toast:visible').count()
}
async clickTextEncodeNode1() {
await this.canvas.click({
position: {
@@ -725,10 +640,9 @@ export class ComfyPage {
y: 645
}
})
const dialogInput = page.locator('.graphdialog input[type="text"]')
await dialogInput.click()
await dialogInput.fill('128')
await dialogInput.press('Enter')
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()
}
@@ -812,43 +726,46 @@ export class ComfyPage {
await this.nextFrame()
}
async ctrlSend(keyToPress: string) {
async ctrlC() {
await this.page.keyboard.down('Control')
await this.page.keyboard.press(keyToPress)
await this.page.keyboard.press('KeyC')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlA() {
await this.ctrlSend('KeyA')
}
async ctrlB() {
await this.ctrlSend('KeyB')
}
async ctrlC() {
await this.ctrlSend('KeyC')
}
async ctrlV() {
await this.ctrlSend('KeyV')
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyV')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlZ() {
await this.ctrlSend('KeyZ')
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyZ')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlY() {
await this.ctrlSend('KeyY')
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyY')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlArrowUp() {
await this.ctrlSend('ArrowUp')
await this.page.keyboard.down('Control')
await this.page.keyboard.press('ArrowUp')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlArrowDown() {
await this.ctrlSend('ArrowDown')
await this.page.keyboard.down('Control')
await this.page.keyboard.press('ArrowDown')
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async closeMenu() {
@@ -962,15 +879,13 @@ export class ComfyPage {
return new NodeReference(id, this)
}
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
return Promise.all(
(
await this.page.evaluate((type) => {
return window['app'].graph.nodes
.filter((n) => n.type === type)
.map((n) => n.id)
}, type)
).map((id: NodeId) => this.getNodeRefById(id))
)
return (
await this.page.evaluate((type) => {
return window['app'].graph.nodes
.filter((n) => n.type === type)
.map((n) => n.id)
}, type)
).map((id: NodeId) => this.getNodeRefById(id))
}
async getFirstNodeRef(): Promise<NodeReference | null> {
const id = await this.page.evaluate(() => {
@@ -979,12 +894,8 @@ export class ComfyPage {
if (!id) return null
return this.getNodeRefById(id)
}
async moveMouseToEmptyArea() {
await this.page.mouse.move(10, 10)
}
}
export class NodeSlotReference {
class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
@@ -1034,37 +945,7 @@ export class NodeSlotReference {
)
}
}
export class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const [x, y, w, h] = node.getBounding()
return window['app'].canvas.ds.convertOffsetToCanvas([
x + w / 2,
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
])
},
[this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
}
export class NodeReference {
class NodeReference {
constructor(
readonly id: NodeId,
readonly comfyPage: ComfyPage
@@ -1087,20 +968,6 @@ export class NodeReference {
y: pos[1]
}
}
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
return {
x,
y,
width,
height
}
}
async getSize(): Promise<Size> {
const size = await this.getProperty<[number, number]>('size')
return {
@@ -1108,18 +975,6 @@ export class NodeReference {
height: size[1]
}
}
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
return await this.getProperty('flags')
}
async isPinned() {
return !!(await this.getFlags()).pinned
}
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
@@ -1136,58 +991,22 @@ export class NodeReference {
async getInput(index: number) {
return new NodeSlotReference('input', index, this)
}
async getWidget(index: number) {
return new NodeWidgetReference(index, this)
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
) {
async click(position: 'title', options?: Parameters<Page['click']>[1]) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
break
case 'collapse':
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y + 15 }
break
default:
throw new Error(`Invalid click position ${position}`)
}
const moveMouseToEmptyArea = options?.moveMouseToEmptyArea
if (options) {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
await this.comfyPage.moveMouseToEmptyArea()
}
}
async copy() {
await this.click('title')
await this.comfyPage.ctrlC()
await this.comfyPage.nextFrame()
}
async connectWidget(
originSlotIndex: number,
targetNode: NodeReference,
targetWidgetIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetWidget.getPosition()
)
return originSlot
}
async connectOutput(
originSlotIndex: number,
@@ -1202,11 +1021,6 @@ export class NodeReference {
)
return originSlot
}
async getContextMenuOptionNames() {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
return await ctx.locator('.litemenu-entry').allInnerTexts()
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
@@ -1243,35 +1057,3 @@ export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
await use(comfyPage)
}
})
const makeMatcher = function <T>(
getValue: (node: NodeReference) => Promise<T> | T,
type: string
) {
return async function (
node: NodeReference,
options?: { timeout?: number; intervals?: number[] }
) {
const value = await getValue(node)
let assertion = expect(
value,
'Node is ' + (this.isNot ? '' : 'not ') + type
)
if (this.isNot) {
assertion = assertion.not
}
await expect(async () => {
assertion.toBeTruthy()
}).toPass({ timeout: 250, ...options })
return {
pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
}
}
}
export const comfyExpect = expect.extend({
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed')
})

View File

@@ -5,7 +5,7 @@ This document outlines the setup and usage of Playwright for testing the ComfyUI
## WARNING
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
If `TEST_COMFYUI_DIR` in `.env` isn't set to your `(Comfy Path)/ComfyUI` directory, these changes won't be automatically restored.
Please backup your ComfyUI data before running the tests locally.
## Setup

View File

@@ -6,9 +6,9 @@ import { webSocketFixture } from './fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Actionbar', () => {
test.describe('AppMenu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
@@ -23,12 +23,12 @@ test.describe('Actionbar', () => {
ws
}) => {
// Enable change auto-queue mode
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
const queueOpts = await comfyPage.appMenu.queueButton.toggleOptions()
expect(await queueOpts.getMode()).toBe('disabled')
await queueOpts.setMode('change')
await comfyPage.nextFrame()
expect(await queueOpts.getMode()).toBe('change')
await comfyPage.actionbar.queueButton.toggleOptions()
await comfyPage.appMenu.queueButton.toggleOptions()
// Intercept the prompt queue endpoint
let promptNumber = 0
@@ -113,15 +113,4 @@ test.describe('Actionbar', () => {
).toBe(END)
expect(promptNumber, 'queued prompt count should be 2').toBe(2)
})
test('Can dock actionbar into top menu', async ({ comfyPage }) => {
await comfyPage.page.dragAndDrop(
'.actionbar .drag-handle',
'.comfyui-menu',
{
targetPosition: { x: 0, y: 0 }
}
)
expect(await comfyPage.actionbar.isDocked()).toBe(true)
})
})

View File

@@ -1,404 +0,0 @@
{
"last_node_id": 10,
"last_link_id": 9,
"nodes": [
{
"id": 10,
"type": "workflow>group_node",
"pos": {
"0": 26,
"1": 186
},
"size": {
"0": 400,
"1": 390
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "workflow>group_node"
},
"widgets_values": [
512,
512,
1,
"v1-5-pruned-emaonly.ckpt",
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
"text, watermark",
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1,
"ComfyUI"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"groupNodes": {
"group_node": {
"nodes": [
{
"id": -1,
"type": "EmptyLatentImage",
"pos": {
"0": 473,
"1": 609
},
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
],
"index": 0
},
{
"id": -1,
"type": "CheckpointLoaderSimple",
"pos": {
"0": 26,
"1": 474
},
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"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": [
"v1-5-pruned-emaonly.ckpt"
],
"index": 1
},
{
"id": -1,
"type": "CLIPTextEncode",
"pos": {
"0": 415,
"1": 186
},
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"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,"
],
"index": 2
},
{
"id": -1,
"type": "CLIPTextEncode",
"pos": {
"0": 413,
"1": 389
},
"size": {
"0": 425.27801513671875,
"1": 180.6060791015625
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"text, watermark"
],
"index": 3
},
{
"id": -1,
"type": "KSampler",
"pos": {
"0": 863,
"1": 186
},
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
],
"index": 4
},
{
"id": -1,
"type": "VAEDecode",
"pos": {
"0": 1209,
"1": 188
},
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"index": 5
},
{
"id": -1,
"type": "SaveImage",
"pos": {
"0": 1451,
"1": 189
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUI"
],
"index": 6
}
],
"links": [
[
1,
1,
2,
0,
4,
"CLIP"
],
[
1,
1,
3,
0,
4,
"CLIP"
],
[
1,
0,
4,
0,
4,
"MODEL"
],
[
2,
0,
4,
1,
6,
"CONDITIONING"
],
[
3,
0,
4,
2,
7,
"CONDITIONING"
],
[
0,
0,
4,
3,
5,
"LATENT"
],
[
4,
0,
5,
0,
3,
"LATENT"
],
[
1,
2,
5,
1,
4,
"VAE"
],
[
5,
0,
6,
0,
8,
"IMAGE"
]
],
"external": []
}
}
},
"version": 0.4
}

View File

@@ -1,128 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "ControlNetApplyAdvanced",
"pos": {
"0": 449,
"1": 204
},
"size": [
340.20001220703125,
166
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "control_net",
"type": "CONTROL_NET",
"link": null
},
{
"name": "image",
"type": "IMAGE",
"link": null
},
{
"name": "strength",
"type": "FLOAT",
"link": 1,
"widget": {
"name": "strength"
}
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": null
},
{
"name": "negative",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "ControlNetApplyAdvanced"
},
"widgets_values": [
1,
0,
1
]
},
{
"id": 2,
"type": "PrimitiveNode",
"pos": {
"0": 177,
"1": 265
},
"size": [
210,
82
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [
1
],
"widget": {
"name": "strength"
}
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
1,
"fixed"
]
}
],
"links": [
[
1,
2,
0,
1,
4,
"FLOAT"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": {
"0": 47.541666666666515,
"1": 186.9375
}
}
},
"version": 0.4
}

View File

@@ -1,104 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "KSampler",
"pos": {
"0": 304.3653259277344,
"1": 42.15586471557617
},
"size": [
315,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"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": 1,
"type": "PrimitiveNode",
"pos": {
"0": 14,
"1": 43
},
"size": [
203.1999969482422,
40.368401303242536
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "connect to widget input",
"type": "*",
"links": [],
"slot_index": 0
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from './ComfyPage'
test.describe('Browser tab title', () => {
test.describe('Beta Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
@@ -16,7 +16,7 @@ test.describe('Browser tab title', () => {
return window['app'].workflowManager.activeWorkflow.name
})
// Note: unsaved workflow name is always prepended with "*".
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
})
// Broken by https://github.com/Comfy-Org/ComfyUI_frontend/pull/893
@@ -28,15 +28,15 @@ test.describe('Browser tab title', () => {
return window['app'].workflowManager.activeWorkflow.name
})
// Note: unsaved workflow name is always prepended with "*".
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
await comfyPage.menu.saveWorkflow('test')
expect(await comfyPage.page.title()).toBe('test - ComfyUI')
expect(await comfyPage.page.title()).toBe('test')
const textBox = comfyPage.widgetTextBox
await textBox.fill('Hello World')
await comfyPage.clickEmptySpace()
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`*test`)
// Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => {

View File

@@ -1,100 +0,0 @@
import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} from './ComfyPage'
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window['app'].canvas.emitBeforeChange()
})
}
async function afterChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window['app'].canvas.emitAfterChange()
})
}
test.describe('Change Tracker', () => {
test('Can group multiple change actions into a single transaction', async ({
comfyPage
}) => {
const node = (await comfyPage.getFirstNodeRef())!
expect(node).toBeTruthy()
await expect(node).not.toBeCollapsed()
await expect(node).not.toBeBypassed()
// Make changes outside set
// Bypass + collapse node
await node.click('collapse')
await comfyPage.ctrlB()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// Undo, undo, ensure both changes undone
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).toBeCollapsed()
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
// Run again, but within a change transaction
beforeChange(comfyPage)
await node.click('collapse')
await comfyPage.ctrlB()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// End transaction
afterChange(comfyPage)
// Ensure undo reverts both changes
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
})
test('Can group multiple transaction calls into a single one', async ({
comfyPage
}) => {
const node = (await comfyPage.getFirstNodeRef())!
const bypassAndPin = async () => {
await beforeChange(comfyPage)
await comfyPage.ctrlB()
await expect(node).toBeBypassed()
await comfyPage.page.keyboard.press('KeyP')
await comfyPage.nextFrame()
await expect(node).toBePinned()
await afterChange(comfyPage)
}
const collapse = async () => {
await beforeChange(comfyPage)
await node.click('collapse', { moveMouseToEmptyArea: true })
await expect(node).toBeCollapsed()
await afterChange(comfyPage)
}
const multipleChanges = async () => {
await beforeChange(comfyPage)
// Call other actions that uses begin/endChange
await collapse()
await bypassAndPin()
await afterChange(comfyPage)
}
await multipleChanges()
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).not.toBePinned()
await expect(node).not.toBeCollapsed()
await comfyPage.ctrlY()
await expect(node).toBeBypassed()
await expect(node).toBePinned()
await expect(node).toBeCollapsed()
})
})

View File

@@ -1,49 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Keybindings', () => {
test('Should execute command', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', () => {
window['foo'] = true
})
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
})
test('Should execute async command', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', async () => {
await new Promise<void>((resolve) =>
setTimeout(() => {
window['foo'] = true
resolve()
}, 5)
)
})
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
})
test('Should handle command errors', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', () => {
throw new Error('Test error')
})
await comfyPage.executeCommand('TestCommand')
await expect(comfyPage.page.locator('.p-toast')).toBeVisible()
})
test('Should handle async command errors', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', async () => {
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
reject(new Error('Test error'))
}, 5)
)
})
await comfyPage.executeCommand('TestCommand')
await expect(comfyPage.page.locator('.p-toast')).toBeVisible()
})
})

View File

@@ -86,23 +86,4 @@ test.describe('Copy Paste', () => {
await comfyPage.page.keyboard.up('Alt')
await expect(comfyPage.canvas).toHaveScreenshot('drag-copy-copied-node.png')
})
test('Can undo paste multiple nodes as single action', async ({
comfyPage
}) => {
const initialCount = await comfyPage.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
await comfyPage.canvas.click()
await comfyPage.ctrlA()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.ctrlC()
await comfyPage.ctrlV()
const pasteCount = await comfyPage.getGraphNodesCount()
expect(pasteCount).toBe(initialCount * 2)
await comfyPage.ctrlZ()
const undoCount = await comfyPage.getGraphNodesCount()
expect(undoCount).toBe(initialCount)
})
})

View File

@@ -95,34 +95,3 @@ test.describe('Missing models warning', () => {
await expect(folderSelect).not.toBeVisible()
})
})
test.describe('Settings', () => {
test.afterEach(async ({ comfyPage }) => {
// Restore default setting value
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const searchBox = comfyPage.page.locator('.settings-content')
await expect(searchBox).toBeVisible()
})
test('Can open settings with hotkey', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator('.settings-container')
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible()
})
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const maxSpeed = 2.5
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
test.step('Setting should persist', async () => {
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
})
})
})

View File

@@ -1,9 +1,9 @@
import { expect, Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Topbar commands', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
@@ -12,75 +12,21 @@ test.describe('Topbar commands', () => {
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
commands: [
window['app'].extensionManager.menu.registerTopbarCommands(
['ext'],
[
{
id: 'foo',
label: 'foo-command',
label: 'foo',
function: () => {
window['foo'] = true
}
}
],
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
})
)
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo'])
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
})
test('Should not allow register command defined in other extension', async ({
comfyPage
}) => {
await comfyPage.registerCommand('foo', () => alert(1))
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestExtension1',
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
})
})
const menuItem: Locator = await comfyPage.menu.topbar.getMenuItem('ext')
expect(await menuItem.count()).toBe(0)
})
test('Should allow registering keybindings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const app = window['app']
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'TestCommand',
function: () => {
window['TestCommand'] = true
}
}
],
keybindings: [
{
combo: { key: 'k' },
commandId: 'TestCommand'
}
]
})
})
await comfyPage.page.keyboard.press('k')
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
true
)
})
})

View File

@@ -1,20 +0,0 @@
import { FullConfig } from '@playwright/test'
import { backupPath } from './utils/backupUtils'
import dotenv from 'dotenv'
dotenv.config()
export default function globalSetup(config: FullConfig) {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
backupPath([process.env.TEST_COMFYUI_DIR, 'models'], {
renameAndReplaceWithScaffolding: true
})
} else {
console.warn(
'Set TEST_COMFYUI_DIR in .env to prevent user data (settings, workflows, etc.) from being overwritten'
)
}
}
}

View File

@@ -1,12 +0,0 @@
import { FullConfig } from '@playwright/test'
import { restorePath } from './utils/backupUtils'
import dotenv from 'dotenv'
dotenv.config()
export default function globalTeardown(config: FullConfig) {
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])
}
}

View File

@@ -1,38 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Graph Canvas Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
// Set link render mode to spline to make sure it's not affected by other tests'
// side effects.
await comfyPage.setSetting('Comfy.LinkRenderMode', 2)
})
test('Can toggle link visibility', async ({ comfyPage }) => {
// Note: `Comfy.Graph.CanvasMenu` is disabled in comfyPage setup.
// so no cleanup is needed.
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
return window['LiteGraph'].HIDDEN_LINK
})
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe(
hiddenLinkRenderMode
)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-visible-links.png'
)
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).not.toBe(
hiddenLinkRenderMode
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { ComfyPage, NodeReference, comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Group Node', () => {
test.afterEach(async ({ comfyPage }) => {
@@ -13,7 +13,7 @@ test.describe('Group Node', () => {
let libraryTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await libraryTab.open()
@@ -148,124 +148,4 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(1)
expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
})
test.describe('Copy and paste', () => {
let groupNode: NodeReference | null
const WORKFLOW_NAME = 'group_node_v1.3.3'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_PREFIX = 'workflow>'
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window['LiteGraph'].registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
.getFolder(GROUP_NODE_CATEGORY)
.count()
return groupNodesFolderCt === 1
}
const verifyNodeLoaded = async (
comfyPage: ComfyPage,
expectedCount: number
) => {
expect(await comfyPage.getNodeRefsByType(GROUP_NODE_TYPE)).toHaveLength(
expectedCount
)
expect(await isRegisteredLitegraph(comfyPage)).toBe(true)
expect(await isRegisteredNodeDefStore(comfyPage)).toBe(true)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.loadWorkflow(WORKFLOW_NAME)
await comfyPage.menu.nodeLibraryTab.open()
groupNode = await comfyPage.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
await groupNode.copy()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.page.evaluate((groupNodeName) => {
window['LiteGraph'].unregisterNodeType(groupNodeName)
}, GROUP_NODE_TYPE)
})
test('Copies and pastes group node within the same workflow', async ({
comfyPage
}) => {
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 2)
})
test('Copies and pastes group node after clearing workflow', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand([
'Edit',
'Clear Workflow'
])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
test('Copies and pastes group node into a newly created blank workflow', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
test('Copies and pastes group node across different workflows', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('default')
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
test('Serializes group node after copy and paste across workflows', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.ctrlV()
const currentGraphState = await comfyPage.page.evaluate(() =>
window['app'].graph.serialize()
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.page.evaluate(
(workflow) => window['app'].loadGraphData(workflow),
currentGraphState
)
await comfyPage.nextFrame()
await verifyNodeLoaded(comfyPage, 1)
})
})
})
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.page.keyboard.press('Alt+g')
await comfyPage.page.waitForTimeout(300)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.clickTextEncodeNode1()
await comfyPage.page.keyboard.press('Alt+g')
await comfyPage.page.waitForTimeout(300)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
})
})

View File

@@ -1,34 +1,29 @@
import type { Page, Locator } from '@playwright/test'
import type { AutoQueueMode } from '../../src/stores/queueStore'
export class ComfyActionbar {
export class ComfyAppMenu {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
constructor(public readonly page: Page) {
this.root = page.locator('.actionbar')
this.root = page.locator('.app-menu')
this.queueButton = new ComfyQueueButton(this)
}
async isDocked() {
const className = await this.root.getAttribute('class')
return className?.includes('is-docked') ?? false
}
}
class ComfyQueueButton {
public readonly root: Locator
public readonly primaryButton: Locator
public readonly dropdownButton: Locator
constructor(public readonly actionbar: ComfyActionbar) {
this.root = actionbar.root.getByTestId('queue-button')
constructor(public readonly appMenu: ComfyAppMenu) {
this.root = appMenu.root.getByTestId('queue-button')
this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
}
public async toggleOptions() {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.actionbar.page)
return new ComfyQueueButtonOptions(this.appMenu.page)
}
}

View File

@@ -11,51 +11,12 @@ test.describe('Node Interaction', () => {
await expect(textBox).toHaveValue('Hello World 2')
})
test.describe('Node Selection', () => {
const multiSelectModifiers = ['Control', 'Shift', 'Meta']
multiSelectModifiers.forEach((modifier) => {
test(`Can add multiple nodes to selection using ${modifier}+Click`, async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
for (const node of clipNodes) {
await node.click('title', { modifiers: [modifier] })
}
const selectedNodeCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedNodeCount).toBe(clipNodes.length)
})
})
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')
})
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
await comfyPage.page.keyboard.down('Meta')
await comfyPage.dragAndDrop(
{
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
},
{
x: Math.max(clipNode1Pos.x, clipNode2Pos.x) + offset,
y: Math.max(clipNode1Pos.y, clipNode2Pos.y) + offset
}
)
await comfyPage.page.keyboard.up('Meta')
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
})
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')
})
test('Can drag node', async ({ comfyPage }) => {
@@ -386,52 +347,6 @@ test.describe('Canvas Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
})
test('Cursor style changes when panning', async ({ comfyPage }) => {
const getCursorStyle = async () => {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
)
})
}
await comfyPage.page.mouse.move(10, 10)
expect(await getCursorStyle()).toBe('default')
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
expect(await getCursorStyle()).toBe('default')
await comfyPage.page.keyboard.down('Space')
expect(await getCursorStyle()).toBe('grab')
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
expect(await getCursorStyle()).toBe('grab')
await comfyPage.page.keyboard.up('Space')
expect(await getCursorStyle()).toBe('default')
})
test('Can pan when dragging a link', async ({ comfyPage }) => {
const posSlot1 = comfyPage.clipTextEncodeNode1InputSlot
await comfyPage.page.mouse.move(posSlot1.x, posSlot1.y)
await comfyPage.page.mouse.down()
const posEmpty = comfyPage.emptySpace
await comfyPage.page.mouse.move(posEmpty.x, posEmpty.y)
await expect(comfyPage.canvas).toHaveScreenshot('dragging-link1.png')
await comfyPage.page.keyboard.down('Space')
await comfyPage.page.mouse.move(posEmpty.x + 100, posEmpty.y + 100)
// Canvas should be panned.
await expect(comfyPage.canvas).toHaveScreenshot(
'panning-when-dragging-link.png'
)
await comfyPage.page.keyboard.up('Space')
await comfyPage.page.mouse.move(posEmpty.x, posEmpty.y)
// Should be back to dragging link mode when space is released.
await expect(comfyPage.canvas).toHaveScreenshot('dragging-link2.png')
await comfyPage.page.mouse.up()
})
test('Can pan very far and back', async ({ comfyPage }) => {
// intentionally slice the edge of where the clip text encode dom widgets are
await comfyPage.pan({ x: -800, y: -300 }, { x: 1000, y: 10 })
@@ -490,7 +405,7 @@ test.describe('Load workflow', () => {
test.describe('Load duplicate workflow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,37 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Keybindings', () => {
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
comfyPage
}) => {
await comfyPage.registerKeybinding({ key: 'k' }, () => {
window['TestCommand'] = true
})
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('k')
await expect(textBox).toHaveValue('k')
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
undefined
)
})
test('Should not trigger modifier keybinding when typing in input fields', async ({
comfyPage
}) => {
await comfyPage.registerKeybinding({ key: 'k', ctrl: true }, () => {
window['TestCommand'] = true
})
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('q')
await textBox.press('Control+k')
await expect(textBox).toHaveValue('q')
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
true
)
})
})

View File

@@ -3,7 +3,7 @@ import { comfyPageFixture as test } from './ComfyPage'
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
@@ -389,8 +389,6 @@ test.describe('Menu', () => {
// Open the sidebar
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.setupWorkflowsDirectory({})
})
test('Can create new blank workflow', async ({ comfyPage }) => {
@@ -416,9 +414,10 @@ test.describe('Menu', () => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
)
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual([
'workflow1.json',
'workflow2.json'
])
})
test('Does not report warning when switching between opened workflows', async ({
@@ -427,9 +426,9 @@ test.describe('Menu', () => {
await comfyPage.loadWorkflow('missing_nodes')
await comfyPage.closeDialog()
// Load blank workflow
// Load default workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.menu.workflowsTab.newDefaultWorkflowButton.click()
// Switch back to the missing_nodes workflow
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
@@ -438,21 +437,6 @@ test.describe('Menu', () => {
comfyPage.page.locator('.comfy-missing-nodes')
).not.toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.saveWorkflow(
`tempWorkflow-${test.info().title}`
)
const closeButton = comfyPage.page.locator(
'.comfyui-workflows-open .p-button-icon.pi-times'
)
await closeButton.click()
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['*Unsaved Workflow (2).json'])
})
})
test.describe('Workflows topbar tabs', () => {
@@ -461,7 +445,6 @@ test.describe('Menu', () => {
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setupWorkflowsDirectory({})
})
test('Can show opened workflows', async ({ comfyPage }) => {
@@ -469,41 +452,6 @@ test.describe('Menu', () => {
'Unsaved Workflow'
])
})
test('Can close saved-workflow tabs', async ({ comfyPage }) => {
const workflowName = `tempWorkflow-${test.info().title}`
await comfyPage.menu.topbar.saveWorkflow(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow (2)'
])
})
})
test.describe('Topbar submmenus', () => {
test('@mobile Items fully visible on mobile screen width', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.openSubmenuMobile()
const topLevelMenuItem = comfyPage.page
.locator('a.p-menubar-item-link')
.first()
const isTextCutoff = await topLevelMenuItem.evaluate((el) => {
return el.scrollWidth > el.clientWidth
})
expect(isTextCutoff).toBe(false)
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
const workflowMenuItem =
await comfyPage.menu.topbar.getMenuItem('Workflow')
await workflowMenuItem.click()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'
})
expect(await exportTag.count()).toBe(1)
})
})
// Only test 'Top' to reduce test time.
@@ -513,7 +461,7 @@ test.describe('Menu', () => {
comfyPage
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', position)
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Top')
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Floating')
})
test(`Can migrate deprecated menu positions on initial load (${position})`, async ({
@@ -521,7 +469,20 @@ test.describe('Menu', () => {
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', position)
await comfyPage.setup()
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Top')
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Floating')
})
})
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const [defaultSpeed, maxSpeed] = [1.1, 2.5]
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
defaultSpeed
)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
await comfyPage.page.reload()
await comfyPage.setup()
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', defaultSpeed)
})
})

View File

@@ -32,16 +32,4 @@ test.describe('Optional input', () => {
// If the node's multiline text widget is visible, then it was loaded successfully
expect(comfyPage.page.locator('.comfy-multiline-input')).toHaveCount(1)
})
test('Old workflow with converted input', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('old_workflow_converted_input')
const node = await comfyPage.getNodeRefById('1')
const inputs = await node.getProperty('inputs')
const vaeInput = inputs.find((w) => w.name === 'vae')
const convertedInput = inputs.find((w) => w.name === 'strength')
expect(vaeInput).toBeDefined()
expect(convertedInput).toBeDefined()
expect(vaeInput.link).toBeNull()
expect(convertedInput.link).not.toBeNull()
})
})

View File

@@ -1,22 +1,9 @@
import { expect } from '@playwright/test'
import { type NodeReference, comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Primitive Node', () => {
test('Can load with correct size', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node')
await expect(comfyPage.canvas).toHaveScreenshot('primitive_node.png')
})
// When link is dropped on widget, it should automatically convert the widget
// to input.
test('Can connect to widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node_unconnected')
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const ksamplerNode: NodeReference = await comfyPage.getNodeRefById(2)
// Connect the output of the primitive node to the input of first widget of the ksampler node
await primitiveNode.connectWidget(0, ksamplerNode, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'primitive_node_connected.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -3,7 +3,7 @@ import { comfyPageFixture as test } from './ComfyPage'
test.describe('Properties Panel', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {

View File

@@ -93,54 +93,16 @@ test.describe('Node Right Click Menu', () => {
)
})
test.describe('Widget conversion', () => {
const convertibleWidgetTypes = ['text', 'string', 'number', 'toggle']
test.afterEach(async ({ comfyPage }) => {
// Restore default setting value
await comfyPage.setSetting('Comfy.NodeInputConversionSubmenus', true)
})
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
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'
)
})
convertibleWidgetTypes.forEach((widgetType) => {
test(`Can convert ${widgetType} widget to input`, async ({
comfyPage
}) => {
const nodeType = 'KSampler'
// To avoid needing multiple clicks, disable nesting of conversion options
await comfyPage.setSetting('Comfy.NodeInputConversionSubmenus', false)
// Add the widget using the node's `addWidget` method
await comfyPage.page.evaluate(
([nodeType, widgetType]) => {
const node = window['app'].graph.nodes.find(
(n) => n.type === nodeType
)
node.addWidget(widgetType, widgetType, 'defaultValue', () => {}, {})
},
[nodeType, widgetType]
)
// Verify the context menu includes the conversion option
const node = (await comfyPage.getNodeRefsByType(nodeType))[0]
const menuOptions = await node.getContextMenuOptionNames()
expect(menuOptions.includes(`Convert ${widgetType} to input`)).toBe(
true
)
})
})
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 pin and unpin', async ({ comfyPage }) => {

View File

@@ -3,7 +3,7 @@ import { comfyPageFixture as test } from './ComfyPage'
test.describe('Templates', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {

View File

@@ -1,69 +0,0 @@
import path from 'path'
import fs from 'fs-extra'
type PathParts = readonly [string, ...string[]]
const getBackupPath = (originalPath: string): string => `${originalPath}.bak`
const resolvePathIfExists = (pathParts: PathParts): string | null => {
const resolvedPath = path.resolve(...pathParts)
if (!fs.pathExistsSync(resolvedPath)) {
console.warn(`Path not found: ${resolvedPath}`)
return null
}
return resolvedPath
}
const createScaffoldingCopy = (srcDir: string, destDir: string) => {
// Get all items (files and directories) in the source directory
const items = fs.readdirSync(srcDir, { withFileTypes: true })
for (const item of items) {
const srcPath = path.join(srcDir, item.name)
const destPath = path.join(destDir, item.name)
if (item.isDirectory()) {
// Create the corresponding directory in the destination
fs.ensureDirSync(destPath)
// Recursively copy the directory structure
createScaffoldingCopy(srcPath, destPath)
}
}
}
export function backupPath(
pathParts: PathParts,
{ renameAndReplaceWithScaffolding = false } = {}
) {
const originalPath = resolvePathIfExists(pathParts)
if (!originalPath) return
const backupPath = getBackupPath(originalPath)
try {
if (renameAndReplaceWithScaffolding) {
// Rename the original path and create scaffolding in its place
fs.moveSync(originalPath, backupPath)
createScaffoldingCopy(backupPath, originalPath)
} else {
// Create a copy of the original path
fs.copySync(originalPath, backupPath)
}
} catch (error) {
console.error(`Failed to backup ${originalPath} from ${backupPath}`, error)
}
}
export function restorePath(pathParts: PathParts) {
const originalPath = resolvePathIfExists(pathParts)
if (!originalPath) return
const backupPath = getBackupPath(originalPath)
if (!fs.pathExistsSync(backupPath)) return
try {
fs.moveSync(backupPath, originalPath, { overwrite: true })
} catch (error) {
console.error(`Failed to restore ${originalPath} from ${backupPath}`, error)
}
}

View File

@@ -2,7 +2,6 @@ import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue'
import unusedImports from 'eslint-plugin-unused-imports'
export default [
{
@@ -36,16 +35,5 @@ export default [
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off'
}
},
{
plugins: {
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'unused-imports/no-unused-imports': 'error'
}
}
]

View File

@@ -36,6 +36,7 @@
</form>
</main>
</div>
<script type="module" src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module" src="src/main.ts"></script>
</body>
</html>

918
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.3.19",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"deploy": "npm run build && node scripts/deploy.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "tsc --noEmit && tsc-strict",
"typecheck": "tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue}'",
"test": "npm run build && jest",
"test:generate:examples": "npx tsx tests-ui/extractExamples",
@@ -30,15 +30,13 @@
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
"@vue/vue3-jest": "^29.2.6",
"autoprefixer": "^10.4.19",
"babel-plugin-transform-import-meta": "^2.2.1",
"babel-plugin-transform-rename-import": "^2.3.0",
"chalk": "^5.3.0",
"eslint": "^9.12.0",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint": "^9.8.0",
"eslint-plugin-vue": "^9.27.0",
"fs-extra": "^11.2.0",
"globals": "^15.9.0",
@@ -56,7 +54,6 @@
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
"typescript-strict-plugin": "^2.4.4",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.6",
@@ -65,17 +62,20 @@
"zip-dir": "^2.0.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/litegraph": "^0.8.3",
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.77",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",
"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.5",
"reflect-metadata": "^0.2.2",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",

View File

@@ -30,10 +30,6 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},
/* Path to global setup file. Exported function runs once before all the tests */
globalSetup: './browser_tests/globalSetup.ts',
/* Path to global teardown file. Exported function runs once after all the tests */
globalTeardown: './browser_tests/globalTeardown.ts',
/* Configure projects for major browsers */
projects: [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -266,7 +266,7 @@
],
"properties": {},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
"v1-5-pruned-emaonly.ckpt"
]
}
],
@@ -347,10 +347,5 @@
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"models": [{
"name": "v1-5-pruned-emaonly.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly.safetensors?download=true",
"directory": "checkpoints"
}]
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -409,12 +409,5 @@
]
}
},
"models": [
{
"name": "flux1-schnell-fp8.safetensors",
"url": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true",
"directory": "checkpoints"
}
],
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -330,7 +330,7 @@
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
"v1-5-pruned-emaonly.ckpt"
]
}
],
@@ -438,10 +438,5 @@
],
"config": {},
"extra": {},
"version": 0.4,
"models": [{
"name": "v1-5-pruned-emaonly.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly.safetensors?download=true",
"directory": "checkpoints"
}]
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -429,7 +429,7 @@
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v2-1_768-ema-pruned.safetensors"
"v2-1_768-ema-pruned.ckpt"
]
},
{
@@ -641,12 +641,5 @@
],
"config": {},
"extra": {},
"version": 0.4,
"models": [
{
"name": "v2-1_768-ema-pruned.safetensors",
"url": "https://huggingface.co/stabilityai/stable-diffusion-2-1/resolve/main/v2-1_768-ema-pruned.safetensors?download=true",
"directory": "checkpoints"
}
]
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

View File

@@ -18,9 +18,6 @@ def download_and_extract(version, temp_dir):
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(temp_dir)
# Clean up the zip file after extraction
os.remove(zip_path)
else:
raise Exception(
f"Failed to download release asset. Status code: {response.status_code}"

View File

@@ -10,20 +10,13 @@
<script setup lang="ts">
import config from '@/config'
import { computed, onMounted } from 'vue'
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useEventListener } from '@vueuse/core'
const workspaceStore = useWorkspaceStore()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}
useEventListener(window, 'keydown', handleKey)
useEventListener(window, 'keyup', handleKey)
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
onMounted(() => {
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version

View File

@@ -126,8 +126,7 @@ body {
/* Span across all columns */
grid-column: 1/-1;
grid-row: 3;
/* Bottom menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
z-index: 1000;
z-index: 10;
display: flex;
flex-direction: column;
}
@@ -317,7 +316,6 @@ span.drag-handle {
letter-spacing: 2px;
color: var(--drag-text);
text-shadow: 1px 0 1px black;
touch-action: none;
}
span.drag-handle::after {

View File

@@ -12,8 +12,6 @@ import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
const DEFAULT_TITLE = 'ComfyUI'
const TITLE_SUFFIX = ' - ComfyUI'
const executionStore = useExecutionStore()
const executionText = computed(() =>
executionStore.isIdle ? '' : `[${executionStore.executionProgress}%]`
@@ -30,9 +28,7 @@ const isUnsavedText = computed(() =>
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.name
return workflowName
? isUnsavedText.value + workflowName + TITLE_SUFFIX
: DEFAULT_TITLE
return workflowName ? isUnsavedText.value + workflowName : DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>

View File

@@ -9,8 +9,8 @@
>
<slot name="side-bar-panel"></slot>
</SplitterPanel>
<SplitterPanel class="graph-canvas-panel relative" :size="100">
<slot name="graph-canvas-panel"></slot>
<SplitterPanel class="graph-canvas-panel" :size="100">
<div></div>
</SplitterPanel>
<SplitterPanel
class="side-bar-panel"
@@ -37,7 +37,7 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
)
const sidebarPanelVisible = computed(
() => useWorkspaceStore().sidebarTab.activeSidebarTab !== null
() => useWorkspaceStore().activeSidebarTab !== null
)
const gutterClass = computed(() => {
return sidebarPanelVisible.value ? '' : 'gutter-hidden'

View File

@@ -1,236 +0,0 @@
<template>
<Panel
class="actionbar w-fit"
:style="style"
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
>
<div class="actionbar-content flex items-center" ref="panelRef">
<span class="drag-handle cursor-move mr-2 p-0!" ref="dragHandleRef">
</span>
<ComfyQueueButton />
<Divider layout="vertical" class="mx-1" />
<ButtonGroup class="flex flex-nowrap">
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
severity="secondary"
text
@click="() => commandStore.execute('Comfy.RefreshNodeDefinitions')"
/>
</ButtonGroup>
</div>
</Panel>
</template>
<script lang="ts" setup>
import { computed, inject, nextTick, onMounted, Ref, ref, watch } from 'vue'
import Panel from 'primevue/panel'
import Divider from 'primevue/divider'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import ComfyQueueButton from './ComfyQueueButton.vue'
import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import {
useDraggable,
useElementBounding,
useEventBus,
useEventListener,
useLocalStorage,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'lodash'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const {
x,
y,
style: style,
isDragging
} = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
})
// Update storedPosition when x or y changes
watchDebounced(
[x, y],
([newX, newY]) => {
storedPosition.value = { x: newX, y: newY }
},
{ debounce: 300 }
)
// Set initial position to bottom center
const setInitialPosition = () => {
if (x.value !== 0 || y.value !== 0) {
return
}
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
x.value = storedPosition.value.x
y.value = storedPosition.value.y
captureLastDragState()
return
}
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
}
x.value = (screenWidth - menuWidth) / 2
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
captureLastDragState()
}
}
onMounted(setInitialPosition)
watch(visible, (newVisible) => {
if (newVisible) {
nextTick(setInitialPosition)
}
})
const lastDragState = ref({
x: x.value,
y: y.value,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
})
const captureLastDragState = () => {
lastDragState.value = {
x: x.value,
y: y.value,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
}
}
watch(
isDragging,
(newIsDragging) => {
if (!newIsDragging) {
// Stop dragging
captureLastDragState()
}
},
{ immediate: true }
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Calculate the distance from each edge
const distanceRight =
lastDragState.value.windowWidth - (lastDragState.value.x + menuWidth)
const distanceBottom =
lastDragState.value.windowHeight - (lastDragState.value.y + menuHeight)
// Determine if the menu is closer to right/bottom or left/top
const anchorRight = distanceRight < lastDragState.value.x
const anchorBottom = distanceBottom < lastDragState.value.y
// Calculate new position
if (anchorRight) {
x.value =
screenWidth - (lastDragState.value.windowWidth - lastDragState.value.x)
} else {
x.value = lastDragState.value.x
}
if (anchorBottom) {
y.value =
screenHeight -
(lastDragState.value.windowHeight - lastDragState.value.y)
} else {
y.value = lastDragState.value.y
}
// Ensure the menu stays within the screen bounds
x.value = clamp(x.value, 0, screenWidth - menuWidth)
y.value = clamp(y.value, 0, screenHeight - menuHeight)
}
}
useEventListener(window, 'resize', adjustMenuPosition)
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const topMenuBounds = useElementBounding(topMenuRef)
const overlapThreshold = 20 // pixels
const isOverlappingWithTopMenu = computed(() => {
if (!panelRef.value) {
return false
}
const { height } = panelRef.value.getBoundingClientRect()
const actionbarBottom = y.value + height
const topMenuBottom = topMenuBounds.bottom.value
const overlapPixels =
Math.min(actionbarBottom, topMenuBottom) -
Math.max(y.value, topMenuBounds.top.value)
return overlapPixels > overlapThreshold
})
watch(isDragging, (newIsDragging) => {
if (!newIsDragging) {
// Stop dragging
isDocked.value = isOverlappingWithTopMenu.value
} else {
// Start dragging
isDocked.value = false
}
})
const eventBus = useEventBus<string>('topMenu')
watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
eventBus.emit('updateHighlight', {
isDragging: dragging,
isOverlapping: overlapping
})
})
</script>
<style scoped>
.actionbar {
pointer-events: all;
position: fixed;
z-index: 1000;
}
.actionbar.is-docked {
position: static;
@apply bg-transparent border-none p-0;
}
.actionbar.is-dragging {
user-select: none;
}
:deep(.p-panel-content) {
@apply p-1;
}
:deep(.p-panel-header) {
display: none;
}
</style>

View File

@@ -1,127 +0,0 @@
<template>
<div class="queue-button-group flex">
<SplitButton
class="comfyui-queue-button"
:label="activeQueueModeMenuItem.label"
severity="primary"
@click="queuePrompt"
:model="queueModeMenuItems"
data-testid="queue-button"
v-tooltip.bottom="
workspaceStore.shiftDown
? $t('menu.queueWorkflowFront')
: $t('menu.queueWorkflow')
"
>
<template #icon>
<i-lucide:list-start v-if="workspaceStore.shiftDown" />
<i v-else :class="activeQueueModeMenuItem.icon" />
</template>
<template #item="{ item }">
<Button
:label="item.label"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
text
v-tooltip="item.tooltip"
/>
</template>
</SplitButton>
<BatchCountEdit />
<ButtonGroup class="execution-actions flex flex-nowrap">
<Button
v-tooltip.bottom="$t('menu.interrupt')"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
text
@click="() => commandStore.execute('Comfy.Interrupt')"
>
</Button>
<Button
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
text
@click="() => commandStore.execute('Comfy.ClearPendingTasks')"
/>
</ButtonGroup>
</div>
</template>
<script setup lang="ts">
import SplitButton from 'primevue/splitbutton'
import Button from 'primevue/button'
import BatchCountEdit from './BatchCountEdit.vue'
import ButtonGroup from 'primevue/buttongroup'
import { useI18n } from 'vue-i18n'
import {
AutoQueueMode,
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import type { MenuItem } from 'primevue/menuitem'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode } = storeToRefs(useQueueSettingsStore())
const { t } = useI18n()
const queueModeMenuItemLookup: Record<AutoQueueMode, MenuItem> = {
disabled: {
key: 'disabled',
label: 'Queue',
icon: 'pi pi-play',
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
instant: {
key: 'instant',
label: 'Queue (Instant)',
icon: 'pi pi-forward',
tooltip: t('menu.instantTooltip'),
command: () => {
queueMode.value = 'instant'
}
},
change: {
key: 'change',
label: 'Queue (Change)',
icon: 'pi pi-step-forward-alt',
tooltip: t('menu.changeTooltip'),
command: () => {
queueMode.value = 'change'
}
}
}
const activeQueueModeMenuItem = computed(
() => queueModeMenuItemLookup[queueMode.value]
)
const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup)
)
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(() => queueCountStore.count.value > 1)
const commandStore = useCommandStore()
const queuePrompt = (e: MouseEvent) => {
const commandId = e.shiftKey ? 'Comfy.QueuePromptFront' : 'Comfy.QueuePrompt'
commandStore.execute(commandId)
}
</script>
<style scoped>
.comfyui-queue-button :deep(.p-splitbutton-dropdown) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<Panel v-if="visible" class="app-menu">
<div class="app-menu-content flex align-center">
<div class="queue-button-group flex">
<SplitButton
class="comfyui-queue-button"
:label="activeQueueModeMenuItem.label"
:icon="activeQueueModeMenuItem.icon"
severity="primary"
@click="queuePrompt"
:model="queueModeMenuItems"
data-testid="queue-button"
v-tooltip.bottom="$t('menu.queueWorkflow')"
>
<template #item="{ item }">
<Button
:label="item.label"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
text
v-tooltip="item.tooltip"
/>
</template>
</SplitButton>
<BatchCountEdit />
<ButtonGroup class="execution-actions ml-2">
<Button
v-tooltip.bottom="$t('menu.interrupt')"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
@click="() => commandStore.getCommandFunction('Comfy.Interrupt')()"
>
</Button>
<Button
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
@click="
() => commandStore.getCommandFunction('Comfy.ClearPendingTasks')()
"
/>
</ButtonGroup>
</div>
<Divider layout="vertical" class="mx-2" />
<ButtonGroup>
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
severity="secondary"
@click="
() =>
commandStore.getCommandFunction('Comfy.RefreshNodeDefinitions')()
"
/>
<Button
v-tooltip.bottom="$t('menu.resetView')"
icon="pi pi-expand"
severity="secondary"
@click="() => commandStore.getCommandFunction('Comfy.ResetView')()"
/>
</ButtonGroup>
</div>
</Panel>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Panel from 'primevue/panel'
import Divider from 'primevue/divider'
import SplitButton from 'primevue/splitbutton'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import BatchCountEdit from './BatchCountEdit.vue'
import {
AutoQueueMode,
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { app } from '@/scripts/app'
import { storeToRefs } from 'pinia'
import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { MenuItem } from 'primevue/menuitem'
import { useI18n } from 'vue-i18n'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { batchCount, mode: queueMode } = storeToRefs(useQueueSettingsStore())
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') === 'Floating'
)
const { t } = useI18n()
const queueModeMenuItemLookup: Record<AutoQueueMode, MenuItem> = {
disabled: {
key: 'disabled',
label: 'Queue',
icon: 'pi pi-play',
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
instant: {
key: 'instant',
label: 'Queue (Instant)',
icon: 'pi pi-forward',
tooltip: t('menu.instantTooltip'),
command: () => {
queueMode.value = 'instant'
}
},
change: {
key: 'change',
label: 'Queue (Change)',
icon: 'pi pi-step-forward-alt',
tooltip: t('menu.changeTooltip'),
command: () => {
queueMode.value = 'change'
}
}
}
const activeQueueModeMenuItem = computed(
() => queueModeMenuItemLookup[queueMode.value]
)
const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup)
)
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(() => queueCountStore.count.value > 1)
const queuePrompt = (e: MouseEvent) => {
app.queuePrompt(e.shiftKey ? -1 : 0, batchCount.value)
}
</script>
<style scoped>
.app-menu {
pointer-events: all;
position: fixed;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
}
:deep(.p-panel-content) {
padding: 10px;
}
:deep(.p-panel-header) {
display: none;
}
.comfyui-queue-button :deep(.p-splitbutton-dropdown) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View File

@@ -31,10 +31,8 @@
<script lang="ts" setup>
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { storeToRefs } from 'pinia'
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
interface Props {
class?: string
@@ -47,11 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const settingStore = useSettingStore()
const maxQueueCount = computed(() =>
settingStore.get('Comfy.QueueButton.BatchCountLimit')
)
const maxQueueCount = 100
const handleClick = (increment: boolean) => {
let newCount: number

View File

@@ -73,7 +73,7 @@ const vFocus = {
<style scoped>
.editable-text {
display: inline;
display: inline-block;
}
.editable-text input {
width: 100%;

View File

@@ -1,5 +1,5 @@
<template>
<div class="input-slider flex flex-row items-center gap-2">
<div class="input-slider">
<Slider
:modelValue="modelValue"
@update:modelValue="updateValue"
@@ -17,7 +17,6 @@
:min="min"
:max="max"
:step="step"
:allowEmpty="false"
/>
</div>
</template>
@@ -70,3 +69,19 @@ const updateValue = (newValue: number | null) => {
emit('update:modelValue', newValue)
}
</script>
<style scoped>
.input-slider {
display: flex;
align-items: center;
gap: 1rem;
}
.slider-part {
flex-grow: 1;
}
.input-part {
width: 5rem !important;
}
</style>

View File

@@ -21,8 +21,8 @@
<slot name="after-label" :node="props.node"></slot>
</span>
<Badge
v-if="showNodeBadgeText"
:value="nodeBadgeText"
v-if="!props.node.leaf"
:value="props.node.badgeText ?? props.node.totalLeaves"
severity="secondary"
class="leaf-count-badge"
/>
@@ -34,8 +34,12 @@
</template>
<script setup lang="ts">
import { ref, inject, Ref, computed } from 'vue'
import { ref, onMounted, onUnmounted, inject, Ref, computed } from 'vue'
import Badge from 'primevue/badge'
import {
dropTargetForElements,
draggable
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type {
TreeExplorerDragAndDropData,
RenderedTreeExplorerNode,
@@ -43,7 +47,6 @@ import type {
} from '@/types/treeExplorerTypes'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/hooks/errorHooks'
import { usePragmaticDraggable, usePragmaticDroppable } from '@/hooks/dndHooks'
const props = defineProps<{
node: RenderedTreeExplorerNode
@@ -59,17 +62,6 @@ const emit = defineEmits<{
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
}>()
const nodeBadgeText = computed<string>(() => {
if (props.node.leaf) {
return ''
}
if (props.node.badgeText !== undefined && props.node.badgeText !== null) {
return props.node.badgeText
}
return props.node.totalLeaves.toString()
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const labelEditable = computed<boolean>(() => !!props.node.handleRename)
const renameEditingNode =
inject<Ref<TreeExplorerNode | null>>('renameEditingNode')
@@ -86,44 +78,54 @@ const handleRename = errorHandling.wrapWithErrorHandlingAsync(
)
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)
const treeNodeElementGetter = () =>
container.value?.closest('.p-tree-node-content') as HTMLElement
if (props.node.draggable) {
usePragmaticDraggable(treeNodeElementGetter, {
getInitialData: () => {
return {
type: 'tree-explorer-node',
data: props.node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node)
})
}
if (props.node.droppable) {
usePragmaticDroppable(treeNodeElementGetter, {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(props.node, dndData)
const treeNodeElement = ref<HTMLElement | null>(null)
let dropTargetCleanup = () => {}
let draggableCleanup = () => {}
onMounted(() => {
treeNodeElement.value = container.value?.closest(
'.p-tree-node-content'
) as HTMLElement
if (props.node.droppable) {
dropTargetCleanup = dropTargetForElements({
element: treeNodeElement.value,
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}
},
onDragEnter: (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
canDrop.value = true
}
},
onDragLeave: () => {
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}
},
onDragEnter: (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
canDrop.value = true
}
},
onDragLeave: () => {
canDrop.value = false
}
})
}
})
}
if (props.node.draggable) {
draggableCleanup = draggable({
element: treeNodeElement.value,
getInitialData() {
return {
type: 'tree-explorer-node',
data: props.node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node)
})
}
})
onUnmounted(() => {
dropTargetCleanup()
draggableCleanup()
})
</script>
<style scoped>

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeAll } from 'vitest'
import EditableText from '../EditableText.vue'

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -11,16 +11,13 @@
@hide="dialogStore.closeDialog"
@maximize="onMaximize"
@unmaximize="onUnmaximize"
:pt="{ header: 'pb-0' }"
:aria-labelledby="headerId"
>
<template #header>
<component
v-if="dialogStore.headerComponent"
:is="dialogStore.headerComponent"
:id="headerId"
/>
<h3 v-else :id="headerId">{{ dialogStore.title || ' ' }}</h3>
<h3 v-else>{{ dialogStore.title || ' ' }}</h3>
</template>
<component :is="dialogStore.component" v-bind="contentProps" />
@@ -33,9 +30,7 @@ import { useDialogStore } from '@/stores/dialogStore'
import Dialog from 'primevue/dialog'
const dialogStore = useDialogStore()
const maximizable = computed(
() => dialogStore.dialogComponentProps.maximizable ?? false
)
const maximizable = computed(() => dialogStore.props.maximizable ?? false)
const maximized = ref(false)
const onMaximize = () => {
@@ -50,6 +45,4 @@ const contentProps = computed(() => ({
...dialogStore.props,
maximized: maximized.value
}))
const headerId = `dialog-${Math.random().toString(36).substr(2, 9)}`
</script>

View File

@@ -191,4 +191,8 @@ const openNewGithubIssue = async () => {
white-space: pre-wrap;
word-wrap: break-word;
}
.no-results-placeholder {
padding-top: 0;
}
</style>

View File

@@ -1,43 +0,0 @@
<template>
<div class="prompt-dialog-content flex flex-col gap-2 pt-8">
<FloatLabel>
<InputText
ref="inputRef"
v-model="inputValue"
@keyup.enter="onConfirm"
@focus="selectAllText"
autofocus
/>
<label>{{ message }}</label>
</FloatLabel>
<Button @click="onConfirm">{{ $t('confirm') }}</Button>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import FloatLabel from 'primevue/floatlabel'
import { ref } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
}>()
const inputValue = ref<string>(props.defaultValue)
const onConfirm = () => {
props.onConfirm(inputValue.value)
useDialogStore().closeDialog()
}
const inputRef = ref(null)
const selectAllText = () => {
if (!inputRef.value) return
const inputElement = inputRef.value.$el
inputElement.setSelectionRange(0, inputElement.value.length)
}
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div class="settings-container">
<ScrollPanel class="settings-sidebar flex-shrink-0 p-2 w-64">
<div class="settings-sidebar">
<SearchBox
class="settings-search-box w-full mb-2"
class="settings-search-box"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchSettings') + '...'"
@@ -13,11 +13,11 @@
optionLabel="label"
scrollHeight="100%"
:disabled="inSearch"
class="border-none w-full"
:pt="{ root: { class: 'border-none' } }"
/>
</ScrollPanel>
</div>
<Divider layout="vertical" />
<ScrollPanel class="settings-content flex-grow">
<div class="settings-content">
<Tabs :value="tabValue">
<TabPanels class="settings-tab-panels">
<TabPanel key="search-results" value="Search Results">
@@ -54,36 +54,19 @@
<TabPanel key="about" value="About">
<AboutPanel />
</TabPanel>
<TabPanel key="keybinding" value="Keybinding">
<Suspense>
<KeybindingPanel />
<template #fallback>
<div>Loading keybinding panel...</div>
</template>
</Suspense>
</TabPanel>
<TabPanel key="extension" value="Extension">
<Suspense>
<ExtensionPanel />
<template #fallback>
<div>Loading extension panel...</div>
</template>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
</ScrollPanel>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, defineAsyncComponent } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import Listbox from 'primevue/listbox'
import Tabs from 'primevue/tabs'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import SettingGroup from './setting/SettingGroup.vue'
@@ -92,13 +75,6 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
const KeybindingPanel = defineAsyncComponent(
() => import('./setting/KeybindingPanel.vue')
)
const ExtensionPanel = defineAsyncComponent(
() => import('./setting/ExtensionPanel.vue')
)
interface ISettingGroup {
label: string
settings: SettingParams[]
@@ -110,30 +86,10 @@ const aboutPanelNode: SettingTreeNode = {
children: []
}
const keybindingPanelNode: SettingTreeNode = {
key: 'keybinding',
label: 'Keybinding',
children: []
}
const extensionPanelNode: SettingTreeNode = {
key: 'extension',
label: 'Extension',
children: []
}
const extensionPanelNodeList = computed<SettingTreeNode[]>(() => {
const settingStore = useSettingStore()
const showExtensionPanel = settingStore.get('Comfy.Settings.ExtensionPanel')
return showExtensionPanel ? [extensionPanelNode] : []
})
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const categories = computed<SettingTreeNode[]>(() => [
...(settingRoot.value.children || []),
keybindingPanelNode,
...extensionPanelNodeList.value,
aboutPanelNode
])
const activeCategory = ref<SettingTreeNode | null>(null)
@@ -209,8 +165,44 @@ const tabValue = computed(() =>
display: flex;
height: 70vh;
width: 60vw;
max-width: 1024px;
max-width: 1000px;
overflow: hidden;
/* Prevents container from scrolling */
}
.settings-sidebar {
width: 250px;
flex-shrink: 0;
/* Prevents sidebar from shrinking */
overflow-y: auto;
padding: 10px;
}
.settings-search-box {
width: 100%;
margin-bottom: 10px;
}
.settings-content {
flex-grow: 1;
overflow-y: auto;
/* Allows vertical scrolling */
}
/* Ensure the Listbox takes full width of the sidebar */
.settings-sidebar :deep(.p-listbox) {
width: 100%;
}
/* Optional: Style scrollbars for webkit browsers */
.settings-sidebar::-webkit-scrollbar,
.settings-content::-webkit-scrollbar {
width: 1px;
}
.settings-sidebar::-webkit-scrollbar-thumb,
.settings-content::-webkit-scrollbar-thumb {
background-color: transparent;
}
@media (max-width: 768px) {
@@ -223,15 +215,4 @@ const tabValue = computed(() =>
width: 100%;
}
}
/* Show a separator line above the Keybinding tab */
/* This indicates the start of custom setting panels */
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding']) {
position: relative;
}
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding'])::before {
@apply content-[''] top-0 left-0 absolute w-full;
border-top: 1px solid var(--p-divider-border-color);
}
</style>

View File

@@ -1,93 +0,0 @@
<template>
<div class="extension-panel">
<DataTable :value="extensionStore.extensions" stripedRows size="small">
<Column field="name" :header="$t('extensionName')" sortable></Column>
<Column
:pt="{
bodyCell: 'flex items-center justify-end'
}"
>
<template #body="slotProps">
<ToggleSwitch
v-model="editingEnabledExtensions[slotProps.data.name]"
@change="updateExtensionStatus"
/>
</template>
</Column>
</DataTable>
<div class="mt-4">
<Message v-if="hasChanges" severity="info">
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
</Message>
<Button
:label="$t('reloadToApplyChanges')"
icon="pi pi-refresh"
@click="applyChanges"
:disabled="!hasChanges"
text
fluid
severity="danger"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useExtensionStore } from '@/stores/extensionStore'
import { useSettingStore } from '@/stores/settingStore'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import ToggleSwitch from 'primevue/toggleswitch'
import Button from 'primevue/button'
import Message from 'primevue/message'
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()
const editingEnabledExtensions = ref<Record<string, boolean>>({})
onMounted(() => {
extensionStore.extensions.forEach((ext) => {
editingEnabledExtensions.value[ext.name] =
extensionStore.isExtensionEnabled(ext.name)
})
})
const changedExtensions = computed(() => {
return extensionStore.extensions.filter(
(ext) =>
editingEnabledExtensions.value[ext.name] !==
extensionStore.isExtensionEnabled(ext.name)
)
})
const hasChanges = computed(() => {
return changedExtensions.value.length > 0
})
const updateExtensionStatus = () => {
const editingDisabledExtensionNames = Object.entries(
editingEnabledExtensions.value
)
.filter(([_, enabled]) => !enabled)
.map(([name]) => name)
settingStore.set('Comfy.Extension.Disabled', [
...extensionStore.inactiveDisabledExtensionNames,
...editingDisabledExtensionNames
])
}
const applyChanges = () => {
// Refresh the page to apply changes
window.location.reload()
}
</script>

View File

@@ -1,240 +0,0 @@
<template>
<div class="keybinding-panel">
<DataTable
:value="commandsData"
v-model:selection="selectedCommandData"
:global-filter-fields="['id']"
:filters="filters"
selectionMode="single"
stripedRows
:pt="{
header: 'px-0'
}"
>
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('searchKeybindings') + '...'"
/>
</template>
<Column field="actions" header="">
<template #body="slotProps">
<div class="actions invisible">
<Button
icon="pi pi-pencil"
class="p-button-text"
@click="editKeybinding(slotProps.data)"
/>
<Button
icon="pi pi-trash"
class="p-button-text p-button-danger"
@click="removeKeybinding(slotProps.data)"
:disabled="!slotProps.data.keybinding"
/>
</div>
</template>
</Column>
<Column field="id" header="Command ID" sortable></Column>
<Column field="keybinding" header="Keybinding">
<template #body="slotProps">
<KeyComboDisplay
v-if="slotProps.data.keybinding"
:keyCombo="slotProps.data.keybinding.combo"
:isModified="
keybindingStore.isCommandKeybindingModified(slotProps.data.id)
"
/>
<span v-else>-</span>
</template>
</Column>
</DataTable>
<Dialog
class="min-w-96"
v-model:visible="editDialogVisible"
modal
:header="currentEditingCommand?.id"
@hide="cancelEdit"
>
<div>
<InputText
class="mb-2 text-center"
ref="keybindingInput"
:modelValue="newBindingKeyCombo?.toString() ?? ''"
placeholder="Press keys for new binding"
@keydown.stop.prevent="captureKeybinding"
autocomplete="off"
fluid
:invalid="!!existingKeybindingOnCombo"
/>
<Message v-if="existingKeybindingOnCombo" severity="error">
Keybinding already exists on
<Tag
severity="secondary"
:value="existingKeybindingOnCombo.commandId"
/>
</Message>
</div>
<template #footer>
<Button
label="Save"
icon="pi pi-check"
@click="saveKeybinding"
:disabled="!!existingKeybindingOnCombo"
autofocus
/>
</template>
</Dialog>
<Button
class="mt-4"
:label="$t('reset')"
v-tooltip="$t('resetKeybindingsTooltip')"
icon="pi pi-trash"
severity="danger"
fluid
text
@click="resetKeybindings"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import {
useKeybindingStore,
KeyComboImpl,
KeybindingImpl
} from '@/stores/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { useToast } from 'primevue/usetoast'
import { FilterMatchMode } from '@primevue/core/api'
const filters = ref({
global: { value: '', matchMode: FilterMatchMode.CONTAINS }
})
const keybindingStore = useKeybindingStore()
const commandStore = useCommandStore()
interface ICommandData {
id: string
keybinding: KeybindingImpl | null
}
const commandsData = computed<ICommandData[]>(() => {
return Object.values(commandStore.commands).map((command) => ({
id: command.id,
keybinding: keybindingStore.getKeybindingByCommandId(command.id)
}))
})
const selectedCommandData = ref<ICommandData | null>(null)
const editDialogVisible = ref(false)
const newBindingKeyCombo = ref<KeyComboImpl | null>(null)
const currentEditingCommand = ref<ICommandData | null>(null)
const keybindingInput = ref(null)
const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
if (!currentEditingCommand.value) {
return null
}
// If the new keybinding is the same as the current editing command, then don't show the error
if (
currentEditingCommand.value.keybinding?.combo?.equals(
newBindingKeyCombo.value
)
) {
return null
}
if (!newBindingKeyCombo.value) {
return null
}
return keybindingStore.getKeybinding(newBindingKeyCombo.value)
})
function editKeybinding(commandData: ICommandData) {
currentEditingCommand.value = commandData
newBindingKeyCombo.value = commandData.keybinding
? commandData.keybinding.combo
: null
editDialogVisible.value = true
}
watchEffect(() => {
if (editDialogVisible.value) {
// nextTick doesn't work here, so we use a timeout instead
setTimeout(() => {
keybindingInput.value?.$el?.focus()
}, 300)
}
})
function removeKeybinding(commandData: ICommandData) {
if (commandData.keybinding) {
keybindingStore.unsetKeybinding(commandData.keybinding)
keybindingStore.persistUserKeybindings()
}
}
function captureKeybinding(event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
newBindingKeyCombo.value = keyCombo
}
function cancelEdit() {
editDialogVisible.value = false
currentEditingCommand.value = null
newBindingKeyCombo.value = null
}
function saveKeybinding() {
if (currentEditingCommand.value && newBindingKeyCombo.value) {
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({
commandId: currentEditingCommand.value.id,
combo: newBindingKeyCombo.value
})
)
if (updated) {
keybindingStore.persistUserKeybindings()
}
}
cancelEdit()
}
const toast = useToast()
async function resetKeybindings() {
keybindingStore.resetKeybindings()
await keybindingStore.persistUserKeybindings()
toast.add({
severity: 'info',
summary: 'Info',
detail: 'Keybindings reset',
life: 3000
})
}
</script>
<style scoped>
:deep(.p-datatable-tbody) > tr > td {
padding: 1px;
min-height: 2rem;
}
:deep(.p-datatable-row-selected) .actions,
:deep(.p-datatable-selectable-row:hover) .actions {
@apply visible;
}
</style>

View File

@@ -5,10 +5,10 @@
<div
v-for="setting in group.settings"
:key="setting.id"
class="setting-item flex items-center mb-4"
class="setting-item"
>
<div class="setting-label flex flex-grow items-center">
<span class="text-[var(--p-text-muted-color)]">
<div class="setting-label">
<span>
<Tag v-if="setting.experimental" :value="$t('experimental')" />
<Tag
v-if="setting.deprecated"
@@ -17,11 +17,11 @@
{{ setting.name }}
<i
v-if="setting.tooltip"
class="pi pi-info-circle bg-transparent"
class="pi pi-info-circle info-chip"
v-tooltip="setting.tooltip"
/></span>
</div>
<div class="setting-input flex justify-end">
<div class="setting-input">
<component
:is="markRaw(getSettingComponent(setting))"
:id="setting.id"
@@ -110,13 +110,45 @@ function getSettingComponent(setting: SettingParams): Component {
</script>
<style scoped>
.setting-input :deep(.input-slider) .p-inputnumber input,
.setting-input :deep(.input-slider) .slider-part {
@apply w-20;
.info-chip {
background: transparent;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.setting-label {
display: flex;
align-items: center;
flex: 1;
}
.setting-input {
flex: 1;
display: flex;
justify-content: flex-end;
margin-left: 1rem;
}
/* Ensure PrimeVue components take full width of their container */
.setting-input :deep(.p-inputtext),
.setting-input :deep(.p-select) {
@apply w-44;
.setting-input :deep(.input-slider),
.setting-input :deep(.p-select),
.setting-input :deep(.p-togglebutton) {
width: 100%;
max-width: 200px;
}
.setting-input :deep(.p-inputtext) {
max-width: unset;
}
/* Special case for ToggleSwitch to align it to the right */
.setting-input :deep(.p-toggleswitch) {
margin-left: auto;
}
</style>

View File

@@ -1,28 +0,0 @@
<template>
<span>
<template v-for="(sequence, index) in keySequences" :key="index">
<Tag :severity="isModified ? 'info' : 'secondary'">
{{ sequence }}
</Tag>
<span v-if="index < keySequences.length - 1" class="px-2">+</span>
</template>
</span>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import { KeyComboImpl } from '@/stores/keybindingStore'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
keyCombo: KeyComboImpl
isModified: boolean
}>(),
{
isModified: false
}
)
const keySequences = computed(() => props.keyCombo.getKeySequences())
</script>

View File

@@ -4,16 +4,12 @@
<template #side-bar-panel>
<SideToolbar />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu v-if="canvasMenuEnabled" />
</template>
</LiteGraphCanvasSplitterOverlay>
<TitleEditor />
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
<canvas ref="canvasRef" id="graph-canvas" tabindex="1" />
</teleport>
<NodeSearchboxPopover />
<NodeTooltip v-if="tooltipEnabled" />
<NodeTooltip />
</template>
<script setup lang="ts">
@@ -22,10 +18,15 @@ import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import { ref, computed, onMounted, watchEffect } from 'vue'
import { ref, computed, onUnmounted, onMounted, watchEffect } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import {
ComfyNodeDefImpl,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import {
LiteGraph,
@@ -39,14 +40,10 @@ import {
LGraphBadge
} from '@comfyorg/litegraph'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyModelDef } from '@/stores/modelStore'
import {
ModelNodeProvider,
useModelToNodeStore
} from '@/stores/modelToNodeStore'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -58,10 +55,6 @@ const modelToNodeStore = useModelToNodeStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const canvasMenuEnabled = computed(() =>
settingStore.get('Comfy.Graph.CanvasMenu')
)
const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
watchEffect(() => {
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
@@ -99,76 +92,7 @@ watchEffect(() => {
})
})
watchEffect(() => {
if (!canvasStore.canvas) return
if (canvasStore.canvas.dragging_canvas) {
canvasStore.canvas.canvas.style.cursor = 'grabbing'
return
}
if (canvasStore.canvas.read_only) {
canvasStore.canvas.canvas.style.cursor = 'grab'
return
}
canvasStore.canvas.canvas.style.cursor = 'default'
})
usePragmaticDroppable(() => canvasRef.value, {
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// 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
])
comfyApp.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
if (nodeAtPos) {
const providers = modelToNodeStore.getAllNodeProviders(
model.directory
)
for (const provider of providers) {
if (provider.nodeDef.name === nodeAtPos.comfyClass) {
targetGraphNode = nodeAtPos
targetProvider = provider
}
}
}
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = comfyApp.addNodeOnGraph(provider.nodeDef, {
pos
})
targetProvider = provider
}
}
if (targetGraphNode) {
const widget = targetGraphNode.widgets.find(
(widget) => widget.name === targetProvider.key
)
if (widget) {
widget.value = model.file_name
}
}
}
}
}
})
let dropTargetCleanup = () => {}
onMounted(async () => {
// Backward compatible
@@ -193,6 +117,57 @@ onMounted(async () => {
window['app'] = comfyApp
window['graph'] = comfyApp.graph
dropTargetCleanup = dropTargetForElements({
element: canvasRef.value,
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// 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
])
comfyApp.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const pos = comfyApp.clientPosToCanvasPos([
loc.clientX - 20,
loc.clientY
])
const node = comfyApp.addNodeOnGraph(provider.nodeDef, { pos })
const widget = node.widgets.find(
(widget) => widget.name === provider.key
)
if (widget) {
widget.value = model.name
}
}
}
}
}
})
// Migrate legacy bookmarks
useNodeBookmarkStore().migrateLegacyBookmarks()
// Explicitly initialize nodeSearchService to avoid indexing delay when
// node search is triggered
useNodeDefStore().nodeSearchService.endsWithFilterStartSequence('')
// Non-blocking load of node frequencies
useNodeFrequencyStore().loadNodeFrequencies()
emit('ready')
})
onUnmounted(() => {
dropTargetCleanup()
})
</script>

View File

@@ -1,99 +0,0 @@
<template>
<ButtonGroup
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000] pointer-events-auto"
>
<Button
severity="secondary"
icon="pi pi-plus"
v-tooltip.left="t('graphCanvasMenu.zoomIn')"
@mousedown="repeat('Comfy.Canvas.ZoomIn')"
@mouseup="stopRepeat"
/>
<Button
severity="secondary"
icon="pi pi-minus"
v-tooltip.left="t('graphCanvasMenu.zoomOut')"
@mousedown="repeat('Comfy.Canvas.ZoomOut')"
@mouseup="stopRepeat"
/>
<Button
severity="secondary"
icon="pi pi-expand"
v-tooltip.left="t('graphCanvasMenu.resetView')"
@click="() => commandStore.execute('Comfy.Canvas.ResetView')"
/>
<Button
severity="secondary"
v-tooltip.left="
t(
'graphCanvasMenu.' +
(canvasStore.canvas?.read_only ? 'panMode' : 'selectMode')
) + ' (Space)'
"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLock')"
>
<template #icon>
<i-material-symbols:pan-tool-outline
v-if="canvasStore.canvas?.read_only"
/>
<i-simple-line-icons:cursor v-else />
</template>
</Button>
<Button
severity="secondary"
:icon="linkHidden ? 'pi pi-eye-slash' : 'pi pi-eye'"
v-tooltip.left="t('graphCanvasMenu.toggleLinkVisibility')"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
data-testid="toggle-link-visibility-button"
/>
</ButtonGroup>
</template>
<script setup lang="ts">
import ButtonGroup from 'primevue/buttongroup'
import Button from 'primevue/button'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useI18n } from 'vue-i18n'
import { LiteGraph } from '@comfyorg/litegraph'
import { computed } from 'vue'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
)
let interval: number | null = null
const repeat = (command: string) => {
if (interval) return
const cmd = () => commandStore.execute(command)
cmd()
interval = window.setInterval(cmd, 100)
}
const stopRepeat = () => {
if (interval) {
clearInterval(interval)
interval = null
}
}
</script>
<style scoped>
.p-buttongroup-vertical {
display: flex;
flex-direction: column;
border-radius: var(--p-button-border-radius);
overflow: hidden;
border: 1px solid var(--p-panel-border-color);
}
.p-buttongroup-vertical .p-button {
margin: 0;
border-radius: 0;
}
</style>

View File

@@ -10,14 +10,15 @@
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { nextTick, ref, onBeforeUnmount, watch } from 'vue'
import { LiteGraph } from '@comfyorg/litegraph'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useEventListener } from '@vueuse/core'
import { useSettingStore } from '@/stores/settingStore'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()
const settingStore = useSettingStore()
const tooltipRef = ref<HTMLDivElement>()
const tooltipText = ref('')
const left = ref<string>()
@@ -128,8 +129,24 @@ const onMouseMove = (e: MouseEvent) => {
idleTimeout = window.setTimeout(onIdle, 500)
}
useEventListener(window, 'mousemove', onMouseMove)
useEventListener(window, 'click', hideTooltip)
watch(
() => settingStore.get('Comfy.EnableTooltips'),
(enabled) => {
if (enabled) {
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('click', hideTooltip)
} else {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('click', hideTooltip)
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('click', hideTooltip)
})
</script>
<style lang="css" scoped>

View File

@@ -6,8 +6,8 @@
:key="tab.id"
:icon="tab.icon"
:iconBadge="tab.iconBadge"
:tooltip="tab.tooltip + getTabTooltipSuffix(tab)"
:selected="tab.id === selectedTab?.id"
:tooltip="tab.tooltip"
:selected="tab === selectedTab"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
@@ -45,7 +45,6 @@ import {
CustomSidebarTabExtension,
SidebarTabExtension
} from '@/types/extensionTypes'
import { useKeybindingStore } from '@/stores/keybindingStore'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
@@ -61,12 +60,17 @@ const isSmall = computed(
)
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
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.sidebarTab.toggleSidebarTab(item.id)
workspaceStore.updateActiveSidebarTab(
workspaceStore.activeSidebarTab === item.id ? null : item.id
)
}
onBeforeUnmount(() => {
tabs.value.forEach((tab) => {
@@ -75,14 +79,6 @@ onBeforeUnmount(() => {
}
})
})
const keybindingStore = useKeybindingStore()
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const keybinding = keybindingStore.getKeybindingByCommandId(
`Workspace.ToggleSidebarTab.${tab.id}`
)
return keybinding ? ` (${keybinding.combo.toString()})` : ''
}
</script>
<style>

View File

@@ -1,7 +1,6 @@
<template>
<SidebarIcon
icon="pi pi-cog"
class="comfy-settings-btn"
@click="showSetting"
:tooltip="$t('settings')"
/>

View File

@@ -8,19 +8,21 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import SidebarIcon from './SidebarIcon.vue'
import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
const settingStore = useSettingStore()
const currentTheme = computed(() => settingStore.get('Comfy.ColorPalette'))
const icon = computed(() =>
currentTheme.value !== 'light' ? 'pi pi-moon' : 'pi pi-sun'
)
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 commandStore = useCommandStore()
const toggleTheme = () => {
commandStore.execute('Comfy.ToggleTheme')
if (isDarkMode.value) {
previousDarkTheme.value = currentTheme.value
useSettingStore().set('Comfy.ColorPalette', 'light')
} else {
useSettingStore().set('Comfy.ColorPalette', previousDarkTheme.value)
}
}
</script>

View File

@@ -1,26 +1,29 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.modelLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #header>
<SearchBox
class="model-lib-search-box p-4"
v-model:modelValue="searchQuery"
:placeholder="$t('searchModels') + '...'"
/>
</template>
<SidebarTabTemplate :title="$t('sideToolbar.modelLibrary')">
<template #tool-buttons> </template>
<template #body>
<TreeExplorer
class="model-lib-tree-explorer py-0"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<ModelTreeLeaf :node="node" />
</template>
</TreeExplorer>
<div class="flex flex-col h-full">
<div class="flex-shrink-0">
<SearchBox
class="model-lib-search-box mx-4 mt-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchModels') + '...'"
/>
</div>
<div class="flex-grow overflow-y-auto">
<TreeExplorer
class="model-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<ModelTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</div>
</template>
</SidebarTabTemplate>
<div id="model-library-model-preview-container" />
@@ -68,8 +71,7 @@ const root: ComputedRef<TreeNode> = computed(() => {
if (Object.values(models.models).length) {
modelList.push(...Object.values(models.models))
} else {
// ModelDef with key 'folder/a/b/c/' is treated as empty folder
const fakeModel = new ComfyModelDef('', folder)
const fakeModel = new ComfyModelDef('(No Content)', folder)
fakeModel.is_fake_object = true
modelList.push(fakeModel)
}
@@ -82,23 +84,33 @@ const root: ComputedRef<TreeNode> = computed(() => {
if (searchQuery.value) {
const search = searchQuery.value.toLocaleLowerCase()
modelList = modelList.filter((model: ComfyModelDef) => {
return model.searchable.includes(search)
return model.name.toLocaleLowerCase().includes(search)
})
}
const tree: TreeNode = buildTree(modelList, (model: ComfyModelDef) =>
model.key.split('/')
)
const tree: TreeNode = buildTree(modelList, (model: ComfyModelDef) => {
return [model.directory, ...model.name.replaceAll('\\', '/').split('/')]
})
return tree
})
const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
const nameFormat = settingStore.get('Comfy.ModelLibrary.NameFormat')
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ComfyModelDef> => {
const children = node.children?.map(fillNodeInfo)
const model: ComfyModelDef | null =
node.leaf && node.data ? node.data : null
if (model?.is_fake_object) {
if (model.file_name === 'Loading') {
if (model.name === '(No Content)') {
return {
key: node.key,
label: t('noContent'),
leaf: true,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
return 'pi pi-file'
},
children: []
}
} else {
return {
key: node.key,
label: t('loading') + '...',
@@ -114,11 +126,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
return {
key: node.key,
label: model
? nameFormat === 'title'
? model.title
: model.simplified_file_name
: node.label,
label: model ? model.title : node.label,
leaf: node.leaf,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
@@ -136,8 +144,10 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
if (node.children?.length === 1) {
const onlyChild = node.children[0]
if (onlyChild.data?.is_fake_object) {
if (onlyChild.data.file_name === 'Loading') {
return ''
if (onlyChild.data.name === '(No Content)') {
return '0'
} else if (onlyChild.data.name === 'Loading') {
return '?'
}
}
}
@@ -159,7 +169,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
(widget) => widget.name === provider.key
)
if (widget) {
widget.value = model.file_name
widget.value = model.name
}
}
}
@@ -169,6 +179,10 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
return fillNodeInfo(root.value)
})
const handleSearch = (query: string) => {
// TODO
}
const handleNodeClick = (
node: RenderedTreeExplorerNode<ComfyModelDef>,
e: MouseEvent
@@ -197,9 +211,15 @@ watch(
)
</script>
<style scoped>
:deep(.pi-fake-spacer) {
<style>
.pi-fake-spacer {
height: 1px;
width: 16px;
}
</style>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
</style>

View File

@@ -1,8 +1,5 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<SidebarTabTemplate :title="$t('sideToolbar.nodeLibrary')">
<template #tool-buttons>
<Button
class="new-folder-button"
@@ -21,41 +18,44 @@
v-tooltip="$t('sideToolbar.nodeLibraryTab.sortOrder')"
/>
</template>
<template #header>
<SearchBox
class="node-lib-search-box p-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
</template>
<template #body>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-show="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
class="m-2"
/>
<TreeExplorer
class="node-lib-tree-explorer py-0"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
<div class="flex flex-col h-full">
<div class="flex-shrink-0">
<SearchBox
class="node-lib-search-box mx-4 mt-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/>
<Popover ref="searchFilter" class="node-lib-filter-popup">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
</div>
<div class="flex-grow overflow-y-auto">
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-if="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
/>
<TreeExplorer
class="node-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</div>
</template>
</SidebarTabTemplate>
<div id="node-library-node-preview-container" />
@@ -193,3 +193,23 @@ const onRemoveFilter = (filterAndValue) => {
handleSearch(searchQuery.value)
}
</script>
<style>
.node-lib-filter-popup {
margin-left: -13px;
}
</style>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
:deep(.node-lib-bookmark-tree-explorer) {
padding-bottom: 2px;
}
:deep(.p-divider) {
margin: var(--comfy-tree-explorer-item-padding) 0px;
}
</style>

View File

@@ -36,7 +36,9 @@
icon="pi pi-stop"
severity="danger"
text
@click="() => commandStore.execute('Comfy.ClearPendingTasks')"
@click="
() => commandStore.getCommandFunction('Comfy.ClearPendingTasks')()
"
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
/>
<Button
@@ -249,8 +251,7 @@ const menuItems = computed<MenuItem[]>(() => [
{
label: t('loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
command: () => menuTargetTask.value?.loadWorkflow()
},
{
label: t('goToNode'),
@@ -328,14 +329,6 @@ watch(
overflow-y: auto;
}
.scroll-container::-webkit-scrollbar {
width: 1px;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: transparent;
}
.queue-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));

View File

@@ -1,36 +1,66 @@
<template>
<div
class="comfy-vue-side-bar-container flex flex-col h-full"
:class="props.class"
>
<div class="comfy-vue-side-bar-header">
<Toolbar
class="flex-shrink-0 border-x-0 border-t-0 rounded-none px-2 py-1 min-h-10"
>
<template #start>
<span class="text-sm">{{ props.title.toUpperCase() }}</span>
</template>
<template #end>
<slot name="tool-buttons"></slot>
</template>
</Toolbar>
<slot name="header"></slot>
<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>
<!-- h-0 to force scrollpanel to flex-grow -->
<ScrollPanel class="comfy-vue-side-bar-body flex-grow h-0">
<div class="h-full">
<slot name="body"></slot>
</div>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import Toolbar from 'primevue/toolbar'
import ScrollPanel from 'primevue/scrollpanel'
const props = defineProps<{
title: string
class?: string
}>()
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;
min-height: 2.5rem;
}
.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>

View File

@@ -1,40 +1,48 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.workflows')"
class="bg-[var(--p-tree-background)]"
>
<SidebarTabTemplate :title="$t('sideToolbar.workflows')">
<template #tool-buttons>
<Button
class="browse-templates-button"
icon="pi pi-th-large"
v-tooltip="$t('sideToolbar.browseTemplates')"
text
@click="() => commandStore.execute('Comfy.BrowseTemplates')"
@click="
() => commandStore.getCommandFunction('Comfy.BrowseTemplates')()
"
/>
<Button
class="open-workflow-button"
class="browse-workflows-button"
icon="pi pi-folder-open"
v-tooltip="$t('sideToolbar.openWorkflow')"
v-tooltip="'Browse for an image or exported workflow'"
text
@click="() => commandStore.execute('Comfy.OpenWorkflow')"
@click="() => commandStore.getCommandFunction('Comfy.OpenWorkflow')()"
/>
<Button
class="new-default-workflow-button"
icon="pi pi-code"
v-tooltip="'Load default workflow'"
text
@click="
() => commandStore.getCommandFunction('Comfy.LoadDefaultWorkflow')()
"
/>
<Button
class="new-blank-workflow-button"
icon="pi pi-plus"
v-tooltip="$t('sideToolbar.newBlankWorkflow')"
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
v-tooltip="'Create a new blank workflow'"
@click="
() => commandStore.getCommandFunction('Comfy.NewBlankWorkflow')()
"
text
/>
</template>
<template #header>
<template #body>
<SearchBox
class="workflows-search-box p-4"
class="workflows-search-box mx-4 my-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchWorkflows') + '...'"
/>
</template>
<template #body>
<div class="comfyui-workflows-panel" v-if="!isSearching">
<div
class="comfyui-workflows-open"
@@ -219,3 +227,9 @@ const selectionKeys = computed(() => ({
[`root/${workflowStore.activeWorkflow?.name}.json`]: true
}))
</script>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
</style>

View File

@@ -4,9 +4,6 @@
{{ modelDef.title }}
</div>
<div class="model_preview_top_container">
<div class="model_preview_filename">
{{ modelDef.file_name }}
</div>
<div class="model_preview_architecture" v-if="modelDef.architecture_id">
<span class="model_preview_prefix">Architecture: </span>
{{ modelDef.architecture_id }}
@@ -78,9 +75,7 @@ const modelDef = props.modelDef
}
.model_preview_top_container {
text-align: center;
line-height: 0.5;
}
.model_preview_filename,
.model_preview_author,
.model_preview_architecture {
display: inline-block;

View File

@@ -90,7 +90,6 @@ const showPreview = computed(() => {
!modelDef.value.is_fake_object &&
modelDef.value.has_loaded_metadata &&
(modelDef.value.author ||
modelDef.value.simplified_file_name != modelDef.value.title ||
modelDef.value.description ||
modelDef.value.usage_hint ||
modelDef.value.trigger_phrase ||

View File

@@ -1,6 +1,6 @@
<template>
<TreeExplorer
class="node-lib-bookmark-tree-explorer py-0"
class="node-lib-bookmark-tree-explorer"
ref="treeExplorerRef"
:roots="renderedBookmarkedRoot.children"
:expandedKeys="expandedKeys"

View File

@@ -25,9 +25,7 @@
:src="item.url"
:contain="false"
class="galleria-image"
v-if="item.isImage"
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
</template>
</Galleria>
</template>
@@ -37,7 +35,6 @@ import { ref, watch, onMounted, onUnmounted } from 'vue'
import Galleria from 'primevue/galleria'
import { ResultItemImpl } from '@/stores/queueStore'
import ComfyImage from '@/components/common/ComfyImage.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)

View File

@@ -1,25 +1,27 @@
<template>
<div class="result-container" ref="resultContainer">
<ComfyImage
v-if="result.isImage"
:src="result.url"
class="task-output-image"
:contain="imageFit === 'contain'"
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<template
v-if="result.mediaType === 'images' || result.mediaType === 'gifs'"
>
<ComfyImage
:src="result.url"
class="task-output-image"
:contain="imageFit === 'contain'"
/>
<div class="image-preview-mask">
<Button
icon="pi pi-eye"
severity="secondary"
@click="emit('preview', result)"
rounded
/>
</div>
</template>
<!-- TODO: handle more media types -->
<div v-else class="task-result-preview">
<i class="pi pi-file"></i>
<span>{{ result.mediaType }}</span>
</div>
<div v-if="result.supportsPreview" class="preview-mask">
<Button
icon="pi pi-eye"
severity="secondary"
@click="emit('preview', result)"
rounded
/>
</div>
</div>
</template>
@@ -29,7 +31,6 @@ import ComfyImage from '@/components/common/ComfyImage.vue'
import Button from 'primevue/button'
import { computed, onMounted, ref } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{
result: ResultItemImpl
@@ -66,7 +67,7 @@ onMounted(() => {
align-items: center;
}
.preview-mask {
.image-preview-mask {
position: absolute;
left: 50%;
top: 50%;
@@ -79,7 +80,7 @@ onMounted(() => {
z-index: 1;
}
.result-container:hover .preview-mask {
.result-container:hover .image-preview-mask {
opacity: 1;
}
</style>

View File

@@ -1,30 +0,0 @@
<template>
<video controls width="100%" height="100%">
<source :src="url" :type="htmlVideoType" />
{{ $t('videoFailedToLoad') }}
</video>
</template>
<script setup lang="ts">
import { ResultItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { computed } from 'vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const settingStore = useSettingStore()
const vhsAdvancedPreviews = computed(() =>
settingStore.get('VHS.AdvancedPreviews')
)
const url = computed(() =>
vhsAdvancedPreviews.value
? props.result.vhsAdvancedPreviewUrl
: props.result.url
)
const htmlVideoType = computed(() =>
vhsAdvancedPreviews.value ? 'video/webm' : props.result.htmlVideoType
)
</script>

View File

@@ -8,14 +8,10 @@
@preview="handlePreview"
/>
</template>
<template v-if="task.displayStatus === TaskItemDisplayStatus.Running">
<i v-if="!progressPreviewBlobUrl" class="pi pi-spin pi-spinner"></i>
<img
v-else
:src="progressPreviewBlobUrl"
class="progress-preview-img"
/>
</template>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Running"
class="pi pi-spin pi-spinner"
></i>
<span v-else-if="task.displayStatus === TaskItemDisplayStatus.Pending"
>...</span
>
@@ -64,14 +60,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import ResultItem from './ResultItem.vue'
import { TaskItemDisplayStatus, type TaskItemImpl } from '@/stores/queueStore'
import { ComfyNode } from '@/types/comfyWorkflow'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
const props = defineProps<{
task: TaskItemImpl
@@ -83,13 +77,11 @@ const coverResult = flatOutputs.length
? props.task.previewOutput || flatOutputs[0]
: null
// Using `==` instead of `===` because NodeId can be a string or a number
const node: ComfyNode | null =
flatOutputs.length && props.task.workflow
? props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult.nodeId
) ?? null
: null
const progressPreviewBlobUrl = ref('')
const node: ComfyNode | null = flatOutputs.length
? props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult.nodeId
) ?? null
: null
const emit = defineEmits<{
(
@@ -100,14 +92,6 @@ const emit = defineEmits<{
(e: 'task-output-length-clicked', value: TaskItemImpl): void
}>()
onMounted(() => {
api.addEventListener('b_preview', onProgressPreviewReceived)
})
onUnmounted(() => {
api.removeEventListener('b_preview', onProgressPreviewReceived)
})
const handleContextMenu = (e: MouseEvent) => {
emit('contextmenu', { task: props.task, event: e, node })
}
@@ -140,7 +124,7 @@ const taskStatusText = (status: TaskItemDisplayStatus) => {
case TaskItemDisplayStatus.Pending:
return 'Pending'
case TaskItemDisplayStatus.Running:
return '<i class="pi pi-spin pi-spinner" style="font-weight: bold"></i> Running'
return 'Running'
case TaskItemDisplayStatus.Completed:
return '<i class="pi pi-check" style="font-weight: bold"></i>'
case TaskItemDisplayStatus.Failed:
@@ -156,12 +140,6 @@ const formatTime = (time?: number) => {
}
return `${time.toFixed(2)}s`
}
const onProgressPreviewReceived = async ({ detail }: CustomEvent) => {
if (props.task.displayStatus === TaskItemDisplayStatus.Running) {
progressPreviewBlobUrl.value = URL.createObjectURL(detail)
}
}
</script>
<style scoped>
@@ -219,11 +197,4 @@ are floating on top of images. */
display: flex;
flex-direction: column;
}
.progress-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
</style>

View File

@@ -1,6 +1,3 @@
// @ts-strict-ignore
// Disabled because of https://github.com/Comfy-Org/ComfyUI_frontend/issues/1184
import { mount } from '@vue/test-utils'
import { expect, describe, it } from 'vitest'
import ResultGallery from '../ResultGallery.vue'
@@ -15,12 +12,15 @@ describe('ResultGallery', () => {
let mockResultItem: ResultItemImpl
beforeEach(() => {
mockResultItem = new ResultItemImpl({
mockResultItem = {
filename: 'test.jpg',
type: 'images',
nodeId: 1,
mediaType: 'images'
})
nodeId: 'test',
mediaType: 'images',
url: 'https://picsum.photos/200/300',
urlWithTimestamp: 'https://picsum.photos/200/300?t=123456',
supportsPreview: true
}
})
const mountResultGallery = (props: ResultGalleryProps, options = {}) => {

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