Compare commits

...

21 Commits

Author SHA1 Message Date
filtered
782d93a7a0 Add awaits to various tests 2024-11-15 01:28:48 +11:00
filtered
7be14c5189 Add nodeTemplate tests
Test failure confirmed when links are not connected
2024-11-15 01:28:18 +11:00
Chenlei Hu
ee5c127146 1.3.43 (#1536) 2024-11-13 19:01:44 -05:00
Chenlei Hu
acba6097e0 Replace electron API mocks with actual electron API impl (#1535)
* link electron types locally

* Update electronAPI calls

* Fix source validation

* Payload to raw

* nit

* Update electron types
2024-11-13 17:20:18 -05:00
filtered
82d00a1bcf Update Template copy & paste (#1533)
* Split original clipboard functions out

* Add version check for templates

* Fix regression in use template undo steps
2024-11-13 17:04:31 -05:00
Chenlei Hu
b9224464c0 Fix reverse proxy (#1532) 2024-11-13 15:36:35 -05:00
Chenlei Hu
fba9a03df3 Lazy load setting dialog tabs (#1530) 2024-11-13 10:56:48 -05:00
Chenlei Hu
2fd624cd3d [skip ci] Update README.md (#1529)
Replace screenshot with actual logs for better accessibility.
2024-11-13 10:37:12 -05:00
Chenlei Hu
095fe2a175 Allow access of dev server in LAN for touch device testing (#1528) 2024-11-13 10:34:36 -05:00
Lasse Lauwerys
d838777e04 Touch support bug fixes (#1527)
* Improved touch support

* Fix touch support scaling error

* Fix touch scaling precision on all zoom levels

* Improved touch experiene, fixed zooming on textarea elements and fixed context menu.

* Minor bug fix
2024-11-13 10:14:11 -05:00
filtered
7e0d1d441d Flaky tests and observable state (#1526)
* Fix missing await

* Fix flaky tests - keyboard combos

Old code is causing playwright &/ changeTracker to add an undo step.  Using combo mode resolves flakiness until that can be investigated thoroughly.

* Restore skipped tests

* Fix flaky tests

* Async clean up

* Fix test always fails on retry

* Add TS types (tests)

* Fix flaky test

* Add observable busy state to workflow store

* Add workflow store busy wait to tests

* Rename test for clarity

* Fix flaky tests - use press() from locator API

Ref: https://playwright.dev/docs/api/class-keyboard#keyboard-press

* Fix flaky test - wait next frame

* Add delay between mouse events

Litegraph pointer handling is all custom coded, so a adding a delay between events for a bit of reality is actually beneficial.
2024-11-13 09:35:22 -05:00
Chenlei Hu
ddab149f16 1.3.42 (#1524) 2024-11-12 23:13:49 -05:00
Chenlei Hu
a73fdcd3bd Fix sidebar splitter state (#1523) 2024-11-12 23:12:56 -05:00
filtered
d6e0c197bd Decouple group node from Litegraph copy & paste (#1522)
* nit - Refactor

* Add old clipboard code to groupNode

* [Refactor] groupNode copy / paste functions

* Clarify function name
2024-11-12 23:11:04 -05:00
Chenlei Hu
3117d0fdc1 Fix loading of model library in non-electron env (#1521) 2024-11-12 22:38:29 -05:00
Chenlei Hu
96fda64b70 Fix queue button overlaped by pysssss.ImageFeed (#1520) 2024-11-12 21:35:14 -05:00
oto-ciulis-tt
e3d2c3a814 feat: Add download progress to sidebar (#1490)
* feat: Add download progress to sidebar

* Removing console log

* Lint fixes

* Updating UI

* Fixing lint error

* Fixing lint error

* Fixing lint error

* PR comments

* Reverting change

---------

Co-authored-by: Oto Ciulis <oto.ciulis@gmail.com>
2024-11-12 16:28:55 -05:00
Lasse Lauwerys
1a8900de1f Improved touch support (#1519)
* Improved touch support

* Fix touch support scaling error
2024-11-12 16:19:59 -05:00
Chenlei Hu
05ba526388 Type DOMWidget and DOMWidgetOptions (#1517)
* Type DOMWidget and DOMWidgetOptions

* Annotate widget value type
2024-11-12 13:35:24 -05:00
Chenlei Hu
4bc79181ae Move DOMClippingEnabled to coreSettings.ts (#1516)
* Move DOMClippingEnabled to coreSettings.ts

* nit
2024-11-12 12:01:44 -05:00
filtered
feafbf9cbf Litegraph Reroute Beta (#1421)
* Add Reroute support - ConnectingLinkImpl

Bonus: TS strict

* Add Reroute support

* Remove unused TS expect error

* Add reroute beta opt-in option

* Add settings option: Middle-click reroute node

* Add settings: Link Markers

* Move settings

* Update litegraph

---------

Co-authored-by: huchenlei <huchenlei@proton.me>
2024-11-12 11:46:14 -05:00
41 changed files with 954 additions and 307 deletions

View File

@@ -425,9 +425,29 @@ hook is used to auto-format code on commit.
Note: The dev server will NOT load any extension from the ComfyUI server. Only
core extensions will be loaded.
- Run `npm install` to install the necessary packages
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
- Run `npm run dev:electron` to start the dev server with electron API mocked
#### Access dev server on touch devices
After you start the dev server, you should see following logs:
```
> comfyui-frontend@1.3.42 dev
> vite
VITE v5.4.6 ready in 488 ms
➜ Local: http://localhost:5173/
➜ Network: http://172.21.80.1:5173/
➜ Network: http://192.168.2.20:5173/
➜ press h + enter to show help
```
Make sure your desktop machine and touch device are on the same network. On your touch device,
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
### Unit Test

View File

@@ -0,0 +1,10 @@
[
{
"name": "Three Nodes Template",
"data": "{\"nodes\":[{\"id\":7,\"type\":\"CLIPTextEncode\",\"pos\":[413,389],\"size\":[425.27801513671875,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\"]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"pos\":[415,186],\"size\":[422.84503173828125,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,\"]},{\"id\":4,\"type\":\"CheckpointLoaderSimple\",\"pos\":[26,474],\"size\":[315,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\"]}],\"groups\":[],\"reroutes\":[],\"links\":[{\"id\":5,\"origin_id\":4,\"origin_slot\":1,\"target_id\":7,\"target_slot\":0,\"type\":\"CLIP\"},{\"id\":3,\"origin_id\":4,\"origin_slot\":1,\"target_id\":6,\"target_slot\":0,\"type\":\"CLIP\"}]}"
},
{
"name": "Completely empty template",
"data": "{\"nodes\":[],\"groups\":[],\"reroutes\":[],\"links\":[]}"
}
]

View File

@@ -0,0 +1,6 @@
[
{
"name": "vintageClipboard Template",
"data": "{\"nodes\":[{\"id\":-1,\"type\":\"CheckpointLoaderSimple\",\"pos\":[26,474],\"size\":[315,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\"]},{\"id\":-1,\"type\":\"CLIPTextEncode\",\"pos\":[415,186],\"size\":[422.84503173828125,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,\"]},{\"id\":-1,\"type\":\"CLIPTextEncode\",\"pos\":[413,389],\"size\":[425.27801513671875,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\"]}],\"links\":[[0,1,1,0,4],[0,1,2,0,4]]}"
}
]

View File

@@ -3,6 +3,9 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from './fixtures/ComfyPage'
import type { useWorkspaceStore } from '../src/stores/workspaceStore'
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
@@ -19,38 +22,40 @@ test.describe('Change Tracker', () => {
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setupWorkflowsDirectory({})
})
test('Can undo multiple operations', async ({ comfyPage }) => {
function isModified() {
return comfyPage.page.evaluate(async () => {
return window['app'].extensionManager.workflow.activeWorkflow
.isModified
return !!(window['app'].extensionManager as WorkspaceStore).workflow
.activeWorkflow?.isModified
})
}
function getUndoQueueSize() {
return comfyPage.page.evaluate(() => {
const workflow =
window['app'].extensionManager.workflow.activeWorkflow
return workflow.changeTracker.undoQueue.length
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.undoQueue.length
})
}
function getRedoQueueSize() {
return comfyPage.page.evaluate(() => {
const workflow =
window['app'].extensionManager.workflow.activeWorkflow
return workflow.changeTracker.redoQueue.length
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.redoQueue.length
})
}
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
await comfyPage.page.waitForFunction(
() => !window['app'].extensionManager.workflow.activeWorkflow.isModified
)
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await isModified()).toBe(false)
// TODO(huchenlei): Investigate why saving the workflow is causing the
// undo queue to be triggered.
expect(await getUndoQueueSize()).toBe(1)
@@ -75,13 +80,11 @@ test.describe('Change Tracker', () => {
expect(await getUndoQueueSize()).toBe(2)
expect(await getRedoQueueSize()).toBe(1)
// TODO(huchenlei): Following assertion is flaky.
// Seems like ctrlZ() is not triggered correctly.
// await comfyPage.ctrlZ()
// await expect(node).not.toBeCollapsed()
// expect(await isModified()).toBe(false)
// expect(await getUndoQueueSize()).toBe(1)
// expect(await getRedoQueueSize()).toBe(2)
await comfyPage.ctrlZ()
await expect(node).not.toBeCollapsed()
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(2)
})
})
@@ -109,7 +112,7 @@ test.describe('Change Tracker', () => {
await expect(node).not.toBeCollapsed()
// Run again, but within a change transaction
beforeChange(comfyPage)
await beforeChange(comfyPage)
await node.click('collapse')
await comfyPage.ctrlB()
@@ -117,7 +120,7 @@ test.describe('Change Tracker', () => {
await expect(node).toBeBypassed()
// End transaction
afterChange(comfyPage)
await afterChange(comfyPage)
// Ensure undo reverts both changes
await comfyPage.ctrlZ()
@@ -125,7 +128,7 @@ test.describe('Change Tracker', () => {
await expect(node).not.toBeCollapsed()
})
test('Can group multiple transaction calls into a single one', async ({
test('Can nest multiple change transactions without adding undo steps', async ({
comfyPage
}) => {
const node = (await comfyPage.getFirstNodeRef())!

View File

@@ -15,9 +15,9 @@ test.describe('Copy Paste', () => {
await textBox.click()
const originalString = await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.ctrlV()
await comfyPage.ctrlC(null)
await comfyPage.ctrlV(null)
await comfyPage.ctrlV(null)
const resultString = await textBox.inputValue()
expect(resultString).toBe(originalString + originalString)
})
@@ -31,7 +31,7 @@ test.describe('Copy Paste', () => {
y: 643
}
})
await comfyPage.ctrlC()
await comfyPage.ctrlC(null)
// KSampler's seed
await comfyPage.canvas.click({
position: {
@@ -39,7 +39,7 @@ test.describe('Copy Paste', () => {
y: 281
}
})
await comfyPage.ctrlV()
await comfyPage.ctrlV(null)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
})
@@ -51,14 +51,14 @@ test.describe('Copy Paste', () => {
comfyPage
}) => {
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
await comfyPage.ctrlC(null)
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await comfyPage.ctrlV()
await comfyPage.ctrlC(null)
await comfyPage.ctrlV(null)
await comfyPage.ctrlV(null)
await expect(comfyPage.canvas).toHaveScreenshot(
'paste-in-text-area-with-node-previously-copied.png'
)
@@ -69,10 +69,10 @@ test.describe('Copy Paste', () => {
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.ctrlC()
await comfyPage.ctrlC(null)
// Unfocus textbox.
await comfyPage.page.mouse.click(10, 10)
await comfyPage.ctrlV()
await comfyPage.ctrlV(null)
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
})

View File

@@ -36,6 +36,7 @@ test.describe('Execution error', () => {
}) => {
await comfyPage.loadWorkflow('execution_error')
await comfyPage.queueButton.click()
await comfyPage.nextFrame()
// Wait for the element with the .comfy-execution-error selector to be visible
const executionError = comfyPage.page.locator('.comfy-error-report')
@@ -93,7 +94,7 @@ test.describe('Settings', () => {
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 () => {
await test.step('Setting should persist', async () => {
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
})
})

View File

@@ -48,7 +48,7 @@ test.describe('Topbar commands', () => {
})
})
const menuItem: Locator = await comfyPage.menu.topbar.getMenuItem('ext')
const menuItem = comfyPage.menu.topbar.getMenuItem('ext')
expect(await menuItem.count()).toBe(0)
})

View File

@@ -77,6 +77,7 @@ export class ComfyPage {
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
public readonly widgetTextBox: Locator
public readonly contextMenu: Locator
// Buttons
public readonly resetViewButton: Locator
@@ -107,6 +108,7 @@ export class ComfyPage {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.canvas = page.locator('#graph-canvas')
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.contextMenu = page.locator('.litegraph.litecontextmenu')
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.workflowUploadInput = page.locator('#comfy-file-input')
@@ -148,6 +150,12 @@ export class ComfyPage {
})
}
async getGraphSelectedItemsCount(): Promise<number | undefined> {
return await this.page.evaluate(() => {
return window['app']?.canvas?.selectedItems?.size
})
}
async setupWorkflowsDirectory(structure: FolderStructure) {
const resp = await this.request.post(
`${this.url}/api/devtools/setup_folder_structure`,
@@ -191,6 +199,39 @@ export class ComfyPage {
return await resp.json()
}
async clearNodeTemplates() {
const resp = await this.request.delete(
`${this.url}/api/userdata/comfy.templates.json`,
{
headers: { 'Comfy-User': this.id }
}
)
const status = resp.status()
if (status !== 204 && status !== 404)
throw new Error(`Failed to delete node templates: ${await resp.text()}`)
}
async setNodeTemplates(fileName: string) {
const path = this.assetPath(fileName)
const data = fs.readFileSync(path, 'utf-8')
const resp = await this.request.post(
`${this.url}/api/userdata/comfy.templates.json`,
{
headers: {
'Comfy-User': this.id,
overwrite: 'true',
full_info: 'true'
},
data
}
)
if (resp.status() !== 200)
throw new Error(`Failed to upload node templates: ${await resp.text()}`)
}
async setupSettings(settings: Record<string, any>) {
const resp = await this.request.post(
`${this.url}/api/devtools/set_settings`,
@@ -349,6 +390,12 @@ export class ComfyPage {
await this.nextFrame()
}
async getToastErrorCount() {
return await this.page
.locator('.p-toast-message.p-toast-message-error')
.count()
}
async getVisibleToastCount() {
return await this.page.locator('.p-toast:visible').count()
}
@@ -393,11 +440,17 @@ export class ComfyPage {
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position) {
async dragAndDrop(
source: Position,
target: Position,
modifierKey?: 'ControlOrMeta' | 'Control' | 'Alt' | 'Shift'
) {
if (modifierKey) await this.page.keyboard.down(modifierKey)
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y)
await this.page.mouse.up()
if (modifierKey) await this.page.keyboard.up(modifierKey)
await this.nextFrame()
}
@@ -545,12 +598,15 @@ export class ComfyPage {
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
await this.canvas.click({
position: { x: 10, y: 10 },
button: 'right'
})
await expect(this.contextMenu).toBeVisible()
}
async doubleClickCanvas() {
await this.page.mouse.dblclick(10, 10)
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
}
@@ -561,7 +617,7 @@ export class ComfyPage {
y: 625
}
})
this.page.mouse.move(10, 10)
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -573,10 +629,14 @@ export class ComfyPage {
},
button: 'right'
})
this.page.mouse.move(10, 10)
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async clickContextMenuItem(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name }).click()
}
async select2Nodes() {
// Select 2 CLIP nodes.
await this.page.keyboard.down('Control')
@@ -586,43 +646,42 @@ export class ComfyPage {
await this.nextFrame()
}
async ctrlSend(keyToPress: string) {
await this.page.keyboard.down('Control')
await this.page.keyboard.press(keyToPress)
await this.page.keyboard.up('Control')
async ctrlSend(keyToPress: string, locator: Locator | null = this.canvas) {
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await this.nextFrame()
}
async ctrlA() {
await this.ctrlSend('KeyA')
async ctrlA(locator?: Locator | null) {
await this.ctrlSend('KeyA', locator)
}
async ctrlB() {
await this.ctrlSend('KeyB')
async ctrlB(locator?: Locator | null) {
await this.ctrlSend('KeyB', locator)
}
async ctrlC() {
await this.ctrlSend('KeyC')
async ctrlC(locator?: Locator | null) {
await this.ctrlSend('KeyC', locator)
}
async ctrlV() {
await this.ctrlSend('KeyV')
async ctrlV(locator?: Locator | null) {
await this.ctrlSend('KeyV', locator)
}
async ctrlZ() {
await this.ctrlSend('KeyZ')
async ctrlZ(locator?: Locator | null) {
await this.ctrlSend('KeyZ', locator)
}
async ctrlY() {
await this.ctrlSend('KeyY')
async ctrlY(locator?: Locator | null) {
await this.ctrlSend('KeyY', locator)
}
async ctrlArrowUp() {
await this.ctrlSend('ArrowUp')
async ctrlArrowUp(locator?: Locator | null) {
await this.ctrlSend('ArrowUp', locator)
}
async ctrlArrowDown() {
await this.ctrlSend('ArrowDown')
async ctrlArrowDown(locator?: Locator | null) {
await this.ctrlSend('ArrowDown', locator)
}
async closeMenu() {

View File

@@ -13,35 +13,46 @@ export class Topbar {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
async getMenuItem(itemLabel: string): Promise<Locator> {
getMenuItem(itemLabel: string): Locator {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
async getWorkflowTab(tabName: string): Promise<Locator> {
getWorkflowTab(tabName: string): Locator {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
.locator('..')
}
async closeWorkflowTab(tabName: string) {
const tab = await this.getWorkflowTab(tabName)
const tab = 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)
getSaveDialog(): Locator {
return this.page.locator('.p-dialog-content input')
}
async saveWorkflowAs(workflowName: string) {
await this.triggerTopbarCommand(['Workflow', 'Save As'])
await this.page.locator('.p-dialog-content input').fill(workflowName)
saveWorkflow(workflowName: string): Promise<void> {
return this._saveWorkflow(workflowName, 'Save')
}
saveWorkflowAs(workflowName: string): Promise<void> {
return this._saveWorkflow(workflowName, 'Save As')
}
async _saveWorkflow(workflowName: string, command: 'Save' | 'Save As') {
await this.triggerTopbarCommand(['Workflow', command])
await this.getSaveDialog().fill(workflowName)
await this.page.keyboard.press('Enter')
// Wait for workflow service to finish saving
await this.page.waitForFunction(
() => !window['app'].extensionManager.workflow.isBusy,
undefined,
{ timeout: 3000 }
)
// Wait for the dialog to close.
await this.page.waitForTimeout(300)
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
}
async triggerTopbarCommand(path: string[]) {

View File

@@ -285,7 +285,8 @@ test.describe('Node Interaction', () => {
position: {
x: 50,
y: 10
}
},
delay: 5
})
await comfyPage.page.keyboard.type('Hello World')
await comfyPage.page.keyboard.press('Enter')
@@ -300,7 +301,8 @@ test.describe('Node Interaction', () => {
position: {
x: 50,
y: 50
}
},
delay: 5
})
expect(await comfyPage.page.locator('.node-title-editor').count()).toBe(0)
})
@@ -352,7 +354,8 @@ test.describe('Group Interaction', () => {
position: {
x: 50,
y: 10
}
},
delay: 5
})
await comfyPage.page.keyboard.type('Hello World')
await comfyPage.page.keyboard.press('Enter')
@@ -504,7 +507,7 @@ test.describe('Widget Interaction', () => {
await expect(textBox).toHaveValue('')
await textBox.fill('Hello World')
await expect(textBox).toHaveValue('Hello World')
await comfyPage.ctrlZ()
await comfyPage.ctrlZ(null)
await expect(textBox).toHaveValue('')
})
@@ -515,9 +518,9 @@ test.describe('Widget Interaction', () => {
await textBox.fill('1girl')
await expect(textBox).toHaveValue('1girl')
await textBox.selectText()
await comfyPage.ctrlArrowUp()
await comfyPage.ctrlArrowUp(null)
await expect(textBox).toHaveValue('(1girl:1.05)')
await comfyPage.ctrlZ()
await comfyPage.ctrlZ(null)
await expect(textBox).toHaveValue('1girl')
})
})

View File

@@ -504,6 +504,7 @@ test.describe('Menu', () => {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
await comfyPage.nextFrame()
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow'
])
@@ -525,8 +526,7 @@ test.describe('Menu', () => {
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
const workflowMenuItem =
await comfyPage.menu.topbar.getMenuItem('Workflow')
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
await workflowMenuItem.click()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'

View File

@@ -15,7 +15,7 @@ test.describe('Node search box', () => {
test(`Can trigger on group body double click`, async ({ comfyPage }) => {
await comfyPage.loadWorkflow('single_group_only')
await comfyPage.page.mouse.dblclick(50, 50)
await comfyPage.page.mouse.dblclick(50, 50, { delay: 5 })
await comfyPage.nextFrame()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})

View File

@@ -0,0 +1,70 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from './fixtures/ComfyPage'
// Old `nodeTemplate.ts` system
test.describe('Node Template', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.clearNodeTemplates()
})
test('Can create and use node template', async ({ comfyPage }) => {
const templateName = 'Can create node template template'
await comfyPage.clearNodeTemplates()
await comfyPage.reload()
// TODO: Flaky test. Right click requires delay after reload, but other interactions do not.
await comfyPage.page.waitForTimeout(500)
// Enter filename when prompt dialog shown
comfyPage.page.on('dialog', (dialog) => dialog.accept(templateName))
// Ctrl + drag over 3 nodes
await comfyPage.dragAndDrop(
{ x: 175, y: 252 },
{ x: 483, y: 564 },
'ControlOrMeta'
)
expect(await comfyPage.getGraphSelectedItemsCount()).toEqual(3)
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Save Selected as Template')
await comfyPage.nextFrame()
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Node Templates >')
await comfyPage.clickContextMenuItem(templateName)
await expect(comfyPage.canvas).toHaveScreenshot()
})
test('Can load old format template', async ({ comfyPage }) => {
await comfyPage.setNodeTemplates('vintage_clipboard_template.json')
await comfyPage.reload()
// TODO: Flaky test. Right click requires delay after reload, but other interactions do not.
await comfyPage.page.waitForTimeout(500)
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Node Templates >')
await comfyPage.clickContextMenuItem('vintageClipboard Template')
await expect(comfyPage.canvas).toHaveScreenshot()
})
test('Can load new format template', async ({ comfyPage }) => {
await comfyPage.setNodeTemplates('node_template_templates.json')
await comfyPage.reload()
// TODO: Flaky test. Right click requires delay after reload, but other interactions do not.
await comfyPage.page.waitForTimeout(500)
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Node Templates >')
await comfyPage.clickContextMenuItem('Three Nodes Template')
await expect(comfyPage.canvas).toHaveScreenshot()
})
})

20
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "comfyui-frontend",
"version": "1.3.41",
"version": "1.3.43",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.3.41",
"version": "1.3.43",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.2.10",
"@comfyorg/litegraph": "^0.8.25",
"@comfyorg/comfyui-electron-types": "^0.2.16",
"@comfyorg/litegraph": "^0.8.26",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -1916,15 +1916,15 @@
"dev": true
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.2.10.tgz",
"integrity": "sha512-JwqFeqmJBp6n276Ki+VEkMkO43rFHobdt93AzJYpWC+BXGUuvTyquon/MvblWtJDnTdO0mGWGXztDFe0sXie6A==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.2.16.tgz",
"integrity": "sha512-Hm6NeyMK4sd2V5AyOnvfI+tvCsXr5NBG8wOZlWyyD17ADpbQnpm6qPMWzvm4vCp/YvTR7cUbDGiY0quhofuQGg==",
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.25",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.25.tgz",
"integrity": "sha512-VYMpxNLAwLgmT1mFX77RNA3O5KavhWBmYJpb3+BLW6BwmnDCd0QHX9gy5IFsGSpQP28k2lWgANIcGZF2Ev2eqg==",
"version": "0.8.26",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.26.tgz",
"integrity": "sha512-q0Vcd5usphR5nghfyFksVx+VM+eSB1MyX8Ne304KFDnr214KQMA6DAjrEQJlGBUUCybLiOtPCvd3dxPecEQiSQ==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.3.41",
"version": "1.3.43",
"type": "module",
"scripts": {
"dev": "vite",
@@ -72,8 +72,8 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.2.10",
"@comfyorg/litegraph": "^0.8.25",
"@comfyorg/comfyui-electron-types": "^0.2.16",
"@comfyorg/litegraph": "^0.8.26",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",

View File

@@ -83,7 +83,10 @@ body {
/* Position at the first row */
grid-row: 1;
/* Top menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
z-index: 1000;
/* Top menu bar z-index needs to be higher than bottom menu bar z-index as by default
pysssss's image feed is located at body-bottom, and it can overlap with the queue button, which
is located in body-top. */
z-index: 1001;
display: flex;
flex-direction: column;
}

View File

@@ -59,6 +59,7 @@
:disabled="props.error"
@click="triggerCancelDownload"
icon="pi pi-times-circle"
severity="danger"
v-tooltip.top="t('electronFileDownload.cancel')"
/>
</div>
@@ -73,6 +74,7 @@ import { ref, computed } from 'vue'
import { formatSize } from '@/utils/formatUtil'
import { useI18n } from 'vue-i18n'
import { electronAPI } from '@/utils/envUtil'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
const props = defineProps<{
url: string
@@ -81,55 +83,37 @@ const props = defineProps<{
error?: string
}>()
interface ModelDownload {
url: string
status: 'paused' | 'in_progress' | 'cancelled'
progress: number
}
const { t } = useI18n()
const { DownloadManager } = electronAPI()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const status = ref<ModelDownload | null>(null)
const downloadProgress = ref<number>(0)
const status = ref<string | null>(null)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const electronDownloadStore = useElectronDownloadStore()
const [savePath, filename] = props.label.split('/')
const downloads: ModelDownload[] = await DownloadManager.getAllDownloads()
const modelDownload = downloads.find(({ url }) => url === props.url)
electronDownloadStore.$subscribe((mutation, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
const updateProperties = (download: ModelDownload) => {
if (download.url === props.url) {
if (download) {
downloadProgress.value = Number((download.progress * 100).toFixed(1))
status.value = download.status
downloadProgress.value = (download.progress * 100).toFixed(1)
}
}
DownloadManager.onDownloadProgress((data: ModelDownload) => {
updateProperties(data)
})
const triggerDownload = async () => {
await DownloadManager.startDownload(
props.url,
filename.trim(),
savePath.trim()
)
await electronDownloadStore.start({
url: props.url,
savePath: savePath.trim(),
filename: filename.trim()
})
}
const triggerCancelDownload = async () => {
await DownloadManager.cancelDownload(props.url)
}
const triggerPauseDownload = async () => {
await DownloadManager.pauseDownload(props.url)
}
const triggerResumeDownload = async () => {
await DownloadManager.resumeDownload(props.url)
}
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
</script>

View File

@@ -18,7 +18,7 @@
</ScrollPanel>
<Divider layout="vertical" class="mx-1 2xl:mx-4" />
<ScrollPanel class="settings-content flex-grow">
<Tabs :value="tabValue">
<Tabs :value="tabValue" :lazy="true">
<FirstTimeUIMessage v-if="tabValue === 'Comfy'" />
<TabPanels class="settings-tab-panels">
<TabPanel key="search-results" value="Search Results">

View File

@@ -1,7 +1,10 @@
<template>
<teleport to=".graph-canvas-container">
<!-- Load splitter overlay only after comfyApp is ready. -->
<!-- If load immediately, the top-level splitter stateKey won't be correctly
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay
v-if="betaMenuEnabled && !workspaceStore.focusMode"
v-if="comfyAppReady && betaMenuEnabled && !workspaceStore.focusMode"
>
<template #side-bar-panel>
<SideToolbar />
@@ -104,6 +107,12 @@ watchEffect(() => {
)
})
watchEffect(() => {
LiteGraph.middle_click_slot_add_default_node = settingStore.get(
'Comfy.Node.MiddleClickRerouteNode'
)
})
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -134,6 +143,24 @@ watchEffect(() => {
}
})
watchEffect(() => {
const linkMarkerShape = settingStore.get('Comfy.Graph.LinkMarkers')
const { canvas } = canvasStore
if (canvas) {
canvas.linkMarkerShape = linkMarkerShape
canvas.setDirty(false, true)
}
})
watchEffect(() => {
const reroutesEnabled = settingStore.get('Comfy.RerouteBeta')
const { canvas } = canvasStore
if (canvas) {
canvas.reroutesEnabled = reroutesEnabled
canvas.setDirty(false, true)
}
})
watchEffect(() => {
if (!canvasStore.canvas) return
@@ -213,6 +240,7 @@ usePragmaticDroppable(() => canvasRef.value, {
}
})
const comfyAppReady = ref(false)
onMounted(async () => {
// Backward compatible
// Assign all properties of lg to window
@@ -239,6 +267,7 @@ onMounted(async () => {
window['app'] = comfyApp
window['graph'] = comfyApp.graph
comfyAppReady.value = true
emit('ready')
})
</script>

View File

@@ -77,8 +77,7 @@ const pathError = defineModel<string>('pathError', { required: true })
const appData = ref('')
const appPath = ref('')
// TODO: Implement the actual electron API.
const electron = electronAPI() as any
const electron = electronAPI()
// Get system paths on component mount
onMounted(async () => {

View File

@@ -68,38 +68,45 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watchEffect } from 'vue'
import { ref, computed, watchEffect } from 'vue'
import { electronAPI } from '@/utils/envUtil'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Message from 'primevue/message'
import { useI18n } from 'vue-i18n'
import { MigrationItems } from '@comfyorg/comfyui-electron-types'
const { t } = useI18n()
const electron = electronAPI() as any
const electron = electronAPI()
const sourcePath = defineModel<string>('sourcePath', { required: false })
const migrationItemIds = defineModel<string[]>('migrationItemIds', {
required: false
})
const migrationItems = ref([])
const migrationItems = ref(
MigrationItems.map((item) => ({
...item,
selected: true
}))
)
const pathError = ref('')
const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
const validateSource = async () => {
if (!sourcePath.value) {
const validateSource = async (sourcePath: string) => {
if (!sourcePath) {
pathError.value = ''
return
}
try {
pathError.value = ''
const validation = await electron.validateComfyUISource(sourcePath.value)
const validation = await electron.validateComfyUISource(sourcePath)
if (!validation.isValid) pathError.value = validation.error
} catch (error) {
@@ -113,7 +120,7 @@ const browsePath = async () => {
const result = await electron.showDirectoryPicker()
if (result) {
sourcePath.value = result
await validateSource()
await validateSource(result)
}
} catch (error) {
console.error(error)
@@ -121,13 +128,6 @@ const browsePath = async () => {
}
}
onMounted(async () => {
migrationItems.value = (await electron.migrationItems()).map((item) => ({
...item,
selected: true
}))
})
watchEffect(() => {
migrationItemIds.value = migrationItems.value
.filter((item) => item.selected)

View File

@@ -58,7 +58,6 @@ const getNewNodeLocation = (): [number, number] => {
}
const originalEvent = triggerEvent.value.detail.originalEvent
// @ts-expect-error LiteGraph types are not typed
return [originalEvent.canvasX, originalEvent.canvasY]
}
const nodeFilters = ref<FilterAndValue[]>([])
@@ -153,8 +152,16 @@ const showContextMenu = (e: LiteGraphCanvasEvent) => {
showSearchBox: () => showSearchBox(e)
}
const connectionOptions = firstLink.output
? { nodeFrom: firstLink.node, slotFrom: firstLink.output }
: { nodeTo: firstLink.node, slotTo: firstLink.input }
? {
nodeFrom: firstLink.node,
slotFrom: firstLink.output,
afterRerouteId: firstLink.afterRerouteId
}
: {
nodeTo: firstLink.node,
slotTo: firstLink.input,
afterRerouteId: firstLink.afterRerouteId
}
canvasStore.canvas.showConnectionMenu({
...connectionOptions,
...commonOptions
@@ -178,7 +185,6 @@ const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
} else if (e.detail.subType === 'group-double-click') {
const group = e.detail.group
const [x, y] = group.pos
// @ts-expect-error LiteGraphCanvasEvent is not typed
const relativeY = e.detail.originalEvent.canvasY - y
// Show search box if the click is NOT on the title bar
if (relativeY > group.titleHeight) {

View File

@@ -28,6 +28,8 @@
/>
</template>
<template #body>
<ElectronDownloadItems v-if="isElectron()" />
<TreeExplorer
class="model-lib-tree-explorer py-0"
:roots="renderedRoot.children"
@@ -48,6 +50,7 @@ import SearchBox from '@/components/common/SearchBox.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
import {
ComfyModelDef,
ModelFolder,
@@ -65,6 +68,8 @@ import { computed, ref, watch, toRef, onMounted, nextTick } from 'vue'
import type { TreeNode } from 'primevue/treenode'
import { app } from '@/scripts/app'
import { buildTree } from '@/utils/treeUtil'
import { isElectron } from '@/utils/envUtil'
const modelStore = useModelStore()
const modelToNodeStore = useModelToNodeStore()
const settingStore = useSettingStore()
@@ -164,6 +169,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
}
}
}
return fillNodeInfo(root.value)
})

View File

@@ -0,0 +1,97 @@
<template>
<div class="flex flex-col">
<div>
{{ getDownloadLabel(download.savePath) }}
</div>
<div v-if="['cancelled', 'error'].includes(download.status)">
<Chip
class="h-6 text-sm font-light bg-red-700 mt-2"
removable
@remove="handleRemoveDownload"
>
{{ t('electronFileDownload.cancelled') }}
</Chip>
</div>
<div
class="mt-2 flex flex-row items-center gap-2"
v-if="['in_progress', 'paused', 'completed'].includes(download.status)"
>
<ProgressBar
class="flex-1"
:value="Number((download.progress * 100).toFixed(1))"
/>
<Button
class="file-action-button w-[22px] h-[22px]"
size="small"
rounded
@click="triggerPauseDownload"
v-if="download.status === 'in_progress'"
icon="pi pi-pause"
v-tooltip.top="t('electronFileDownload.pause')"
/>
<Button
class="file-action-button w-[22px] h-[22px]"
size="small"
rounded
@click="triggerResumeDownload"
v-if="download.status === 'paused'"
icon="pi pi-play"
v-tooltip.top="t('electronFileDownload.resume')"
/>
<Button
class="file-action-button w-[22px] h-[22px] p-red"
size="small"
rounded
severity="danger"
@click="triggerCancelDownload"
v-if="['in_progress', 'paused'].includes(download.status)"
icon="pi pi-times-circle"
v-tooltip.top="t('electronFileDownload.cancel')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ProgressBar from 'primevue/progressbar'
import Chip from 'primevue/chip'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
import {
type ElectronDownload,
useElectronDownloadStore
} from '@/stores/electronDownloadStore'
const electronDownloadStore = useElectronDownloadStore()
const props = defineProps<{
download: ElectronDownload
}>()
const getDownloadLabel = (savePath: string, filename: string) => {
let parts = (savePath ?? '').split('/')
parts = parts.length === 1 ? parts[0].split('\\') : parts
const name = parts.pop()
const dir = parts.pop()
return `${dir}/${name}`
}
const triggerCancelDownload = () =>
electronDownloadStore.cancel(props.download.url)
const triggerPauseDownload = () =>
electronDownloadStore.pause(props.download.url)
const triggerResumeDownload = () =>
electronDownloadStore.resume(props.download.url)
const handleRemoveDownload = () => {
electronDownloadStore.$patch((state) => {
state.downloads = state.downloads.filter(
({ url }) => url !== props.download.url
)
state.hasChanged = true
})
}
</script>

View File

@@ -0,0 +1,20 @@
<template>
<div class="mx-6 mb-4" v-if="downloads.length > 0">
<div class="text-lg my-4">
{{ $t('electronFileDownload.inProgress') }}
</div>
<template v-for="download in downloads" :key="download.url">
<DownloadItem :download="download" />
</template>
</div>
</template>
<script setup lang="ts">
import DownloadItem from './DownloadItem.vue'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { storeToRefs } from 'pinia'
const electronDownloadStore = useElectronDownloadStore()
const { downloads } = storeToRefs(electronDownloadStore)
</script>

View File

@@ -3,12 +3,16 @@ import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import { mergeIfValid } from './widgetInputs'
import { ManageGroupDialog } from './groupNodeManage'
import type { LGraphNode } from '@comfyorg/litegraph'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { LGraphCanvas, LiteGraph, type LGraph } from '@comfyorg/litegraph'
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { ComfyLink, ComfyNode, ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import { useToastStore } from '@/stores/toastStore'
import { ComfyExtension } from '@/types/comfy'
import {
deserialiseAndCreate,
serialise
} from '@/extensions/core/vintageClipboard'
type GroupNodeWorkflowData = {
external: ComfyLink[]
@@ -144,21 +148,15 @@ class GroupNodeBuilder {
}
// Use the built in copyToClipboard function to generate the node data we need
const backup = localStorage.getItem('litegrapheditor_clipboard')
try {
// @ts-expect-error
// TODO Figure out if copyToClipboard is really taking this param
app.canvas.copyToClipboard(this.nodes)
const config = JSON.parse(
localStorage.getItem('litegrapheditor_clipboard')
)
const serialised = serialise(this.nodes, app.canvas.graph)
const config = JSON.parse(serialised)
storeLinkTypes(config)
storeExternalLinks(config)
return config
} finally {
localStorage.setItem('litegrapheditor_clipboard', backup)
}
}
}
@@ -842,7 +840,6 @@ export class GroupNodeHandler {
this.node.convertToNodes = () => {
const addInnerNodes = () => {
const backup = localStorage.getItem('litegrapheditor_clipboard')
// Clone the node data so we dont mutate it for other nodes
const c = { ...this.groupData.nodeData }
c.nodes = [...c.nodes]
@@ -858,9 +855,7 @@ export class GroupNodeHandler {
}
c.nodes[i] = { ...c.nodes[i], id }
}
localStorage.setItem('litegrapheditor_clipboard', JSON.stringify(c))
app.canvas.pasteFromClipboard()
localStorage.setItem('litegrapheditor_clipboard', backup)
deserialiseAndCreate(JSON.stringify(c), app.canvas)
const [x, y] = this.node.pos
let top
@@ -923,10 +918,8 @@ export class GroupNodeHandler {
// Shift each node
for (const newNode of newNodes) {
newNode.pos = [
newNode.pos[0] - (left - x),
newNode.pos[1] - (top - y)
]
newNode.pos[0] -= left - x
newNode.pos[1] -= top - y
}
return { newNodes, selectedIds }
@@ -970,11 +963,15 @@ export class GroupNodeHandler {
}
}
app.canvas.emitBeforeChange()
const { newNodes, selectedIds } = addInnerNodes()
reconnectInputs(selectedIds)
reconnectOutputs(selectedIds)
app.graph.remove(this.node)
app.canvas.emitAfterChange()
return newNodes
}

View File

@@ -5,6 +5,7 @@ import { ComfyDialog, $el } from '../../scripts/ui'
import { GroupNodeConfig, GroupNodeHandler } from './groupNode'
import { LGraphCanvas } from '@comfyorg/litegraph'
import { useToastStore } from '@/stores/toastStore'
import { deserialiseAndCreate } from '@/extensions/core/vintageClipboard'
// Adds the ability to save and add multiple nodes as a template
// To save:
@@ -414,8 +415,14 @@ app.registerExtension({
clipboardAction(async () => {
const data = JSON.parse(t.data)
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {})
localStorage.setItem('litegrapheditor_clipboard', t.data)
app.canvas.pasteFromClipboard()
// Check for old clipboard format
if (!data.reroutes) {
deserialiseAndCreate(t.data, app.canvas)
} else {
localStorage.setItem('litegrapheditor_clipboard', t.data)
app.canvas.pasteFromClipboard()
}
})
}
}

View File

@@ -8,10 +8,10 @@ let touchCount = 0
app.registerExtension({
name: 'Comfy.SimpleTouchSupport',
setup() {
let zoomPos
let touchDist
let touchTime
let lastTouch
let lastScale
function getMultiTouchPos(e) {
return Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
@@ -19,11 +19,19 @@ app.registerExtension({
)
}
app.canvasEl.addEventListener(
function getMultiTouchCenter(e) {
return {
clientX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
clientY: (e.touches[0].clientY + e.touches[1].clientY) / 2
}
}
app.canvasEl.parentElement.addEventListener(
'touchstart',
(e) => {
(e: TouchEvent) => {
touchCount++
lastTouch = null
lastScale = null
if (e.touches?.length === 1) {
// Store start time for press+hold for context menu
touchTime = new Date()
@@ -32,7 +40,10 @@ app.registerExtension({
touchTime = null
if (e.touches?.length === 2) {
// Store center pos for zoom
zoomPos = getMultiTouchPos(e)
lastScale = app.canvas.ds.scale
lastTouch = getMultiTouchCenter(e)
touchDist = getMultiTouchPos(e)
app.canvas.pointer_is_down = false
}
}
@@ -40,54 +51,80 @@ app.registerExtension({
true
)
app.canvasEl.addEventListener('touchend', (e: TouchEvent) => {
touchZooming = false
touchCount = e.touches?.length ?? touchCount - 1
app.canvasEl.parentElement.addEventListener('touchend', (e: TouchEvent) => {
touchCount--
if (e.touches?.length !== 1) touchZooming = false
if (touchTime && !e.touches?.length) {
if (new Date().getTime() - touchTime > 600) {
try {
// hack to get litegraph to use this event
e.constructor = CustomEvent
} catch (error) {}
// @ts-expect-error
e.clientX = lastTouch.clientX
// @ts-expect-error
e.clientY = lastTouch.clientY
app.canvas.pointer_is_down = true
// @ts-expect-error
app.canvas._mousedown_callback(e)
if (e.target === app.canvasEl) {
app.canvasEl.dispatchEvent(
new PointerEvent('pointerdown', {
button: 2,
clientX: e.changedTouches[0].clientX,
clientY: e.changedTouches[0].clientY
})
)
e.preventDefault()
}
}
touchTime = null
}
})
app.canvasEl.addEventListener(
app.canvasEl.parentElement.addEventListener(
'touchmove',
(e) => {
touchTime = null
if (e.touches?.length === 2) {
if (e.touches?.length === 2 && lastTouch && !e.ctrlKey && !e.shiftKey) {
e.preventDefault() // Prevent browser from zooming when two textareas are touched
app.canvas.pointer_is_down = false
touchZooming = true
// @ts-expect-error
LiteGraph.closeAllContextMenus()
LiteGraph.closeAllContextMenus(window)
// @ts-expect-error
app.canvas.search_box?.close()
const newZoomPos = getMultiTouchPos(e)
const newTouchDist = getMultiTouchPos(e)
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2
const center = getMultiTouchCenter(e)
let scale = app.canvas.ds.scale
const diff = zoomPos - newZoomPos
if (diff > 0.5) {
scale *= 1 / 1.07
} else if (diff < -0.5) {
scale *= 1.07
let scale = (lastScale * newTouchDist) / touchDist
const newX = (center.clientX - lastTouch.clientX) / scale
const newY = (center.clientY - lastTouch.clientY) / scale
// Code from LiteGraph
if (scale < app.canvas.ds.min_scale) {
scale = app.canvas.ds.min_scale
} else if (scale > app.canvas.ds.max_scale) {
scale = app.canvas.ds.max_scale
}
app.canvas.ds.changeScale(scale, [midX, midY])
const oldScale = app.canvas.ds.scale
app.canvas.ds.scale = scale
// Code from LiteGraph
if (Math.abs(app.canvas.ds.scale - 1) < 0.01) {
app.canvas.ds.scale = 1
}
const newScale = app.canvas.ds.scale
const convertScaleToOffset = (scale) => [
center.clientX / scale - app.canvas.ds.offset[0],
center.clientY / scale - app.canvas.ds.offset[1]
]
var oldCenter = convertScaleToOffset(oldScale)
var newCenter = convertScaleToOffset(newScale)
app.canvas.ds.offset[0] += newX + newCenter[0] - oldCenter[0]
app.canvas.ds.offset[1] += newY + newCenter[1] - oldCenter[1]
lastTouch.clientX = center.clientX
lastTouch.clientY = center.clientY
app.canvas.setDirty(true, true)
zoomPos = newZoomPos
}
},
true
@@ -100,6 +137,7 @@ LGraphCanvas.prototype.processMouseDown = function (e) {
if (touchZooming || touchCount) {
return
}
app.canvas.pointer_is_down = false // Prevent context menu from opening on second tap
return processMouseDown.apply(this, arguments)
}

View File

@@ -5,7 +5,6 @@ import type { IWidget } from '@comfyorg/litegraph'
import type { DOMWidget } from '@/scripts/domWidget'
import { ComfyNodeDef } from '@/types/apiTypes'
import { useToastStore } from '@/stores/toastStore'
import { Widgets } from '@/types/comfy'
type FolderType = 'input' | 'output' | 'temp'
@@ -37,7 +36,7 @@ function getResourceURL(
async function uploadFile(
audioWidget: IWidget,
audioUIWidget: DOMWidget<HTMLAudioElement>,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
file: File,
updateNode: boolean,
pasted: boolean = false
@@ -95,12 +94,10 @@ app.registerExtension({
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement> = node.addDOMWidget(
inputName,
/* name=*/ 'audioUI',
audio,
{ serialize: false }
)
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio, {
serialize: false
})
const isOutputNode = node.constructor.nodeData.output_node
if (isOutputNode) {
@@ -121,7 +118,7 @@ app.registerExtension({
}
return { widget: audioUIWidget }
}
} as Widgets
}
},
onNodeOutputsUpdated(nodeOutputs: Record<number, any>) {
for (const [nodeId, output] of Object.entries(nodeOutputs)) {
@@ -129,7 +126,7 @@ app.registerExtension({
if ('audio' in output) {
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement>
) as unknown as DOMWidget<HTMLAudioElement, string>
const audio = output.audio[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
@@ -156,7 +153,7 @@ app.registerExtension({
)
const audioUIWidget = node.widgets.find(
(w: IWidget) => w.name === 'audioUI'
) as DOMWidget<HTMLAudioElement>
) as unknown as DOMWidget<HTMLAudioElement, string>
const onAudioWidgetUpdate = () => {
audioUIWidget.element.src = api.apiURL(

View File

@@ -0,0 +1,119 @@
// @ts-strict-ignore
import type { LGraph, LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
import { LiteGraph } from '@comfyorg/litegraph'
/**
* Serialises an array of nodes using a modified version of the old Litegraph copy (& paste) function
* @param nodes All nodes to be serialised
* @param graph The graph we are working in
* @returns A serialised string of all nodes, and their connections
* @deprecated Format not in use anywhere else.
*/
export function serialise(nodes: LGraphNode[], graph: LGraph): string {
const serialisable = {
nodes: [],
links: []
}
let index = 0
const cloneable: LGraphNode[] = []
for (const node of nodes) {
if (node.clonable === false) continue
node._relative_id = index++
cloneable.push(node)
}
// Clone the node
for (const node of cloneable) {
const cloned = node.clone()
if (!cloned) {
console.warn('node type not found: ' + node.type)
continue
}
serialisable.nodes.push(cloned.serialize())
if (!node.inputs?.length) continue
// For inputs only, gather link details of every connection
for (const input of node.inputs) {
if (!input || input.link == null) continue
const link = graph.links.get(input.link)
if (!link) continue
const outNode = graph.getNodeById(link.origin_id)
if (!outNode) continue
// Special format for old Litegraph copy & paste only
serialisable.links.push([
outNode._relative_id,
link.origin_slot,
node._relative_id,
link.target_slot,
outNode.id
])
}
}
return JSON.stringify(serialisable)
}
/**
* Deserialises nodes and links using a modified version of the old Litegraph (copy &) paste function
* @param data The serialised nodes and links to create
* @param canvas The canvas to create the serialised items in
*/
export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void {
if (!data) return
const { graph, graph_mouse } = canvas
canvas.emitBeforeChange()
graph.beforeChange()
const deserialised = JSON.parse(data)
// Find the top left point of the boundary of all pasted nodes
const topLeft = [Infinity, Infinity]
for (const { pos } of deserialised.nodes) {
if (topLeft[0] > pos[0]) topLeft[0] = pos[0]
if (topLeft[1] > pos[1]) topLeft[1] = pos[1]
}
// Silent default instead of throw
if (!Number.isFinite(topLeft[0]) || !Number.isFinite(topLeft[1])) {
topLeft[0] = graph_mouse[0]
topLeft[1] = graph_mouse[1]
}
// Create nodes
const nodes: LGraphNode[] = []
for (const info of deserialised.nodes) {
const node = LiteGraph.createNode(info.type)
if (!node) continue
node.configure(info)
// Paste to the bottom right of pointer
node.pos[0] += graph_mouse[0] - topLeft[0]
node.pos[1] += graph_mouse[1] - topLeft[1]
graph.add(node, true)
nodes.push(node)
}
// Create links
for (const info of deserialised.links) {
const relativeId = info[0]
const outNode = relativeId != null ? nodes[relativeId] : undefined
const inNode = nodes[info[2]]
if (outNode && inNode) outNode.connect(info[1], inNode, info[3])
else console.warn('Warning, nodes missing on pasting')
}
canvas.selectNodes(nodes)
graph.afterChange()
canvas.emitAfterChange()
}

View File

@@ -119,6 +119,7 @@ const messages = {
sortOrder: 'Sort Order'
},
modelLibrary: 'Model Library',
downloads: 'Downloads',
queueTab: {
showFlatList: 'Show Flat List',
backToAllTasks: 'Back to All Tasks',
@@ -170,9 +171,12 @@ const messages = {
toggleLinkVisibility: 'Toggle Link Visibility'
},
electronFileDownload: {
inProgress: 'In Progress',
pause: 'Pause Download',
paused: 'Paused',
resume: 'Resume Download',
cancel: 'Cancel Download'
cancel: 'Cancel Download',
cancelled: 'Cancelled'
}
},
zh: {

View File

@@ -22,7 +22,12 @@ const guardElectronAccess = (
}
const router = createRouter({
history: isFileProtocol() ? createWebHashHistory() : createWebHistory(),
history: isFileProtocol()
? createWebHashHistory()
: // Base path must be specified to ensure correct relative paths
// Example: For URL 'http://localhost:7801/ComfyBackendDirect',
// we need this base path or assets will incorrectly resolve from 'http://localhost:7801/'
createWebHistory(window.location.pathname),
routes: [
{
path: '/',

View File

@@ -15,7 +15,6 @@ import {
importA1111,
getLatentMetadata
} from './pnginfo'
import { addDomClippingSetting } from './domWidget'
import { createImageHost, calculateImageGrid } from './ui/imagePreview'
import { DraggableList } from './ui/draggableList'
import { applyTextReplacements, addStylesheet } from './utils'
@@ -65,6 +64,7 @@ import { shallowReactive } from 'vue'
import { type IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { workflowService } from '@/services/workflowService'
import { useWidgetStore } from '@/stores/widgetStore'
import { deserialiseAndCreate } from '@/extensions/core/vintageClipboard'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -1806,7 +1806,6 @@ export class ComfyApp {
])
await this.#loadExtensions()
addDomClippingSetting()
this.#addProcessMouseHandler()
this.#addProcessKeyHandler()
this.#addConfigureHandler()
@@ -2125,8 +2124,14 @@ export class ComfyApp {
continue
}
localStorage.setItem('litegrapheditor_clipboard', template.data)
app.canvas.pasteFromClipboard()
// Check for old clipboard format
const data = JSON.parse(template.data)
if (!data.reroutes) {
deserialiseAndCreate(template.data, app.canvas)
} else {
localStorage.setItem('litegrapheditor_clipboard', template.data)
app.canvas.pasteFromClipboard()
}
// Move mouse position down to paste the next template below

View File

@@ -1,7 +1,12 @@
// @ts-strict-ignore
import { useSettingStore } from '@/stores/settingStore'
import { app, ANIM_PREVIEW_WIDGET } from './app'
import { LGraphCanvas, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type { Vector4 } from '@comfyorg/litegraph'
import {
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
const SIZE = Symbol()
@@ -12,15 +17,20 @@ interface Rect {
y: number
}
export interface DOMWidget<T = HTMLElement> {
type: string
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends ICustomWidget<T> {
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
type: 'custom'
name: string
computedHeight?: number
element?: T
options: any
value?: any
options: DOMWidgetOptions<T, V>
value: V
y?: number
callback?: (value: any) => void
callback?: (value: V) => void
/**
* Draw the widget on the canvas.
*/
draw?: (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
@@ -28,9 +38,31 @@ export interface DOMWidget<T = HTMLElement> {
y: number,
widgetHeight: number
) => void
/**
* TODO(huchenlei): Investigate when is this callback fired. `onRemove` is
* on litegraph's IBaseWidget definition, but not called in litegraph.
* Currently only called in widgetInputs.ts.
*/
onRemove?: () => void
}
export interface DOMWidgetOptions<
T extends HTMLElement,
V extends object | string
> extends IWidgetOptions {
hideOnZoom?: boolean
selectOn?: string[]
onHide?: (widget: DOMWidget<T, V>) => void
getValue?: () => V
setValue?: (value: V) => void
getMinHeight?: () => number
getMaxHeight?: () => number
getHeight?: () => string | number
onDraw?: (widget: DOMWidget<T, V>) => void
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
}
function intersect(a: Rect, b: Rect): Vector4 | null {
const x = Math.max(a.x, b.x)
const num1 = Math.min(a.x + a.width, b.x + b.width)
@@ -248,27 +280,15 @@ LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
return visibleNodes
}
let enableDomClipping = true
export function addDomClippingSetting(): void {
app.ui.settings.addSetting({
id: 'Comfy.DOMClippingEnabled',
category: ['Comfy', 'Node', 'DOMClippingEnabled'],
name: 'Enable DOM element clipping (enabling may reduce performance)',
type: 'boolean',
defaultValue: enableDomClipping,
onChange(value) {
enableDomClipping = !!value
}
})
}
LGraphNode.prototype.addDOMWidget = function (
LGraphNode.prototype.addDOMWidget = function <
T extends HTMLElement,
V extends object | string
>(
name: string,
type: string,
element: HTMLElement,
options: Record<string, any> = {}
): DOMWidget {
element: T,
options: DOMWidgetOptions<T, V> = {}
): DOMWidget<T, V> {
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
if (!element.parentElement) {
@@ -294,13 +314,15 @@ LGraphNode.prototype.addDOMWidget = function (
element.title = tooltip
}
const widget: DOMWidget = {
const widget: DOMWidget<T, V> = {
// @ts-expect-error All unrecognized types will be treated the same way as 'custom'
// in litegraph internally.
type,
name,
get value() {
get value(): V {
return options.getValue?.() ?? undefined
},
set value(v) {
set value(v: V) {
options.setValue?.(v)
widget.callback?.(widget.value)
},
@@ -320,8 +342,11 @@ LGraphNode.prototype.addDOMWidget = function (
const hidden =
(!!options.hideOnZoom && scale < 0.5) ||
widget.computedHeight <= 0 ||
// @ts-expect-error Used by widgetInputs.ts
widget.type === 'converted-widget' ||
// @ts-expect-error Used by groupNode.ts
widget.type === 'hidden'
element.dataset.shouldHide = hidden ? 'true' : 'false'
const isInVisibleNodes = element.dataset.isInVisibleNodes === 'true'
const isCollapsed = element.dataset.collapsed === 'true'
@@ -353,7 +378,7 @@ LGraphNode.prototype.addDOMWidget = function (
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
})
if (enableDomClipping) {
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
element.style.clipPath = getClipPath(node, element, elRect)
element.style.willChange = 'clip-path'
}

View File

@@ -5,6 +5,7 @@ import {
LinkReleaseTriggerMode
} from '@/types/searchBoxTypes'
import type { SettingParams } from '@/types/settingTypes'
import { LinkMarkerShape } from '@comfyorg/litegraph'
import { LiteGraph } from '@comfyorg/litegraph'
export const CORE_SETTINGS: SettingParams[] = [
@@ -484,5 +485,41 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: true,
versionAdded: '1.3.40'
},
{
id: 'Comfy.Node.MiddleClickRerouteNode',
name: 'Middle-click creates a new Reroute node',
type: 'boolean',
defaultValue: true,
versionAdded: '1.3.42'
},
{
id: 'Comfy.RerouteBeta',
name: 'Opt-in to the reroute beta test',
tooltip:
'Enables the new native reroutes.\n\nReroutes can be added by holding alt and dragging from a link line, or on the link menu.\n\nDisabling this option is non-destructive - reroutes are hidden.',
experimental: true,
type: 'boolean',
defaultValue: false,
versionAdded: '1.3.42'
},
{
id: 'Comfy.Graph.LinkMarkers',
name: 'Link midpoint markers',
defaultValue: LinkMarkerShape.Circle,
type: 'combo',
options: [
{ value: LinkMarkerShape.None, text: 'None' },
{ value: LinkMarkerShape.Circle, text: 'Circle' },
{ value: LinkMarkerShape.Arrow, text: 'Arrow' }
],
versionAdded: '1.3.42'
},
{
id: 'Comfy.DOMClippingEnabled',
category: ['Comfy', 'Node', 'DOMClippingEnabled'],
name: 'Enable DOM element clipping (enabling may reduce performance)',
type: 'boolean',
defaultValue: true
}
]

View File

@@ -0,0 +1,69 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { isElectron, electronAPI } from '@/utils/envUtil'
export interface ElectronDownload {
url: string
status: 'paused' | 'in_progress' | 'cancelled'
progress: number
savePath: string
filename: string
}
/** Electron donwloads store handler */
export const useElectronDownloadStore = defineStore('downloads', () => {
const downloads = ref<ElectronDownload[]>([])
const { DownloadManager } = electronAPI()
const findByUrl = (url: string) =>
downloads.value.find((download) => url === download.url)
const initialize = async () => {
if (isElectron()) {
const allDownloads: ElectronDownload[] =
(await DownloadManager.getAllDownloads()) as unknown as ElectronDownload[]
for (const download of allDownloads) {
downloads.value.push(download)
}
// ToDO: replace with ElectronDownload type
DownloadManager.onDownloadProgress((data: any) => {
if (!findByUrl(data.url)) {
downloads.value.push(data)
}
const download = findByUrl(data.url)
if (download) {
download.progress = data.progress
download.status = data.status
download.filename = data.filename
download.savePath = data.savePath
}
})
}
}
void initialize()
const start = ({
url,
savePath,
filename
}: Pick<ElectronDownload, 'url' | 'savePath' | 'filename'>) =>
DownloadManager.startDownload(url, savePath, filename)
const pause = (url: string) => DownloadManager.pauseDownload(url)
const resume = (url: string) => DownloadManager.resumeDownload(url)
const cancel = (url: string) => DownloadManager.cancelDownload(url)
return {
downloads,
start,
pause,
resume,
cancel,
findByUrl,
initialize
}
})

View File

@@ -284,32 +284,45 @@ export const useWorkflowStore = defineStore('workflow', () => {
workflows.value.filter((workflow) => workflow.isModified)
)
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
const isBusy = ref<boolean>(false)
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
// Capture all needed values upfront
const oldPath = workflow.path
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
const openIndex = detachWorkflow(workflow)
// Perform the actual rename operation first
isBusy.value = true
try {
await workflow.rename(newPath)
} finally {
attachWorkflow(workflow, openIndex)
}
// Capture all needed values upfront
const oldPath = workflow.path
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
// Update bookmarks
if (wasBookmarked) {
bookmarkStore.setBookmarked(oldPath, false)
bookmarkStore.setBookmarked(newPath, true)
const openIndex = detachWorkflow(workflow)
// Perform the actual rename operation first
try {
await workflow.rename(newPath)
} finally {
attachWorkflow(workflow, openIndex)
}
// Update bookmarks
if (wasBookmarked) {
bookmarkStore.setBookmarked(oldPath, false)
bookmarkStore.setBookmarked(newPath, true)
}
} finally {
isBusy.value = false
}
}
const deleteWorkflow = async (workflow: ComfyWorkflow) => {
await workflow.delete()
if (bookmarkStore.isBookmarked(workflow.path)) {
bookmarkStore.setBookmarked(workflow.path, false)
isBusy.value = true
try {
await workflow.delete()
if (bookmarkStore.isBookmarked(workflow.path)) {
bookmarkStore.setBookmarked(workflow.path, false)
}
delete workflowLookup.value[workflow.path]
} finally {
isBusy.value = false
}
delete workflowLookup.value[workflow.path]
}
/**
@@ -317,12 +330,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
* @param workflow The workflow to save.
*/
const saveWorkflow = async (workflow: ComfyWorkflow) => {
// Detach the workflow and re-attach to force refresh the tree objects.
const openIndex = detachWorkflow(workflow)
isBusy.value = true
try {
await workflow.save()
// Detach the workflow and re-attach to force refresh the tree objects.
const openIndex = detachWorkflow(workflow)
try {
await workflow.save()
} finally {
attachWorkflow(workflow, openIndex)
}
} finally {
attachWorkflow(workflow, openIndex)
isBusy.value = false
}
}
@@ -333,6 +351,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
openedWorkflowIndexShift,
openWorkflow,
isOpen,
isBusy,
closeWorkflow,
createTemporary,
renameWorkflow,

View File

@@ -1,35 +1,24 @@
// @ts-strict-ignore
import type {
ConnectingLink,
LGraphNode,
Vector2,
INodeInputSlot,
INodeOutputSlot,
INodeSlot
INodeSlot,
ISlotType
} from '@comfyorg/litegraph'
import type { ISlotType } from '@comfyorg/litegraph'
import { LiteGraph } from '@comfyorg/litegraph'
import { RerouteId } from '@comfyorg/litegraph/dist/Reroute'
export class ConnectingLinkImpl implements ConnectingLink {
node: LGraphNode
slot: number
input: INodeInputSlot | null
output: INodeOutputSlot | null
pos: Vector2
constructor(
node: LGraphNode,
slot: number,
input: INodeInputSlot | null,
output: INodeOutputSlot | null,
pos: Vector2
) {
this.node = node
this.slot = slot
this.input = input
this.output = output
this.pos = pos
}
public node: LGraphNode,
public slot: number,
public input: INodeInputSlot | undefined,
public output: INodeOutputSlot | undefined,
public pos: Vector2,
public afterRerouteId?: RerouteId
) {}
static createFromPlainObject(obj: ConnectingLink) {
return new ConnectingLinkImpl(
@@ -37,12 +26,13 @@ export class ConnectingLinkImpl implements ConnectingLink {
obj.slot,
obj.input,
obj.output,
obj.pos
obj.pos,
obj.afterRerouteId
)
}
get type(): ISlotType | null {
const result = this.input ? this.input.type : this.output.type
const result = this.input ? this.input.type : this.output?.type ?? null
return result === -1 ? null : result
}
@@ -71,9 +61,9 @@ export class ConnectingLinkImpl implements ConnectingLink {
}
if (this.releaseSlotType === 'input') {
this.node.connect(this.slot, newNode, newNodeSlot)
this.node.connect(this.slot, newNode, newNodeSlot, this.afterRerouteId)
} else {
newNode.connect(newNodeSlot, this.node, this.slot)
newNode.connect(newNodeSlot, this.node, this.slot, this.afterRerouteId)
}
}
}

View File

@@ -87,7 +87,8 @@ import InstallLocationPicker from '@/components/install/InstallLocationPicker.vu
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsConfiguration.vue'
import { electronAPI } from '@/utils/envUtil'
import { ref, computed } from 'vue'
import { ref, computed, toRaw } from 'vue'
import { useRouter } from 'vue-router'
const installPath = ref('')
const pathError = ref('')
@@ -100,14 +101,17 @@ const allowMetrics = ref(true)
const hasError = computed(() => pathError.value !== '')
const router = useRouter()
const install = () => {
;(electronAPI() as any).installComfyUI({
const options = toRaw({
installPath: installPath.value,
autoUpdate: autoUpdate.value,
allowMetrics: allowMetrics.value,
migrationSourcePath: migrationSourcePath.value,
migrationItemIds: migrationItemIds.value
migrationItemIds: toRaw(migrationItemIds.value)
})
electronAPI().installComfyUI(options)
router.push('/server-start')
}
</script>

View File

@@ -14,9 +14,9 @@ import {
ProgressStatus,
ProgressMessages
} from '@comfyorg/comfyui-electron-types'
import { electronAPI as getElectronAPI } from '@/utils/envUtil'
import { electronAPI } from '@/utils/envUtil'
const electronAPI = getElectronAPI()
const electron = electronAPI()
const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE)
const logs = ref<string[]>([])
@@ -35,9 +35,9 @@ const fetchLogs = async () => {
}
onMounted(() => {
electronAPI.sendReady()
electronAPI.onProgressUpdate(updateProgress)
electronAPI.onLogMessage((message: string) => {
electron.sendReady()
electron.onProgressUpdate(updateProgress)
electron.onLogMessage((message: string) => {
addLogMessage(message)
})
})

View File

@@ -94,6 +94,7 @@ const DEV_SERVER_COMFYUI_URL = process.env.DEV_SERVER_COMFYUI_URL || 'http://127
export default defineConfig({
base: '',
server: {
host: '0.0.0.0',
proxy: {
'/internal': {
target: DEV_SERVER_COMFYUI_URL,
@@ -175,6 +176,9 @@ export default defineConfig({
},
optimizeDeps: {
exclude: ['@comfyorg/litegraph']
exclude: [
'@comfyorg/litegraph',
'@comfyorg/comfyui-electron-types'
]
}
}) as UserConfigExport