Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f05b1855 | ||
|
|
6fefcaad7b | ||
|
|
22fdfd7f0b | ||
|
|
6842eb05de | ||
|
|
37e7994d55 | ||
|
|
399893bbb2 | ||
|
|
227db065f3 | ||
|
|
b4352bcd8d | ||
|
|
39bab9d9e2 | ||
|
|
c71644f02f | ||
|
|
6aad7ee8b6 | ||
|
|
2b96d831fc | ||
|
|
dde0291add | ||
|
|
8af016ffc1 | ||
|
|
82b4547d7d | ||
|
|
791a25637f | ||
|
|
b922aa5c7c | ||
|
|
cbaebbc9c2 | ||
|
|
86b2e1aa6c | ||
|
|
61c5f05126 | ||
|
|
dde9c3dad5 | ||
|
|
ee5c127146 | ||
|
|
acba6097e0 | ||
|
|
82d00a1bcf | ||
|
|
b9224464c0 | ||
|
|
fba9a03df3 | ||
|
|
2fd624cd3d | ||
|
|
095fe2a175 | ||
|
|
d838777e04 | ||
|
|
7e0d1d441d | ||
|
|
ddab149f16 | ||
|
|
a73fdcd3bd | ||
|
|
d6e0c197bd | ||
|
|
3117d0fdc1 | ||
|
|
96fda64b70 | ||
|
|
e3d2c3a814 | ||
|
|
1a8900de1f | ||
|
|
05ba526388 | ||
|
|
4bc79181ae | ||
|
|
feafbf9cbf |
@@ -6,6 +6,11 @@ PLAYWRIGHT_TEST_URL=http://localhost:5173
|
||||
# Note: localhost:8188 does not work.
|
||||
DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
|
||||
|
||||
# Allow dev server access from remote IP addresses.
|
||||
# If true, the vite dev server will listen on all addresses, including LAN
|
||||
# and public addresses.
|
||||
VITE_REMOTE_DEV=false
|
||||
|
||||
# The target ComfyUI checkout directory to deploy the frontend code to.
|
||||
# The dist directory will be copied to {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev
|
||||
# Add `--front-end-root {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
if [[ "$OS" == "Windows_NT" ]]; then
|
||||
npx.cmd lint-staged
|
||||
npm.cmd run typecheck
|
||||
else
|
||||
npx lint-staged
|
||||
npm run typecheck
|
||||
fi
|
||||
|
||||
24
README.md
@@ -425,9 +425,31 @@ 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
|
||||
|
||||
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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())!
|
||||
|
||||
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 151 KiB |
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -349,6 +349,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()
|
||||
}
|
||||
@@ -550,7 +556,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async doubleClickCanvas() {
|
||||
await this.page.mouse.dblclick(10, 10)
|
||||
await this.page.mouse.dblclick(10, 10, { delay: 5 })
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -586,43 +592,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() {
|
||||
|
||||
@@ -43,7 +43,7 @@ export class ComfyNodeSearchBox {
|
||||
}
|
||||
|
||||
get filterButton() {
|
||||
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
|
||||
return this.page.locator('.comfy-vue-node-search-container .filter-button')
|
||||
}
|
||||
|
||||
async fillAndSelectFirstNode(
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -77,8 +77,13 @@ test.describe('Group Node', () => {
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
test('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
// The 500ms fixed delay on the search results is causing flakiness
|
||||
// Potential solution: add a spinner state when the search is in progress,
|
||||
// and observe that state from the test. Blocker: the PrimeVue AutoComplete
|
||||
// does not have a v-model on the query, so we cannot observe the raw
|
||||
// query update, and thus cannot set the spinning state between the raw query
|
||||
// update and the debounced search update.
|
||||
test.skip('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.doubleClickCanvas()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
34
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "comfyui-frontend",
|
||||
"version": "1.3.41",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "comfyui-frontend",
|
||||
"version": "1.3.41",
|
||||
"version": "1.4.1",
|
||||
"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.27",
|
||||
"@primevue/themes": "^4.0.5",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -63,7 +63,6 @@
|
||||
"tailwindcss": "^3.4.4",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-files": "^1.1.4",
|
||||
"tsx": "^4.15.6",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
@@ -1916,15 +1915,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.27",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.27.tgz",
|
||||
"integrity": "sha512-EMQ3jsny+3gUQL4+vSVwJAFxrLq4IpuyjCvAiErLY4wLZZu2Mi+7cELmhrNS0MajhZqfN1M0GPmdcBRwSWbarw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -12170,19 +12169,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tsc-files": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tsc-files/-/tsc-files-1.1.4.tgz",
|
||||
"integrity": "sha512-RePsRsOLru3BPpnf237y1Xe1oCGta8rmSYzM76kYo5tLGsv5R2r3s64yapYorGTPuuLyfS9NVbh9ydzmvNie2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tsc-files": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",
|
||||
|
||||
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.3.41",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -58,7 +58,6 @@
|
||||
"tailwindcss": "^3.4.4",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-files": "^1.1.4",
|
||||
"tsx": "^4.15.6",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
@@ -72,8 +71,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.27",
|
||||
"@primevue/themes": "^4.0.5",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -94,7 +93,8 @@
|
||||
"zod-validation-error": "^3.3.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"./**/*.{js,ts,tsx,vue}": "prettier --write",
|
||||
"**/*.ts": "tsc-files --noEmit"
|
||||
"./**/*.{js,ts,tsx,vue}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -125,30 +125,45 @@ const adjustMenuPosition = () => {
|
||||
const menuWidth = panelRef.value.offsetWidth
|
||||
const menuHeight = panelRef.value.offsetHeight
|
||||
|
||||
// Calculate the distance from each edge
|
||||
// Calculate distances to all edges
|
||||
const distanceLeft = lastDragState.value.x
|
||||
const distanceRight =
|
||||
lastDragState.value.windowWidth - (lastDragState.value.x + menuWidth)
|
||||
const distanceTop = lastDragState.value.y
|
||||
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
|
||||
// Find the smallest distance to determine which edge to anchor to
|
||||
const distances = [
|
||||
{ edge: 'left', distance: distanceLeft },
|
||||
{ edge: 'right', distance: distanceRight },
|
||||
{ edge: 'top', distance: distanceTop },
|
||||
{ edge: 'bottom', distance: distanceBottom }
|
||||
]
|
||||
const closestEdge = distances.reduce((min, curr) =>
|
||||
curr.distance < min.distance ? curr : min
|
||||
)
|
||||
|
||||
// Calculate new position
|
||||
if (anchorRight) {
|
||||
x.value =
|
||||
screenWidth - (lastDragState.value.windowWidth - lastDragState.value.x)
|
||||
} else {
|
||||
x.value = lastDragState.value.x
|
||||
}
|
||||
// Calculate vertical position as a percentage of screen height
|
||||
const verticalRatio =
|
||||
lastDragState.value.y / lastDragState.value.windowHeight
|
||||
const horizontalRatio =
|
||||
lastDragState.value.x / lastDragState.value.windowWidth
|
||||
|
||||
if (anchorBottom) {
|
||||
y.value =
|
||||
screenHeight -
|
||||
(lastDragState.value.windowHeight - lastDragState.value.y)
|
||||
// Apply positioning based on closest edge
|
||||
if (closestEdge.edge === 'left') {
|
||||
x.value = closestEdge.distance // Maintain exact distance from left
|
||||
y.value = verticalRatio * screenHeight
|
||||
} else if (closestEdge.edge === 'right') {
|
||||
x.value = screenWidth - menuWidth - closestEdge.distance // Maintain exact distance from right
|
||||
y.value = verticalRatio * screenHeight
|
||||
} else if (closestEdge.edge === 'top') {
|
||||
x.value = horizontalRatio * screenWidth
|
||||
y.value = closestEdge.distance // Maintain exact distance from top
|
||||
} else {
|
||||
y.value = lastDragState.value.y
|
||||
// bottom
|
||||
x.value = horizontalRatio * screenWidth
|
||||
y.value = screenHeight - menuHeight - closestEdge.distance // Maintain exact distance from bottom
|
||||
}
|
||||
|
||||
// Ensure the menu stays within the screen bounds
|
||||
|
||||
@@ -28,7 +28,14 @@
|
||||
class="flex flex-row items-center gap-2"
|
||||
v-if="status === 'in_progress' || status === 'paused'"
|
||||
>
|
||||
<ProgressBar class="flex-1" :value="downloadProgress" />
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="downloadProgress"
|
||||
:show-value="downloadProgress > 10"
|
||||
/>
|
||||
|
||||
<Button
|
||||
class="file-action-button"
|
||||
@@ -59,6 +66,7 @@
|
||||
:disabled="props.error"
|
||||
@click="triggerCancelDownload"
|
||||
icon="pi pi-times-circle"
|
||||
severity="danger"
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
/>
|
||||
</div>
|
||||
@@ -73,6 +81,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 +90,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>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="updateValue"
|
||||
class="input-part"
|
||||
max-fraction-digits="3"
|
||||
:class="inputClass"
|
||||
:min="min"
|
||||
:max="max"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Tree
|
||||
class="tree-explorer p-2 2xl:p-4"
|
||||
class="tree-explorer py-0 px-2 2xl:px-4"
|
||||
:class="props.class"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
v-model:selectionKeys="selectionKeys"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -234,11 +262,13 @@ onMounted(async () => {
|
||||
ChangeTracker.init(comfyApp)
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
|
||||
window['app'] = comfyApp
|
||||
window['graph'] = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
emit('ready')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -88,10 +87,10 @@ onMounted(async () => {
|
||||
installPath.value = paths.defaultInstallPath
|
||||
})
|
||||
|
||||
const validatePath = async () => {
|
||||
const validatePath = async (path: string) => {
|
||||
try {
|
||||
pathError.value = ''
|
||||
const validation = await electron.validateInstallPath(installPath.value)
|
||||
const validation = await electron.validateInstallPath(path)
|
||||
|
||||
if (!validation.isValid) {
|
||||
pathError.value = validation.error
|
||||
@@ -106,7 +105,7 @@ const browsePath = async () => {
|
||||
const result = await electron.showDirectoryPicker()
|
||||
if (result) {
|
||||
installPath.value = result
|
||||
await validatePath()
|
||||
await validatePath(result)
|
||||
}
|
||||
} catch (error) {
|
||||
pathError.value = t('install.failedToSelectDirectory')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="comfy-vue-node-search-container">
|
||||
<div class="comfy-vue-node-preview-container" v-if="enableNodePreview">
|
||||
<div
|
||||
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96 pointer-events-auto"
|
||||
>
|
||||
<div
|
||||
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
|
||||
v-if="enableNodePreview"
|
||||
>
|
||||
<NodePreview
|
||||
:nodeDef="hoveredSuggestion"
|
||||
:key="hoveredSuggestion?.name || ''"
|
||||
@@ -11,10 +16,10 @@
|
||||
<Button
|
||||
icon="pi pi-filter"
|
||||
severity="secondary"
|
||||
class="_filter-button"
|
||||
class="filter-button z-10"
|
||||
@click="nodeSearchFilterVisible = true"
|
||||
/>
|
||||
<Dialog v-model:visible="nodeSearchFilterVisible" class="_dialog">
|
||||
<Dialog v-model:visible="nodeSearchFilterVisible" class="min-w-96">
|
||||
<template #header>
|
||||
<h3>Add node filter condition</h3>
|
||||
</template>
|
||||
@@ -25,7 +30,7 @@
|
||||
|
||||
<AutoCompletePlus
|
||||
:model-value="props.filters"
|
||||
class="comfy-vue-node-search-box"
|
||||
class="comfy-vue-node-search-box z-10 flex-grow"
|
||||
scrollHeight="40vh"
|
||||
:placeholder="placeholder"
|
||||
:input-id="inputId"
|
||||
@@ -148,31 +153,3 @@ const setHoverSuggestion = (index: number) => {
|
||||
hoveredSuggestion.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-vue-node-search-container {
|
||||
@apply flex justify-center items-center w-full min-w-96;
|
||||
}
|
||||
|
||||
.comfy-vue-node-search-container * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.comfy-vue-node-preview-container {
|
||||
position: absolute;
|
||||
left: -350px;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.comfy-vue-node-search-box {
|
||||
@apply z-10 flex-grow;
|
||||
}
|
||||
|
||||
._filter-button {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
._dialog {
|
||||
@apply min-w-96;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,8 +28,10 @@
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<ElectronDownloadItems v-if="isElectron()" />
|
||||
|
||||
<TreeExplorer
|
||||
class="model-lib-tree-explorer py-0"
|
||||
class="model-lib-tree-explorer"
|
||||
:roots="renderedRoot.children"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
>
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
class="m-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
class="node-lib-tree-explorer py-0"
|
||||
class="node-lib-tree-explorer"
|
||||
:roots="renderedRoot.children"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
>
|
||||
|
||||
101
src/components/sidebar/tabs/modelLibrary/DownloadItem.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<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)"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="Number((download.progress * 100).toFixed(1))"
|
||||
:show-value="download.progress > 0.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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
138
src/extensions/core/electronAdapter.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { app } from '@/scripts/app'
|
||||
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
|
||||
;(async () => {
|
||||
if (!isElectron()) return
|
||||
|
||||
const electronAPI = getElectronAPI()
|
||||
const desktopAppVersion = await electronAPI.getElectronVersion()
|
||||
app.registerExtension({
|
||||
name: 'Comfy.ElectronAdapter',
|
||||
settings: [
|
||||
{
|
||||
id: 'Comfy-Desktop.AutoUpdate',
|
||||
category: ['Comfy-Desktop', 'General', 'AutoUpdate'],
|
||||
name: 'Automatically check for updates',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
onChange(newValue, oldValue) {
|
||||
if (oldValue !== undefined && newValue !== oldValue) {
|
||||
electronAPI.restartApp(
|
||||
'Restart ComfyUI to apply changes.',
|
||||
1500 // add delay to allow changes to take effect before restarting.
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: 'Comfy-Desktop.Folders.OpenLogsFolder',
|
||||
label: 'Open Logs Folder',
|
||||
icon: 'pi pi-folder-open',
|
||||
function() {
|
||||
electronAPI.openLogsFolder()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy-Desktop.Folders.OpenModelsFolder',
|
||||
label: 'Open Models Folder',
|
||||
icon: 'pi pi-folder-open',
|
||||
function() {
|
||||
electronAPI.openModelsFolder()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy-Desktop.Folders.OpenOutputsFolder',
|
||||
label: 'Open Outputs Folder',
|
||||
icon: 'pi pi-folder-open',
|
||||
function() {
|
||||
electronAPI.openOutputsFolder()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy-Desktop.Folders.OpenInputsFolder',
|
||||
label: 'Open Inputs Folder',
|
||||
icon: 'pi pi-folder-open',
|
||||
function() {
|
||||
electronAPI.openInputsFolder()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy-Desktop.Folders.OpenCustomNodesFolder',
|
||||
label: 'Open Custom Nodes Folder',
|
||||
icon: 'pi pi-folder-open',
|
||||
function() {
|
||||
electronAPI.openCustomNodesFolder()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy-Desktop.Folders.OpenModelConfig',
|
||||
label: 'Open extra_model_paths.yaml',
|
||||
icon: 'pi pi-file',
|
||||
function() {
|
||||
electronAPI.openModelConfig()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy-Desktop.OpenDevTools',
|
||||
label: 'Open DevTools',
|
||||
icon: 'pi pi-code',
|
||||
function() {
|
||||
electronAPI.openDevTools()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy-Desktop.OpenFeedbackPage',
|
||||
label: 'Feedback',
|
||||
icon: 'pi pi-envelope',
|
||||
function() {
|
||||
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy-Desktop.Reinstall',
|
||||
label: 'Reinstall',
|
||||
icon: 'pi pi-refresh',
|
||||
function() {
|
||||
// TODO(huchenlei): Add a confirmation dialog.
|
||||
electronAPI.reinstall()
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
menuCommands: [
|
||||
{
|
||||
path: ['Help'],
|
||||
commands: ['Comfy-Desktop.OpenFeedbackPage']
|
||||
},
|
||||
{
|
||||
path: ['Help'],
|
||||
commands: ['Comfy-Desktop.OpenDevTools']
|
||||
},
|
||||
{
|
||||
path: ['Help', 'Open Folder'],
|
||||
commands: [
|
||||
'Comfy-Desktop.Folders.OpenLogsFolder',
|
||||
'Comfy-Desktop.Folders.OpenModelsFolder',
|
||||
'Comfy-Desktop.Folders.OpenOutputsFolder',
|
||||
'Comfy-Desktop.Folders.OpenInputsFolder',
|
||||
'Comfy-Desktop.Folders.OpenCustomNodesFolder',
|
||||
'Comfy-Desktop.Folders.OpenModelConfig'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['Help'],
|
||||
commands: ['Comfy-Desktop.Reinstall']
|
||||
}
|
||||
],
|
||||
|
||||
aboutPageBadges: [
|
||||
{
|
||||
label: 'ComfyUI_Desktop ' + desktopAppVersion,
|
||||
url: 'https://github.com/Comfy-Org/electron',
|
||||
icon: 'pi pi-github'
|
||||
}
|
||||
]
|
||||
})
|
||||
})()
|
||||
@@ -3,12 +3,17 @@ 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'
|
||||
import type { ComfyNodeDef } from '@/types/apiTypes'
|
||||
|
||||
type GroupNodeWorkflowData = {
|
||||
external: ComfyLink[]
|
||||
@@ -52,7 +57,7 @@ const Workflow = {
|
||||
|
||||
class GroupNodeBuilder {
|
||||
nodes: LGraphNode[]
|
||||
nodeData: any
|
||||
nodeData: GroupNodeWorkflowData
|
||||
|
||||
constructor(nodes: LGraphNode[]) {
|
||||
this.nodes = nodes
|
||||
@@ -144,21 +149,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,7 +176,7 @@ export class GroupNodeConfig {
|
||||
primitiveToWidget: {}
|
||||
nodeInputs: {}
|
||||
outputVisibility: any[]
|
||||
nodeDef: any
|
||||
nodeDef: ComfyNodeDef
|
||||
inputs: any[]
|
||||
linksFrom: {}
|
||||
linksTo: {}
|
||||
@@ -206,6 +205,7 @@ export class GroupNodeConfig {
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
// @ts-expect-error Unused, doesn't exist
|
||||
output_is_hidden: [],
|
||||
name: source + SEPARATOR + this.name,
|
||||
display_name: this.name,
|
||||
@@ -697,11 +697,11 @@ export class GroupNodeConfig {
|
||||
}
|
||||
|
||||
export class GroupNodeHandler {
|
||||
node
|
||||
node: LGraphNode
|
||||
groupData
|
||||
innerNodes: any
|
||||
|
||||
constructor(node) {
|
||||
constructor(node: LGraphNode) {
|
||||
this.node = node
|
||||
this.groupData = node.constructor?.nodeData?.[GROUP]
|
||||
|
||||
@@ -776,6 +776,7 @@ export class GroupNodeHandler {
|
||||
|
||||
this.node.updateLink = (link) => {
|
||||
// Replace the group node reference with the internal node
|
||||
// @ts-expect-error Can this be removed? Or replaced with: LLink.create(link.asSerialisable())
|
||||
link = { ...link }
|
||||
const output = this.groupData.newToOldOutputMap[link.origin_slot]
|
||||
let innerNode = this.innerNodes[output.node.index]
|
||||
@@ -842,7 +843,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 +858,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 +921,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,15 +966,22 @@ export class GroupNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
const { newNodes, selectedIds } = addInnerNodes()
|
||||
reconnectInputs(selectedIds)
|
||||
reconnectOutputs(selectedIds)
|
||||
app.graph.remove(this.node)
|
||||
app.canvas.emitBeforeChange()
|
||||
|
||||
return newNodes
|
||||
try {
|
||||
const { newNodes, selectedIds } = addInnerNodes()
|
||||
reconnectInputs(selectedIds)
|
||||
reconnectOutputs(selectedIds)
|
||||
app.graph.remove(this.node)
|
||||
|
||||
return newNodes
|
||||
} finally {
|
||||
app.canvas.emitAfterChange()
|
||||
}
|
||||
}
|
||||
|
||||
const getExtraMenuOptions = this.node.getExtraMenuOptions
|
||||
// @ts-expect-error Should pass patched return value getExtraMenuOptions
|
||||
this.node.getExtraMenuOptions = function (_, options) {
|
||||
getExtraMenuOptions?.apply(this, arguments)
|
||||
|
||||
@@ -991,6 +994,7 @@ export class GroupNodeHandler {
|
||||
null,
|
||||
{
|
||||
content: 'Convert to nodes',
|
||||
// @ts-expect-error
|
||||
callback: () => {
|
||||
return this.convertToNodes()
|
||||
}
|
||||
@@ -1151,6 +1155,7 @@ export class GroupNodeHandler {
|
||||
|
||||
if (
|
||||
old.inputName !== 'image' &&
|
||||
// @ts-expect-error Widget values
|
||||
!widget.options.values.includes(widget.value)
|
||||
) {
|
||||
widget.value = widget.options.values[0]
|
||||
@@ -1357,6 +1362,7 @@ export class GroupNodeHandler {
|
||||
if (!originNode) continue // this node is in the group
|
||||
originNode.connect(
|
||||
originSlot,
|
||||
// @ts-expect-error Valid - uses deprecated interface. Required check: if (graph.getNodeById(this.node.id) !== this.node) report()
|
||||
this.node.id,
|
||||
this.groupData.oldToNewInputMap[targetId][targetSlot]
|
||||
)
|
||||
@@ -1478,7 +1484,7 @@ function ungroupSelectedGroupNodes() {
|
||||
const nodes = Object.values(app.canvas.selected_nodes ?? {})
|
||||
for (const node of nodes) {
|
||||
if (GroupNodeHandler.isGroupNode(node)) {
|
||||
node['convertToNodes']?.()
|
||||
node.convertToNodes?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ import './uploadImage'
|
||||
import './webcamCapture'
|
||||
import './widgetInputs'
|
||||
import './uploadAudio'
|
||||
import './electronAdapter'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
122
src/extensions/core/vintageClipboard.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// @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()
|
||||
try {
|
||||
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()
|
||||
} finally {
|
||||
canvas.emitAfterChange()
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,28 @@ import { markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return {
|
||||
id: 'model-library',
|
||||
icon: 'pi pi-box',
|
||||
title: t('sideToolbar.modelLibrary'),
|
||||
tooltip: t('sideToolbar.modelLibrary'),
|
||||
component: markRaw(ModelLibrarySidebarTab),
|
||||
type: 'vue'
|
||||
type: 'vue',
|
||||
iconBadge: () => {
|
||||
if (isElectron()) {
|
||||
const electronDownloadStore = useElectronDownloadStore()
|
||||
if (electronDownloadStore.downloads.length > 0) {
|
||||
return electronDownloadStore.downloads.length.toString()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1227,7 +1227,8 @@ export class ComfyApp {
|
||||
const origProcessMouseDown = LGraphCanvas.prototype.processMouseDown
|
||||
LGraphCanvas.prototype.processMouseDown = function (e) {
|
||||
// prepare for ctrl+shift drag: zoom start
|
||||
if (e.ctrlKey && e.shiftKey && e.buttons) {
|
||||
const useFastZoom = useSettingStore().get('Comfy.Graph.CtrlShiftZoom')
|
||||
if (useFastZoom && e.ctrlKey && e.shiftKey && !e.altKey && e.buttons) {
|
||||
self.zoom_drag_start = [e.x, e.y, this.ds.scale]
|
||||
return
|
||||
}
|
||||
@@ -1572,10 +1573,7 @@ export class ComfyApp {
|
||||
api.addEventListener('execution_start', ({ detail }) => {
|
||||
this.lastExecutionError = null
|
||||
this.graph.nodes.forEach((node) => {
|
||||
// @ts-expect-error
|
||||
if (node.onExecutionStart)
|
||||
// @ts-expect-error
|
||||
node.onExecutionStart()
|
||||
if (node.onExecutionStart) node.onExecutionStart()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1806,7 +1804,6 @@ export class ComfyApp {
|
||||
])
|
||||
await this.#loadExtensions()
|
||||
|
||||
addDomClippingSetting()
|
||||
this.#addProcessMouseHandler()
|
||||
this.#addProcessKeyHandler()
|
||||
this.#addConfigureHandler()
|
||||
@@ -2125,8 +2122,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
|
||||
|
||||
@@ -2402,8 +2405,8 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
const innerNodes = outerNode['getInnerNodes']
|
||||
? outerNode['getInnerNodes']()
|
||||
const innerNodes = outerNode.getInnerNodes
|
||||
? outerNode.getInnerNodes()
|
||||
: [outerNode]
|
||||
for (const node of innerNodes) {
|
||||
if (node.isVirtualNode) {
|
||||
@@ -2421,8 +2424,8 @@ export class ComfyApp {
|
||||
for (const outerNode of graph.computeExecutionOrder(false)) {
|
||||
const skipNode = outerNode.mode === 2 || outerNode.mode === 4
|
||||
const innerNodes =
|
||||
!skipNode && outerNode['getInnerNodes']
|
||||
? outerNode['getInnerNodes']()
|
||||
!skipNode && outerNode.getInnerNodes
|
||||
? outerNode.getInnerNodes()
|
||||
: [outerNode]
|
||||
for (const node of innerNodes) {
|
||||
if (node.isVirtualNode) {
|
||||
@@ -2888,7 +2891,6 @@ export class ComfyApp {
|
||||
for (let nodeNum in this.graph.nodes) {
|
||||
const node = this.graph.nodes[nodeNum]
|
||||
const def = defs[node.type]
|
||||
// @ts-expect-error
|
||||
// Allow primitive nodes to handle refresh
|
||||
node.refreshComboInNode?.(defs)
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,48 @@ 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
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.CtrlShiftZoom',
|
||||
name: 'Enable fast-zoom shortcut (Ctrl + Shift + Drag)',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
versionAdded: '1.4.0'
|
||||
}
|
||||
]
|
||||
|
||||
69
src/stores/electronDownloadStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
10
src/types/litegraph-augmentation.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
import '@comfyorg/litegraph'
|
||||
import type { ComfyNodeDef } from '@/types/apiTypes'
|
||||
import type { LLink } from '@comfyorg/litegraph'
|
||||
import type { NodeId } from './comfyWorkflow'
|
||||
|
||||
/**
|
||||
* ComfyUI extensions of litegraph
|
||||
@@ -26,8 +27,17 @@ declare module '@comfyorg/litegraph' {
|
||||
onExecuted?(output: any): void
|
||||
onNodeCreated?(this: LGraphNode): void
|
||||
setInnerNodes?(nodes: LGraphNode[]): void
|
||||
// TODO: Requires several coercion changes to runtime code.
|
||||
getInnerNodes?() // : LGraphNode[]
|
||||
convertToNodes?(): LGraphNode[]
|
||||
recreate?(): Promise<LGraphNode>
|
||||
refreshComboInNode?(defs: Record<string, ComfyNodeDef>)
|
||||
applyToGraph?(extraLinks?: LLink[]): void
|
||||
updateLink?(link: LLink): LLink | null
|
||||
onExecutionStart?(): unknown
|
||||
|
||||
index?: number
|
||||
runningInternalNodeId?: NodeId
|
||||
|
||||
comfyClass?: string
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ dotenv.config()
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
const SHOULD_MINIFY = process.env.ENABLE_MINIFY === 'true'
|
||||
// vite dev server will listen on all addresses, including LAN and public addresses
|
||||
const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true'
|
||||
|
||||
interface ShimResult {
|
||||
code: string
|
||||
@@ -94,6 +96,7 @@ const DEV_SERVER_COMFYUI_URL = process.env.DEV_SERVER_COMFYUI_URL || 'http://127
|
||||
export default defineConfig({
|
||||
base: '',
|
||||
server: {
|
||||
host: VITE_REMOTE_DEV ? '0.0.0.0' : undefined,
|
||||
proxy: {
|
||||
'/internal': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
@@ -175,6 +178,9 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
optimizeDeps: {
|
||||
exclude: ['@comfyorg/litegraph']
|
||||
exclude: [
|
||||
'@comfyorg/litegraph',
|
||||
'@comfyorg/comfyui-electron-types'
|
||||
]
|
||||
}
|
||||
}) as UserConfigExport
|
||||
|
||||