mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
Move workflow dropdown to sidebar tab (#893)
* Initial move to sidebar Remove broken CSS Move action buttons Migrate open workflows Add basic browse WIP Add insert support Remove legacy workflow manager Remove unused CSS Reorder Remove legacy workflow UI nit * Support bookmark Add workflow bookmark store nit Add back bookmark functionality Correctly load bookmarks nit Fix many other issues Fix this binding style divider * Extract tree leaf component * Hide bookmark section when no bookmarks * nit * Fix save * Add workflows searchbox * Add search support * Show total opened * Add basic test * Add more tests * Fix redo/undo test * Temporarily disable browser tab title test
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { Page, Locator } from '@playwright/test'
|
import type { Page, Locator, APIRequestContext } from '@playwright/test'
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
import { test as base } from '@playwright/test'
|
import { test as base } from '@playwright/test'
|
||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
import { ComfyAppMenu } from './helpers/appMenu'
|
import { ComfyAppMenu } from './helpers/appMenu'
|
||||||
@@ -97,9 +98,11 @@ class ComfyNodeSearchBox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class NodeLibrarySidebarTab {
|
class SidebarTab {
|
||||||
public readonly tabId: string = 'node-library'
|
constructor(
|
||||||
constructor(public readonly page: Page) {}
|
public readonly page: Page,
|
||||||
|
public readonly tabId: string
|
||||||
|
) {}
|
||||||
|
|
||||||
get tabButton() {
|
get tabButton() {
|
||||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||||
@@ -111,6 +114,19 @@ class NodeLibrarySidebarTab {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
if (await this.selectedTabButton.isVisible()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.tabButton.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NodeLibrarySidebarTab extends SidebarTab {
|
||||||
|
constructor(public readonly page: Page) {
|
||||||
|
super(page, 'node-library')
|
||||||
|
}
|
||||||
|
|
||||||
get nodeLibrarySearchBoxInput() {
|
get nodeLibrarySearchBoxInput() {
|
||||||
return this.page.locator('.node-lib-search-box input[type="text"]')
|
return this.page.locator('.node-lib-search-box input[type="text"]')
|
||||||
}
|
}
|
||||||
@@ -132,11 +148,7 @@ class NodeLibrarySidebarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async open() {
|
async open() {
|
||||||
if (await this.selectedTabButton.isVisible()) {
|
await super.open()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.tabButton.click()
|
|
||||||
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +178,45 @@ class NodeLibrarySidebarTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WorkflowsSidebarTab extends SidebarTab {
|
||||||
|
constructor(public readonly page: Page) {
|
||||||
|
super(page, 'workflows')
|
||||||
|
}
|
||||||
|
|
||||||
|
get newBlankWorkflowButton() {
|
||||||
|
return this.page.locator('.new-blank-workflow-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
get browseWorkflowsButton() {
|
||||||
|
return this.page.locator('.browse-workflows-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
get newDefaultWorkflowButton() {
|
||||||
|
return this.page.locator('.new-default-workflow-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpenedWorkflowNames() {
|
||||||
|
return await this.page
|
||||||
|
.locator('.comfyui-workflows-open .node-label')
|
||||||
|
.allInnerTexts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopLevelSavedWorkflowNames() {
|
||||||
|
return await this.page
|
||||||
|
.locator('.comfyui-workflows-browse .node-label')
|
||||||
|
.allInnerTexts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchToWorkflow(workflowName: string) {
|
||||||
|
const workflowLocator = this.page.locator(
|
||||||
|
'.comfyui-workflows-open .node-label',
|
||||||
|
{ hasText: workflowName }
|
||||||
|
)
|
||||||
|
await workflowLocator.click()
|
||||||
|
await this.page.waitForTimeout(300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ComfyMenu {
|
class ComfyMenu {
|
||||||
public readonly sideToolbar: Locator
|
public readonly sideToolbar: Locator
|
||||||
public readonly themeToggleButton: Locator
|
public readonly themeToggleButton: Locator
|
||||||
@@ -198,6 +249,10 @@ class ComfyMenu {
|
|||||||
return new NodeLibrarySidebarTab(this.page)
|
return new NodeLibrarySidebarTab(this.page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get workflowsTab() {
|
||||||
|
return new WorkflowsSidebarTab(this.page)
|
||||||
|
}
|
||||||
|
|
||||||
async toggleTheme() {
|
async toggleTheme() {
|
||||||
await this.themeToggleButton.click()
|
await this.themeToggleButton.click()
|
||||||
await this.page.evaluate(() => {
|
await this.page.evaluate(() => {
|
||||||
@@ -222,6 +277,10 @@ class ComfyMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FolderStructure = {
|
||||||
|
[key: string]: FolderStructure | string
|
||||||
|
}
|
||||||
|
|
||||||
export class ComfyPage {
|
export class ComfyPage {
|
||||||
public readonly url: string
|
public readonly url: string
|
||||||
// All canvas position operations are based on default view of canvas.
|
// All canvas position operations are based on default view of canvas.
|
||||||
@@ -240,7 +299,10 @@ export class ComfyPage {
|
|||||||
public readonly menu: ComfyMenu
|
public readonly menu: ComfyMenu
|
||||||
public readonly appMenu: ComfyAppMenu
|
public readonly appMenu: ComfyAppMenu
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
constructor(
|
||||||
|
public readonly page: Page,
|
||||||
|
public readonly request: APIRequestContext
|
||||||
|
) {
|
||||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||||
this.canvas = page.locator('#graph-canvas')
|
this.canvas = page.locator('#graph-canvas')
|
||||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||||
@@ -252,13 +314,46 @@ export class ComfyPage {
|
|||||||
this.appMenu = new ComfyAppMenu(page)
|
this.appMenu = new ComfyAppMenu(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
convertLeafToContent(structure: FolderStructure): FolderStructure {
|
||||||
|
const result: FolderStructure = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(structure)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const filePath = this.assetPath(value)
|
||||||
|
result[key] = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
} else {
|
||||||
|
result[key] = this.convertLeafToContent(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
async getGraphNodesCount(): Promise<number> {
|
async getGraphNodesCount(): Promise<number> {
|
||||||
return await this.page.evaluate(() => {
|
return await this.page.evaluate(() => {
|
||||||
return window['app']?.graph?.nodes?.length || 0
|
return window['app']?.graph?.nodes?.length || 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async setup() {
|
async setupWorkflowsDirectory(structure: FolderStructure) {
|
||||||
|
const resp = await this.request.post(
|
||||||
|
`${this.url}/api/devtools/setup_folder_structure`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
tree_structure: this.convertLeafToContent(structure),
|
||||||
|
base_path: 'user/default/workflows'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (resp.status() !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to setup workflows directory: ${await resp.text()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setup({ resetView = true } = {}) {
|
||||||
await this.goto()
|
await this.goto()
|
||||||
await this.page.evaluate(() => {
|
await this.page.evaluate(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
@@ -285,9 +380,11 @@ export class ComfyPage {
|
|||||||
window['app']['canvas'].show_info = false
|
window['app']['canvas'].show_info = false
|
||||||
})
|
})
|
||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
// Reset view to force re-rendering of canvas. So that info fields like fps
|
if (resetView) {
|
||||||
// become hidden.
|
// Reset view to force re-rendering of canvas. So that info fields like fps
|
||||||
await this.resetView()
|
// become hidden.
|
||||||
|
await this.resetView()
|
||||||
|
}
|
||||||
|
|
||||||
// Hide all badges by default.
|
// Hide all badges by default.
|
||||||
await this.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.None)
|
await this.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.None)
|
||||||
@@ -630,6 +727,11 @@ export class ComfyPage {
|
|||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async closeDialog() {
|
||||||
|
await this.page.locator('.p-dialog-close-button').click()
|
||||||
|
await expect(this.page.locator('.p-dialog')).toBeHidden()
|
||||||
|
}
|
||||||
|
|
||||||
async resizeNode(
|
async resizeNode(
|
||||||
nodePos: Position,
|
nodePos: Position,
|
||||||
nodeSize: Size,
|
nodeSize: Size,
|
||||||
@@ -903,8 +1005,8 @@ class NodeReference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
|
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
|
||||||
comfyPage: async ({ page }, use) => {
|
comfyPage: async ({ page, request }, use) => {
|
||||||
const comfyPage = new ComfyPage(page)
|
const comfyPage = new ComfyPage(page, request)
|
||||||
await comfyPage.setup()
|
await comfyPage.setup()
|
||||||
await use(comfyPage)
|
await use(comfyPage)
|
||||||
}
|
}
|
||||||
|
|||||||
135
browser_tests/assets/default.json
Normal file
135
browser_tests/assets/default.json
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 9,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [413, 389],
|
||||||
|
"size": [425.27801513671875, 180.6060791015625],
|
||||||
|
"flags": {},
|
||||||
|
"order": 3,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [6],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["text, watermark"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [415, 186],
|
||||||
|
"size": [422.84503173828125, 164.31304931640625],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [4],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [
|
||||||
|
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "EmptyLatentImage",
|
||||||
|
"pos": [473, 609],
|
||||||
|
"size": [315, 106],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [2], "slot_index": 0 }],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [512, 512, 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": [863, 186],
|
||||||
|
"size": [315, 262],
|
||||||
|
"flags": {},
|
||||||
|
"order": 4,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "model", "type": "MODEL", "link": 1 },
|
||||||
|
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||||
|
{ "name": "negative", "type": "CONDITIONING", "link": 6 },
|
||||||
|
{ "name": "latent_image", "type": "LATENT", "link": 2 }
|
||||||
|
],
|
||||||
|
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [156680208700286, true, 20, 8, "euler", "normal", 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "VAEDecode",
|
||||||
|
"pos": [1209, 188],
|
||||||
|
"size": [210, 46],
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||||
|
{ "name": "vae", "type": "VAE", "link": 8 }
|
||||||
|
],
|
||||||
|
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }],
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "SaveImage",
|
||||||
|
"pos": [1451, 189],
|
||||||
|
"size": [210, 26],
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": [26, 474],
|
||||||
|
"size": [315, 98],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "MODEL", "type": "MODEL", "links": [1], "slot_index": 0 },
|
||||||
|
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||||
|
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["v1-5-pruned-emaonly.ckpt"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[1, 4, 0, 3, 0, "MODEL"],
|
||||||
|
[2, 5, 0, 3, 3, "LATENT"],
|
||||||
|
[3, 4, 1, 6, 0, "CLIP"],
|
||||||
|
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||||
|
[5, 4, 1, 7, 0, "CLIP"],
|
||||||
|
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||||
|
[7, 3, 0, 8, 0, "LATENT"],
|
||||||
|
[8, 4, 2, 8, 1, "VAE"],
|
||||||
|
[9, 8, 0, 9, 0, "IMAGE"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ test.describe('Browser tab title', () => {
|
|||||||
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
|
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Can display workflow name with unsaved changes', async ({
|
// Broken by https://github.com/Comfy-Org/ComfyUI_frontend/pull/893
|
||||||
|
// Release blocker for v1.3.0
|
||||||
|
test.skip('Can display workflow name with unsaved changes', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
const workflowName = await comfyPage.page.evaluate(async () => {
|
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||||
|
|||||||
@@ -13,38 +13,13 @@ test.describe('Load workflow warning', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Does not report warning when switching between opened workflows', async ({
|
|
||||||
comfyPage
|
|
||||||
}) => {
|
|
||||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
|
||||||
await comfyPage.loadWorkflow('missing_nodes')
|
|
||||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
|
||||||
|
|
||||||
// Load default workflow
|
|
||||||
const workflowSelector = comfyPage.page.locator(
|
|
||||||
'button.comfyui-workflows-button'
|
|
||||||
)
|
|
||||||
await workflowSelector.hover()
|
|
||||||
await workflowSelector.click()
|
|
||||||
await comfyPage.page.locator('button[title="Load default workflow"]').click()
|
|
||||||
|
|
||||||
// Switch back to the missing_nodes workflow
|
|
||||||
await workflowSelector.click()
|
|
||||||
await comfyPage.page.locator('span:has-text("missing_nodes")').first().click()
|
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
|
||||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||||
|
|
||||||
await comfyPage.loadWorkflow('missing_nodes')
|
await comfyPage.loadWorkflow('missing_nodes')
|
||||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
await comfyPage.closeDialog()
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
// Make a change to the graph
|
// Make a change to the graph
|
||||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
|
||||||
await comfyPage.page.waitForTimeout(256)
|
|
||||||
await comfyPage.doubleClickCanvas()
|
await comfyPage.doubleClickCanvas()
|
||||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||||
|
|
||||||
|
|||||||
@@ -365,6 +365,61 @@ test.describe('Menu', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.describe('Workflows sidebar', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
// Open the sidebar
|
||||||
|
const tab = comfyPage.menu.workflowsTab
|
||||||
|
await tab.open()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can create new blank workflow', async ({ comfyPage }) => {
|
||||||
|
const tab = comfyPage.menu.workflowsTab
|
||||||
|
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||||
|
'*Unsaved Workflow.json'
|
||||||
|
])
|
||||||
|
|
||||||
|
await tab.newBlankWorkflowButton.click()
|
||||||
|
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||||
|
'*Unsaved Workflow.json',
|
||||||
|
'*Unsaved Workflow (2).json'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can show top level saved workflows', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setupWorkflowsDirectory({
|
||||||
|
'workflow1.json': 'default.json',
|
||||||
|
'workflow2.json': 'default.json'
|
||||||
|
})
|
||||||
|
// Avoid reset view as the button is not visible in BetaMenu UI.
|
||||||
|
await comfyPage.setup({ resetView: false })
|
||||||
|
|
||||||
|
const tab = comfyPage.menu.workflowsTab
|
||||||
|
await tab.open()
|
||||||
|
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual([
|
||||||
|
'workflow1.json',
|
||||||
|
'workflow2.json'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Does not report warning when switching between opened workflows', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.loadWorkflow('missing_nodes')
|
||||||
|
await comfyPage.closeDialog()
|
||||||
|
|
||||||
|
// Load default workflow
|
||||||
|
await comfyPage.menu.workflowsTab.open()
|
||||||
|
await comfyPage.menu.workflowsTab.newDefaultWorkflowButton.click()
|
||||||
|
|
||||||
|
// Switch back to the missing_nodes workflow
|
||||||
|
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
comfyPage.page.locator('.comfy-missing-nodes')
|
||||||
|
).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
|
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
|
||||||
const [defaultSpeed, maxSpeed] = [1.1, 2.5]
|
const [defaultSpeed, maxSpeed] = [1.1, 2.5]
|
||||||
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
|
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
|
||||||
|
|||||||
21
src/App.vue
21
src/App.vue
@@ -33,7 +33,7 @@ import type { ToastMessageOptions } from 'primevue/toast'
|
|||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import { i18n } from '@/i18n'
|
import { i18n } from '@/i18n'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
import { useWorkflowBookmarkStore, useWorkflowStore } from '@/stores/workflowStore'
|
||||||
import BlockUI from 'primevue/blockui'
|
import BlockUI from 'primevue/blockui'
|
||||||
import ProgressSpinner from 'primevue/progressspinner'
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
|
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
|
||||||
@@ -43,6 +43,8 @@ import GlobalToast from '@/components/toast/GlobalToast.vue'
|
|||||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||||
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
|
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
|
||||||
import AppMenu from '@/components/appMenu/AppMenu.vue'
|
import AppMenu from '@/components/appMenu/AppMenu.vue'
|
||||||
|
import WorkflowsSidebarTab from './components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||||
|
import { setupAutoQueueHandler } from './services/autoQueueService'
|
||||||
|
|
||||||
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
|
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ const settingStore = useSettingStore()
|
|||||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
|
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||||
|
|
||||||
const theme = computed<string>(() => settingStore.get('Comfy.ColorPalette'))
|
const theme = computed<string>(() => settingStore.get('Comfy.ColorPalette'))
|
||||||
|
|
||||||
@@ -117,6 +120,18 @@ const init = () => {
|
|||||||
component: markRaw(NodeLibrarySidebarTab),
|
component: markRaw(NodeLibrarySidebarTab),
|
||||||
type: 'vue'
|
type: 'vue'
|
||||||
})
|
})
|
||||||
|
app.extensionManager.registerSidebarTab({
|
||||||
|
id: 'workflows',
|
||||||
|
icon: 'pi pi-folder-open',
|
||||||
|
iconBadge: () => {
|
||||||
|
const value = useWorkflowStore().openWorkflows.length.toString()
|
||||||
|
return value === '0' ? null : value
|
||||||
|
},
|
||||||
|
title: t('sideToolbar.workflows'),
|
||||||
|
tooltip: t('sideToolbar.workflows'),
|
||||||
|
component: markRaw(WorkflowsSidebarTab),
|
||||||
|
type: 'vue'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onStatus = (e: CustomEvent<StatusWsMessageStatus>) => {
|
const onStatus = (e: CustomEvent<StatusWsMessageStatus>) => {
|
||||||
@@ -143,10 +158,8 @@ const onReconnected = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.workflowManager.executionStore = executionStore
|
app.workflowManager.executionStore = executionStore
|
||||||
watchEffect(() => {
|
|
||||||
app.menu.workflows.buttonProgress.style.width = `${executionStore.executionProgress}%`
|
|
||||||
})
|
|
||||||
app.workflowManager.workflowStore = workflowStore
|
app.workflowManager.workflowStore = workflowStore
|
||||||
|
app.workflowManager.workflowBookmarkStore = workflowBookmarkStore
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
|
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const betaMenuEnabled = computed(
|
|||||||
|
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const isUnsavedText = computed(() =>
|
const isUnsavedText = computed(() =>
|
||||||
workflowStore.previousWorkflowUnsaved ? ' *' : ''
|
workflowStore.activeWorkflow?.unsaved ? ' *' : ''
|
||||||
)
|
)
|
||||||
const workflowNameText = computed(() => {
|
const workflowNameText = computed(() => {
|
||||||
const workflowName = workflowStore.activeWorkflow?.name
|
const workflowName = workflowStore.activeWorkflow?.name
|
||||||
|
|||||||
27
src/components/common/TextDivider.vue
Normal file
27
src/components/common/TextDivider.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center" :class="props.class">
|
||||||
|
<span v-if="position === 'left'" class="mr-2 shrink-0">{{ text }}</span>
|
||||||
|
<Divider :align="align" :type="type" :layout="layout" class="flex-grow" />
|
||||||
|
<span v-if="position === 'right'" class="ml-2 shrink-0">{{ text }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Divider from 'primevue/divider'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
class?: string
|
||||||
|
position?: 'left' | 'right'
|
||||||
|
align?: 'left' | 'center' | 'right' | 'top' | 'bottom'
|
||||||
|
type?: 'solid' | 'dashed' | 'dotted'
|
||||||
|
layout?: 'horizontal' | 'vertical'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
position: 'left',
|
||||||
|
align: 'center',
|
||||||
|
type: 'solid',
|
||||||
|
layout: 'horizontal'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
225
src/components/sidebar/tabs/WorkflowsSidebarTab.vue
Normal file
225
src/components/sidebar/tabs/WorkflowsSidebarTab.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<SidebarTabTemplate :title="$t('sideToolbar.workflows')">
|
||||||
|
<template #tool-buttons>
|
||||||
|
<Button
|
||||||
|
class="browse-workflows-button"
|
||||||
|
icon="pi pi-folder-open"
|
||||||
|
v-tooltip="'Browse for an image or exported workflow'"
|
||||||
|
text
|
||||||
|
@click="browse"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
class="new-default-workflow-button"
|
||||||
|
icon="pi pi-code"
|
||||||
|
v-tooltip="'Load default workflow'"
|
||||||
|
text
|
||||||
|
@click="loadDefault"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
class="new-blank-workflow-button"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
v-tooltip="'Create a new blank workflow'"
|
||||||
|
@click="createBlank"
|
||||||
|
text
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<SearchBox
|
||||||
|
class="workflows-search-box mx-4 my-4"
|
||||||
|
v-model:modelValue="searchQuery"
|
||||||
|
@search="handleSearch"
|
||||||
|
:placeholder="$t('searchWorkflows') + '...'"
|
||||||
|
/>
|
||||||
|
<div class="comfyui-workflows-panel" v-if="!isSearching">
|
||||||
|
<div class="comfyui-workflows-open">
|
||||||
|
<TextDivider text="Open" type="dashed" class="ml-2" />
|
||||||
|
<TreeExplorer
|
||||||
|
:roots="renderTreeNode(workflowStore.openWorkflowsTree).children"
|
||||||
|
v-model:selectionKeys="selectionKeys"
|
||||||
|
>
|
||||||
|
<template #node="{ node }">
|
||||||
|
<TreeExplorerTreeNode :node="node">
|
||||||
|
<template #before-label="{ node }">
|
||||||
|
<span v-if="node.data.unsaved">*</span>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ node }">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
@click.stop="app.workflowManager.closeWorkflow(node.data)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</TreeExplorerTreeNode>
|
||||||
|
</template>
|
||||||
|
</TreeExplorer>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="comfyui-workflows-bookmarks"
|
||||||
|
v-show="workflowStore.bookmarkedWorkflows.length > 0"
|
||||||
|
>
|
||||||
|
<TextDivider text="Bookmarks" type="dashed" class="ml-2" />
|
||||||
|
<TreeExplorer
|
||||||
|
:roots="
|
||||||
|
renderTreeNode(workflowStore.bookmarkedWorkflowsTree).children
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #node="{ node }">
|
||||||
|
<WorkflowTreeLeaf :node="node" />
|
||||||
|
</template>
|
||||||
|
</TreeExplorer>
|
||||||
|
</div>
|
||||||
|
<div class="comfyui-workflows-browse">
|
||||||
|
<TextDivider text="Browse" type="dashed" class="ml-2" />
|
||||||
|
<TreeExplorer
|
||||||
|
:roots="renderTreeNode(workflowStore.workflowsTree).children"
|
||||||
|
v-model:expandedKeys="expandedKeys"
|
||||||
|
>
|
||||||
|
<template #node="{ node }">
|
||||||
|
<WorkflowTreeLeaf :node="node" />
|
||||||
|
</template>
|
||||||
|
</TreeExplorer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comfyui-workflows-search-panel" v-else>
|
||||||
|
<TreeExplorer
|
||||||
|
:roots="renderTreeNode(filteredRoot).children"
|
||||||
|
v-model:expandedKeys="expandedKeys"
|
||||||
|
>
|
||||||
|
<template #node="{ node }">
|
||||||
|
<WorkflowTreeLeaf :node="node" />
|
||||||
|
</template>
|
||||||
|
</TreeExplorer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SidebarTabTemplate>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SearchBox from '@/components/common/SearchBox.vue'
|
||||||
|
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
|
||||||
|
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||||
|
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||||
|
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import TextDivider from '@/components/common/TextDivider.vue'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||||
|
import type { TreeNode } from 'primevue/treenode'
|
||||||
|
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||||
|
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useTreeExpansion } from '@/hooks/treeHooks'
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const isSearching = computed(() => searchQuery.value.length > 0)
|
||||||
|
const filteredWorkflows = ref<ComfyWorkflow[]>([])
|
||||||
|
const filteredRoot = computed<TreeNode>(() => {
|
||||||
|
return workflowStore.buildWorkflowTree(
|
||||||
|
filteredWorkflows.value as ComfyWorkflow[]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
if (query.length === 0) {
|
||||||
|
filteredWorkflows.value = []
|
||||||
|
expandedKeys.value = {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filteredWorkflows.value = workflowStore.workflows.filter((workflow) => {
|
||||||
|
return workflow.name.includes(query)
|
||||||
|
})
|
||||||
|
nextTick(() => {
|
||||||
|
expandNode(filteredRoot.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDefault = () => {
|
||||||
|
app.loadGraphData()
|
||||||
|
app.resetView()
|
||||||
|
}
|
||||||
|
|
||||||
|
const browse = () => {
|
||||||
|
app.ui.loadFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBlank = () => {
|
||||||
|
app.workflowManager.setWorkflow(null)
|
||||||
|
app.clean()
|
||||||
|
app.graph.clear()
|
||||||
|
app.workflowManager.activeWorkflow.track()
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const expandedKeys = ref<Record<string, boolean>>({})
|
||||||
|
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||||
|
|
||||||
|
const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
|
||||||
|
const children = node.children?.map(renderTreeNode)
|
||||||
|
|
||||||
|
const workflow: ComfyWorkflow = node.data
|
||||||
|
|
||||||
|
const handleClick = (
|
||||||
|
node: TreeExplorerNode<ComfyWorkflow>,
|
||||||
|
e: MouseEvent
|
||||||
|
) => {
|
||||||
|
if (node.leaf) {
|
||||||
|
const workflow = node.data
|
||||||
|
workflow.load()
|
||||||
|
} else {
|
||||||
|
toggleNodeOnEvent(e, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const actions = node.leaf
|
||||||
|
? {
|
||||||
|
handleClick,
|
||||||
|
handleRename: (
|
||||||
|
node: TreeExplorerNode<ComfyWorkflow>,
|
||||||
|
newName: string
|
||||||
|
) => {
|
||||||
|
const workflow = node.data
|
||||||
|
workflow.rename(newName)
|
||||||
|
},
|
||||||
|
handleDelete: workflow.isTemporary
|
||||||
|
? undefined
|
||||||
|
: (node: TreeExplorerNode<ComfyWorkflow>) => {
|
||||||
|
const workflow = node.data
|
||||||
|
workflow.delete()
|
||||||
|
},
|
||||||
|
contextMenuItems: (node: TreeExplorerNode<ComfyWorkflow>) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('insert'),
|
||||||
|
icon: 'pi pi-file-export',
|
||||||
|
command: () => {
|
||||||
|
const workflow = node.data
|
||||||
|
workflow.insert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: { handleClick }
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: node.key,
|
||||||
|
label: node.label,
|
||||||
|
leaf: node.leaf,
|
||||||
|
data: node.data,
|
||||||
|
children,
|
||||||
|
...actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionKeys = computed(() => ({
|
||||||
|
[`root/${workflowStore.activeWorkflow?.name}.json`]: true
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.comfy-vue-side-bar-body) {
|
||||||
|
background: var(--p-tree-background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
src/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue
Normal file
31
src/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<TreeExplorerTreeNode :node="node">
|
||||||
|
<template #actions="{ node }">
|
||||||
|
<Button
|
||||||
|
:icon="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
@click.stop="workflowBookmarkStore.toggleBookmarked(node.data.path)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</TreeExplorerTreeNode>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { useWorkflowBookmarkStore } from '@/stores/workflowStore'
|
||||||
|
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||||
|
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
node: RenderedTreeExplorerNode<ComfyWorkflow>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||||
|
const isBookmarked = computed(() =>
|
||||||
|
workflowBookmarkStore.isBookmarked(props.node.data.path)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -2,6 +2,7 @@ import { createI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
en: {
|
en: {
|
||||||
|
insert: 'Insert',
|
||||||
systemInfo: 'System Info',
|
systemInfo: 'System Info',
|
||||||
devices: 'Devices',
|
devices: 'Devices',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
@@ -35,6 +36,7 @@ const messages = {
|
|||||||
loadWorkflow: 'Load Workflow',
|
loadWorkflow: 'Load Workflow',
|
||||||
goToNode: 'Go to Node',
|
goToNode: 'Go to Node',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
|
searchWorkflows: 'Search Workflows',
|
||||||
searchSettings: 'Search Settings',
|
searchSettings: 'Search Settings',
|
||||||
searchNodes: 'Search Nodes',
|
searchNodes: 'Search Nodes',
|
||||||
noResultsFound: 'No Results Found',
|
noResultsFound: 'No Results Found',
|
||||||
@@ -47,6 +49,7 @@ const messages = {
|
|||||||
themeToggle: 'Toggle Theme',
|
themeToggle: 'Toggle Theme',
|
||||||
queue: 'Queue',
|
queue: 'Queue',
|
||||||
nodeLibrary: 'Node Library',
|
nodeLibrary: 'Node Library',
|
||||||
|
workflows: 'Workflows',
|
||||||
nodeLibraryTab: {
|
nodeLibraryTab: {
|
||||||
sortOrder: 'Sort Order'
|
sortOrder: 'Sort Order'
|
||||||
},
|
},
|
||||||
@@ -101,6 +104,7 @@ const messages = {
|
|||||||
customize: '定制',
|
customize: '定制',
|
||||||
loadWorkflow: '加载工作流',
|
loadWorkflow: '加载工作流',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
|
searchWorkflows: '搜索工作流',
|
||||||
searchSettings: '搜索设置',
|
searchSettings: '搜索设置',
|
||||||
searchNodes: '搜索节点',
|
searchNodes: '搜索节点',
|
||||||
noResultsFound: '未找到结果',
|
noResultsFound: '未找到结果',
|
||||||
@@ -113,6 +117,7 @@ const messages = {
|
|||||||
themeToggle: '主题切换',
|
themeToggle: '主题切换',
|
||||||
queue: '队列',
|
queue: '队列',
|
||||||
nodeLibrary: '节点库',
|
nodeLibrary: '节点库',
|
||||||
|
workflows: '工作流',
|
||||||
nodeLibraryTab: {
|
nodeLibraryTab: {
|
||||||
sortOrder: '排序顺序'
|
sortOrder: '排序顺序'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1738,12 +1738,6 @@ export class ComfyApp {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
|
||||||
this.menu.workflows.registerExtension(this)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async #migrateSettings() {
|
async #migrateSettings() {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ComfyButton } from '../components/button'
|
|||||||
import { ComfyButtonGroup } from '../components/buttonGroup'
|
import { ComfyButtonGroup } from '../components/buttonGroup'
|
||||||
import { ComfySplitButton } from '../components/splitButton'
|
import { ComfySplitButton } from '../components/splitButton'
|
||||||
import { ComfyQueueButton } from './queueButton'
|
import { ComfyQueueButton } from './queueButton'
|
||||||
import { ComfyWorkflowsMenu } from './workflows'
|
|
||||||
import { getInterruptButton } from './interruptButton'
|
import { getInterruptButton } from './interruptButton'
|
||||||
import './menu.css'
|
import './menu.css'
|
||||||
import type { ComfySettingsDialog } from '../settings'
|
import type { ComfySettingsDialog } from '../settings'
|
||||||
@@ -34,7 +33,6 @@ export class ComfyAppMenu {
|
|||||||
#cachedInnerSize = null
|
#cachedInnerSize = null
|
||||||
#cacheTimeout = null
|
#cacheTimeout = null
|
||||||
app: ComfyApp
|
app: ComfyApp
|
||||||
workflows: ComfyWorkflowsMenu
|
|
||||||
logo: HTMLElement
|
logo: HTMLElement
|
||||||
saveButton: ComfySplitButton
|
saveButton: ComfySplitButton
|
||||||
actionsGroup: ComfyButtonGroup
|
actionsGroup: ComfyButtonGroup
|
||||||
@@ -48,8 +46,6 @@ export class ComfyAppMenu {
|
|||||||
|
|
||||||
constructor(app: ComfyApp) {
|
constructor(app: ComfyApp) {
|
||||||
this.app = app
|
this.app = app
|
||||||
|
|
||||||
this.workflows = new ComfyWorkflowsMenu(app)
|
|
||||||
const getSaveButton = (t?: string) =>
|
const getSaveButton = (t?: string) =>
|
||||||
new ComfyButton({
|
new ComfyButton({
|
||||||
icon: 'content-save',
|
icon: 'content-save',
|
||||||
@@ -145,7 +141,6 @@ export class ComfyAppMenu {
|
|||||||
|
|
||||||
this.element = $el('nav.comfyui-menu.lg', { style: { display: 'none' } }, [
|
this.element = $el('nav.comfyui-menu.lg', { style: { display: 'none' } }, [
|
||||||
this.logo,
|
this.logo,
|
||||||
this.workflows.element,
|
|
||||||
this.saveButton.element,
|
this.saveButton.element,
|
||||||
collapseOnMobile(this.actionsGroup).element,
|
collapseOnMobile(this.actionsGroup).element,
|
||||||
$el('section.comfyui-menu-push'),
|
$el('section.comfyui-menu-push'),
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--comfy-floating-menu-height: 45px;
|
--comfy-floating-menu-height: 45px;
|
||||||
}
|
}
|
||||||
.relative {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.mdi.rotate270::before {
|
.mdi.rotate270::before {
|
||||||
transform: rotate(270deg);
|
transform: rotate(270deg);
|
||||||
}
|
}
|
||||||
@@ -148,6 +143,7 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comfyui-menu.floating {
|
.comfyui-menu.floating {
|
||||||
width: max-content;
|
width: max-content;
|
||||||
padding: 8px 0 8px 12px;
|
padding: 8px 0 8px 12px;
|
||||||
@@ -215,378 +211,6 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Workflows */
|
|
||||||
.comfyui-workflows-button {
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
max-width: 200px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-button.popup-open {
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
.comfyui-workflows-button.unsaved {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.comfyui-workflows-button-progress {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
background-color: green;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-button > span {
|
|
||||||
flex: auto;
|
|
||||||
text-align: left;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.comfyui-workflows-button-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
.comfyui-workflows-label {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
direction: rtl;
|
|
||||||
flex: auto;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-button.unsaved .comfyui-workflows-label {
|
|
||||||
padding-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-button.unsaved .comfyui-workflows-label:after {
|
|
||||||
content: "*";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
.comfyui-workflows-button-inner .mdi-graph::before {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-popup {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 0.8em;
|
|
||||||
padding: 10px;
|
|
||||||
overflow: auto;
|
|
||||||
background-color: var(--content-bg);
|
|
||||||
color: var(--content-fg);
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
z-index: 1001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-panel {
|
|
||||||
min-height: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-panel .lds-ring {
|
|
||||||
transform: translate(-50%);
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 75px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-panel h3 {
|
|
||||||
margin: 10px 0 10px 0;
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-panel section header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.comfy-ui-workflows-search .mdi {
|
|
||||||
position: relative;
|
|
||||||
top: 2px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.comfy-ui-workflows-search input {
|
|
||||||
background-color: var(--comfy-input-bg);
|
|
||||||
color: var(--input-text);
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
margin-left: -24px;
|
|
||||||
text-indent: 18px;
|
|
||||||
}
|
|
||||||
.comfy-ui-workflows-search input:placeholder-shown {
|
|
||||||
width: 10px;
|
|
||||||
}
|
|
||||||
.comfy-ui-workflows-search input:placeholder-shown:focus {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.comfyui-workflows-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-actions .comfyui-button {
|
|
||||||
background: var(--comfy-input-bg);
|
|
||||||
color: var(--input-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-actions .comfyui-button:not(:disabled):hover {
|
|
||||||
background: var(--primary-bg);
|
|
||||||
color: var(--primary-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-favorites,
|
|
||||||
.comfyui-workflows-open {
|
|
||||||
border-bottom: 1px solid var(--comfy-input-bg);
|
|
||||||
padding-bottom: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-open .active {
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--primary-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-favorites:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree:empty::after {
|
|
||||||
content: "No saved workflows";
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.comfyui-workflows-tree > ul {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree > ul ul {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0 0 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree:not(.filtered) .closed > ul {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree li,
|
|
||||||
.comfyui-workflows-tree-file {
|
|
||||||
--item-height: 32px;
|
|
||||||
list-style-type: none;
|
|
||||||
height: var(--item-height);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file.active::before,
|
|
||||||
.comfyui-workflows-tree li:hover::before,
|
|
||||||
.comfyui-workflows-tree-file:hover::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
left: 0;
|
|
||||||
height: var(--item-height);
|
|
||||||
background-color: var(--content-hover-bg);
|
|
||||||
color: var(--content-hover-fg);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file.active::before {
|
|
||||||
background-color: var(--primary-bg);
|
|
||||||
color: var(--primary-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file.running:not(:hover)::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
width: var(--progress, 0);
|
|
||||||
left: 0;
|
|
||||||
height: var(--item-height);
|
|
||||||
background-color: green;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file.unsaved span {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file span {
|
|
||||||
flex: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file span + .comfyui-workflows-file-action {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file .comfyui-workflows-file-action {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--fg-color);
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file.active .comfyui-workflows-file-action {
|
|
||||||
color: var(--primary-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lg ~ .comfyui-workflows-popup .comfyui-workflows-tree-file:not(:hover) .comfyui-workflows-file-action {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file .comfyui-workflows-file-action:hover {
|
|
||||||
background-color: var(--primary-bg);
|
|
||||||
color: var(--primary-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-tree-file .comfyui-workflows-file-action-primary {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--fg-color);
|
|
||||||
padding: 2px 4px;
|
|
||||||
margin: 0 -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-workflows-file-action-favorite .mdi-star {
|
|
||||||
color: orange;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* View List */
|
|
||||||
.comfyui-view-list-popup {
|
|
||||||
padding: 10px;
|
|
||||||
background-color: var(--content-bg);
|
|
||||||
color: var(--content-fg);
|
|
||||||
min-width: 170px;
|
|
||||||
min-height: 435px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-popup h3 {
|
|
||||||
margin: 0 0 5px 0;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-items {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--comfy-menu-bg);
|
|
||||||
border-radius: 5px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
flex: auto;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-items section {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow: auto;
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto auto;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-items section + section {
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-items section h5 {
|
|
||||||
grid-column: 1 / 4;
|
|
||||||
text-align: center;
|
|
||||||
margin: 5px;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-items span {
|
|
||||||
text-align: center;
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-popup header {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-popup header .comfyui-button {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
.comfyui-view-list-popup header .comfyui-button:not(:disabled):hover {
|
|
||||||
border: 1px solid var(--comfy-menu-bg);
|
|
||||||
}
|
|
||||||
/* Queue button */
|
|
||||||
.comfyui-queue-button .comfyui-split-primary .comfyui-button {
|
|
||||||
padding-right: 12px;
|
|
||||||
}
|
|
||||||
.comfyui-queue-count {
|
|
||||||
margin-left: 5px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background-color: rgb(8, 80, 153);
|
|
||||||
padding: 2px 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
min-width: 1em;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
/* Queue options*/
|
|
||||||
.comfyui-queue-options {
|
|
||||||
padding: 10px;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-queue-batch {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-right: 1px solid var(--comfy-menu-bg);
|
|
||||||
padding-right: 10px;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-queue-batch input {
|
|
||||||
width: 145px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-queue-batch .comfyui-queue-batch-value {
|
|
||||||
width: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-queue-mode {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-queue-mode span {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-queue-mode label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
justify-content: start;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comfyui-queue-mode label input {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Send to workflow widget selection dialog */
|
/** Send to workflow widget selection dialog */
|
||||||
.comfy-widget-selection-dialog {
|
.comfy-widget-selection-dialog {
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -1,834 +0,0 @@
|
|||||||
import { ComfyButton } from '../components/button'
|
|
||||||
import { prop, getStorageValue, setStorageValue } from '../../utils'
|
|
||||||
import { $el } from '../../ui'
|
|
||||||
import { api } from '../../api'
|
|
||||||
import { ComfyPopup } from '../components/popup'
|
|
||||||
import { createSpinner } from '../spinner'
|
|
||||||
import { ComfyWorkflow } from '../../workflows'
|
|
||||||
import { ComfyAsyncDialog } from '../components/asyncDialog'
|
|
||||||
import { trimJsonExt } from '@/utils/formatUtil'
|
|
||||||
import type { ComfyApp } from '@/scripts/app'
|
|
||||||
import type { ComfyComponent } from '../components'
|
|
||||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
|
||||||
|
|
||||||
export class ComfyWorkflowsMenu {
|
|
||||||
#first = true
|
|
||||||
element = $el('div.comfyui-workflows')
|
|
||||||
popup: ComfyPopup
|
|
||||||
app: ComfyApp
|
|
||||||
buttonProgress: HTMLElement
|
|
||||||
workflowLabel: HTMLElement
|
|
||||||
button: ComfyButton
|
|
||||||
content: ComfyWorkflowsContent
|
|
||||||
unsaved: boolean
|
|
||||||
|
|
||||||
get open() {
|
|
||||||
return this.popup.open
|
|
||||||
}
|
|
||||||
|
|
||||||
set open(open) {
|
|
||||||
this.popup.open = open
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(app: ComfyApp) {
|
|
||||||
this.app = app
|
|
||||||
this.#bindEvents()
|
|
||||||
|
|
||||||
const classList = {
|
|
||||||
'comfyui-workflows-button': true,
|
|
||||||
'comfyui-button': true,
|
|
||||||
unsaved: getStorageValue('Comfy.PreviousWorkflowUnsaved') === 'true',
|
|
||||||
running: false
|
|
||||||
}
|
|
||||||
this.buttonProgress = $el('div.comfyui-workflows-button-progress')
|
|
||||||
this.workflowLabel = $el('span.comfyui-workflows-label', '')
|
|
||||||
this.button = new ComfyButton({
|
|
||||||
content: $el('div.comfyui-workflows-button-inner', [
|
|
||||||
$el('i.mdi.mdi-graph'),
|
|
||||||
this.workflowLabel,
|
|
||||||
this.buttonProgress
|
|
||||||
]),
|
|
||||||
icon: 'chevron-down',
|
|
||||||
classList,
|
|
||||||
tooltip: 'Click to open workflows menu'
|
|
||||||
})
|
|
||||||
|
|
||||||
this.element.append(this.button.element)
|
|
||||||
|
|
||||||
this.popup = new ComfyPopup({
|
|
||||||
target: this.element,
|
|
||||||
classList: 'comfyui-workflows-popup'
|
|
||||||
})
|
|
||||||
this.content = new ComfyWorkflowsContent(app, this.popup)
|
|
||||||
this.popup.children = [this.content.element]
|
|
||||||
this.popup.addEventListener('change', () => {
|
|
||||||
this.button.icon = 'chevron-' + (this.popup.open ? 'up' : 'down')
|
|
||||||
})
|
|
||||||
this.button.withPopup(this.popup)
|
|
||||||
|
|
||||||
this.unsaved = prop(this, 'unsaved', classList.unsaved, (v) => {
|
|
||||||
classList.unsaved = v
|
|
||||||
this.button.classList = classList
|
|
||||||
setStorageValue('Comfy.PreviousWorkflowUnsaved', String(v))
|
|
||||||
|
|
||||||
if (this.app.vueAppReady) {
|
|
||||||
useWorkflowStore().previousWorkflowUnsaved = v
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateActive = () => {
|
|
||||||
const active = this.app.workflowManager.activeWorkflow
|
|
||||||
this.button.tooltip = active.path
|
|
||||||
this.workflowLabel.textContent = active.name
|
|
||||||
this.workflowLabel.ariaLabel = `Active workflow: ${active.name}`
|
|
||||||
this.unsaved = active.unsaved
|
|
||||||
|
|
||||||
if (this.#first) {
|
|
||||||
this.#first = false
|
|
||||||
this.content.load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#bindEvents() {
|
|
||||||
this.app.workflowManager.addEventListener(
|
|
||||||
'changeWorkflow',
|
|
||||||
this.#updateActive
|
|
||||||
)
|
|
||||||
this.app.workflowManager.addEventListener('rename', this.#updateActive)
|
|
||||||
this.app.workflowManager.addEventListener('delete', this.#updateActive)
|
|
||||||
|
|
||||||
this.app.workflowManager.addEventListener('save', () => {
|
|
||||||
this.unsaved = this.app.workflowManager.activeWorkflow.unsaved
|
|
||||||
})
|
|
||||||
|
|
||||||
api.addEventListener('graphChanged', () => {
|
|
||||||
this.unsaved = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#getMenuOptions(callback) {
|
|
||||||
const menu = []
|
|
||||||
const directories = new Map()
|
|
||||||
for (const workflow of this.app.workflowManager.workflows || []) {
|
|
||||||
const path = workflow.pathParts
|
|
||||||
if (!path) continue
|
|
||||||
let parent = menu
|
|
||||||
let currentPath = ''
|
|
||||||
for (let i = 0; i < path.length - 1; i++) {
|
|
||||||
currentPath += '/' + path[i]
|
|
||||||
let newParent = directories.get(currentPath)
|
|
||||||
if (!newParent) {
|
|
||||||
newParent = {
|
|
||||||
title: path[i],
|
|
||||||
has_submenu: true,
|
|
||||||
submenu: {
|
|
||||||
options: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parent.push(newParent)
|
|
||||||
newParent = newParent.submenu.options
|
|
||||||
directories.set(currentPath, newParent)
|
|
||||||
}
|
|
||||||
parent = newParent
|
|
||||||
}
|
|
||||||
parent.push({
|
|
||||||
title: trimJsonExt(path[path.length - 1]),
|
|
||||||
callback: () => callback(workflow)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
#getFavoriteMenuOptions(callback) {
|
|
||||||
const menu = []
|
|
||||||
for (const workflow of this.app.workflowManager.workflows || []) {
|
|
||||||
if (workflow.isFavorite) {
|
|
||||||
menu.push({
|
|
||||||
title: '⭐ ' + workflow.name,
|
|
||||||
callback: () => callback(workflow)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
registerExtension(app: ComfyApp) {
|
|
||||||
const self = this
|
|
||||||
app.registerExtension({
|
|
||||||
name: 'Comfy.Workflows',
|
|
||||||
async beforeRegisterNodeDef(nodeType) {
|
|
||||||
function getImageWidget(node) {
|
|
||||||
const inputs = {
|
|
||||||
...node.constructor?.nodeData?.input?.required,
|
|
||||||
...node.constructor?.nodeData?.input?.optional
|
|
||||||
}
|
|
||||||
for (const input in inputs) {
|
|
||||||
if (inputs[input][0] === 'IMAGEUPLOAD') {
|
|
||||||
const imageWidget = node.widgets.find(
|
|
||||||
(w) => w.name === (inputs[input]?.[1]?.widget ?? 'image')
|
|
||||||
)
|
|
||||||
if (imageWidget) return imageWidget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setWidgetImage(node, widget, img) {
|
|
||||||
const url = new URL(img.src)
|
|
||||||
const filename = url.searchParams.get('filename')
|
|
||||||
const subfolder = url.searchParams.get('subfolder')
|
|
||||||
const type = url.searchParams.get('type')
|
|
||||||
const imageId = `${subfolder ? subfolder + '/' : ''}${filename} [${type}]`
|
|
||||||
widget.value = imageId
|
|
||||||
node.imgs = [img]
|
|
||||||
app.graph.setDirtyCanvas(true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendToWorkflow(
|
|
||||||
img: HTMLImageElement,
|
|
||||||
workflow: ComfyWorkflow
|
|
||||||
) {
|
|
||||||
const openWorkflow = app.workflowManager.openWorkflows.find(
|
|
||||||
(w) => w.path === workflow.path
|
|
||||||
)
|
|
||||||
if (openWorkflow) {
|
|
||||||
workflow = openWorkflow
|
|
||||||
}
|
|
||||||
|
|
||||||
await workflow.load()
|
|
||||||
let options = []
|
|
||||||
const nodes = app.graph.computeExecutionOrder(false)
|
|
||||||
for (const node of nodes) {
|
|
||||||
const widget = getImageWidget(node)
|
|
||||||
if (widget == null) continue
|
|
||||||
|
|
||||||
if (node.title?.toLowerCase().includes('input')) {
|
|
||||||
options = [{ widget, node }]
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
options.push({ widget, node })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.length) {
|
|
||||||
alert('No image nodes have been found in this workflow!')
|
|
||||||
return
|
|
||||||
} else if (options.length > 1) {
|
|
||||||
const dialog = new WidgetSelectionDialog(options)
|
|
||||||
const res = await dialog.show(app)
|
|
||||||
if (!res) return
|
|
||||||
options = [res]
|
|
||||||
}
|
|
||||||
|
|
||||||
setWidgetImage(options[0].node, options[0].widget, img)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExtraMenuOptions = nodeType.prototype['getExtraMenuOptions']
|
|
||||||
nodeType.prototype['getExtraMenuOptions'] = function (
|
|
||||||
this: { imageIndex?: number; overIndex?: number; imgs: string[] },
|
|
||||||
_,
|
|
||||||
options
|
|
||||||
) {
|
|
||||||
const r = getExtraMenuOptions?.apply?.(this, arguments)
|
|
||||||
const setting = app.ui.settings.getSettingValue(
|
|
||||||
'Comfy.UseNewMenu',
|
|
||||||
'Disabled'
|
|
||||||
)
|
|
||||||
if (setting && setting != 'Disabled') {
|
|
||||||
const t = this
|
|
||||||
let img
|
|
||||||
if (t.imageIndex != null) {
|
|
||||||
// An image is selected so select that
|
|
||||||
img = t.imgs?.[t.imageIndex]
|
|
||||||
} else if (t.overIndex != null) {
|
|
||||||
// No image is selected but one is hovered
|
|
||||||
img = t.imgs?.[t.overIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (img) {
|
|
||||||
let pos = options.findIndex((o) => o.content === 'Save Image')
|
|
||||||
if (pos === -1) {
|
|
||||||
pos = 0
|
|
||||||
} else {
|
|
||||||
pos++
|
|
||||||
}
|
|
||||||
|
|
||||||
options.splice(pos, 0, {
|
|
||||||
content: 'Send to workflow',
|
|
||||||
has_submenu: true,
|
|
||||||
submenu: {
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
callback: () =>
|
|
||||||
sendToWorkflow(img, app.workflowManager.activeWorkflow),
|
|
||||||
title: '[Current workflow]'
|
|
||||||
},
|
|
||||||
...self.#getFavoriteMenuOptions(
|
|
||||||
sendToWorkflow.bind(null, img)
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
...self.#getMenuOptions(sendToWorkflow.bind(null, img))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ComfyWorkflowsContent {
|
|
||||||
element = $el('div.comfyui-workflows-panel')
|
|
||||||
treeState = {}
|
|
||||||
treeFiles: Record<string, WorkflowElement> = {}
|
|
||||||
openFiles: Map<ComfyWorkflow, WorkflowElement<ComfyComponent>> = new Map()
|
|
||||||
activeElement: WorkflowElement<ComfyComponent> = null
|
|
||||||
spinner: Element
|
|
||||||
openElement: HTMLElement
|
|
||||||
favoritesElement: HTMLElement
|
|
||||||
treeElement: HTMLElement
|
|
||||||
app: ComfyApp
|
|
||||||
popup: ComfyPopup
|
|
||||||
actions: HTMLElement
|
|
||||||
filterText: string | undefined
|
|
||||||
treeRoot: HTMLElement
|
|
||||||
|
|
||||||
constructor(app: ComfyApp, popup: ComfyPopup) {
|
|
||||||
this.app = app
|
|
||||||
this.popup = popup
|
|
||||||
this.actions = $el('div.comfyui-workflows-actions', [
|
|
||||||
new ComfyButton({
|
|
||||||
content: 'Default',
|
|
||||||
icon: 'file-code',
|
|
||||||
iconSize: 18,
|
|
||||||
classList: 'comfyui-button primary',
|
|
||||||
tooltip: 'Load default workflow',
|
|
||||||
action: () => {
|
|
||||||
popup.open = false
|
|
||||||
app.loadGraphData()
|
|
||||||
app.resetView()
|
|
||||||
}
|
|
||||||
}).element,
|
|
||||||
new ComfyButton({
|
|
||||||
content: 'Browse',
|
|
||||||
icon: 'folder',
|
|
||||||
iconSize: 18,
|
|
||||||
tooltip: 'Browse for an image or exported workflow',
|
|
||||||
action: () => {
|
|
||||||
popup.open = false
|
|
||||||
app.ui.loadFile()
|
|
||||||
}
|
|
||||||
}).element,
|
|
||||||
new ComfyButton({
|
|
||||||
content: 'Blank',
|
|
||||||
icon: 'plus-thick',
|
|
||||||
iconSize: 18,
|
|
||||||
tooltip: 'Create a new blank workflow',
|
|
||||||
action: () => {
|
|
||||||
app.workflowManager.setWorkflow(null)
|
|
||||||
app.clean()
|
|
||||||
app.graph.clear()
|
|
||||||
app.workflowManager.activeWorkflow.track()
|
|
||||||
popup.open = false
|
|
||||||
}
|
|
||||||
}).element
|
|
||||||
])
|
|
||||||
|
|
||||||
this.spinner = createSpinner()
|
|
||||||
this.element.replaceChildren(this.actions, this.spinner)
|
|
||||||
|
|
||||||
this.popup.addEventListener('open', () => this.load())
|
|
||||||
this.popup.addEventListener('close', () =>
|
|
||||||
this.element.replaceChildren(this.actions, this.spinner)
|
|
||||||
)
|
|
||||||
|
|
||||||
this.app.workflowManager.addEventListener('favorite', (e) => {
|
|
||||||
const workflow = e['detail']
|
|
||||||
const button = this.treeFiles[workflow.path]?.primary
|
|
||||||
if (!button) return // Can happen when a workflow is renamed
|
|
||||||
button.icon = this.#getFavoriteIcon(workflow)
|
|
||||||
button.overIcon = this.#getFavoriteOverIcon(workflow)
|
|
||||||
this.updateFavorites()
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const e of ['save', 'open', 'close', 'changeWorkflow']) {
|
|
||||||
// TODO: dont be lazy and just update the specific element
|
|
||||||
app.workflowManager.addEventListener(e, () => this.updateOpen())
|
|
||||||
}
|
|
||||||
this.app.workflowManager.addEventListener('rename', () => this.load())
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
await this.app.workflowManager.loadWorkflows()
|
|
||||||
this.updateTree()
|
|
||||||
this.updateFavorites()
|
|
||||||
this.updateOpen()
|
|
||||||
this.element.replaceChildren(
|
|
||||||
this.actions,
|
|
||||||
this.openElement,
|
|
||||||
this.favoritesElement,
|
|
||||||
this.treeElement
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateOpen() {
|
|
||||||
const current = this.openElement
|
|
||||||
this.openFiles.clear()
|
|
||||||
|
|
||||||
this.openElement = $el('div.comfyui-workflows-open', [
|
|
||||||
$el('h3', 'Open'),
|
|
||||||
...this.app.workflowManager.openWorkflows.map((w) => {
|
|
||||||
const wrapper = new WorkflowElement(this, w, {
|
|
||||||
primary: { element: $el('i.mdi.mdi-18px.mdi-progress-pencil') },
|
|
||||||
buttons: [
|
|
||||||
this.#getRenameButton(w),
|
|
||||||
new ComfyButton({
|
|
||||||
icon: 'close',
|
|
||||||
iconSize: 18,
|
|
||||||
classList: 'comfyui-button comfyui-workflows-file-action',
|
|
||||||
tooltip: 'Close workflow',
|
|
||||||
action: (e) => {
|
|
||||||
e.stopImmediatePropagation()
|
|
||||||
this.app.workflowManager.closeWorkflow(w)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
if (w.unsaved) {
|
|
||||||
wrapper.element.classList.add('unsaved')
|
|
||||||
}
|
|
||||||
if (w === this.app.workflowManager.activeWorkflow) {
|
|
||||||
wrapper.element.classList.add('active')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.openFiles.set(w, wrapper)
|
|
||||||
return wrapper.element
|
|
||||||
})
|
|
||||||
])
|
|
||||||
|
|
||||||
this.#updateActive()
|
|
||||||
current?.replaceWith(this.openElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFavorites() {
|
|
||||||
const current = this.favoritesElement
|
|
||||||
const favorites = [
|
|
||||||
...this.app.workflowManager.workflows.filter((w) => w.isFavorite)
|
|
||||||
]
|
|
||||||
|
|
||||||
this.favoritesElement = $el('div.comfyui-workflows-favorites', [
|
|
||||||
$el('h3', 'Favorites'),
|
|
||||||
...favorites
|
|
||||||
.map((w) => {
|
|
||||||
return this.#getWorkflowElement(w).element
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
])
|
|
||||||
|
|
||||||
current?.replaceWith(this.favoritesElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
filterTree() {
|
|
||||||
if (!this.filterText) {
|
|
||||||
this.treeRoot.classList.remove('filtered')
|
|
||||||
// Unfilter whole tree
|
|
||||||
for (const item of Object.values(this.treeFiles)) {
|
|
||||||
item.element.parentElement.style.removeProperty('display')
|
|
||||||
this.showTreeParents(item.element.parentElement)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.treeRoot.classList.add('filtered')
|
|
||||||
const searchTerms = this.filterText.toLocaleLowerCase().split(' ')
|
|
||||||
for (const item of Object.values(this.treeFiles)) {
|
|
||||||
const parts = item.workflow.pathParts
|
|
||||||
let termIndex = 0
|
|
||||||
let valid = false
|
|
||||||
for (const part of parts) {
|
|
||||||
let currentIndex = 0
|
|
||||||
do {
|
|
||||||
currentIndex = part.indexOf(searchTerms[termIndex], currentIndex)
|
|
||||||
if (currentIndex > -1) currentIndex += searchTerms[termIndex].length
|
|
||||||
} while (currentIndex !== -1 && ++termIndex < searchTerms.length)
|
|
||||||
|
|
||||||
if (termIndex >= searchTerms.length) {
|
|
||||||
valid = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (valid) {
|
|
||||||
item.element.parentElement.style.removeProperty('display')
|
|
||||||
this.showTreeParents(item.element.parentElement)
|
|
||||||
} else {
|
|
||||||
item.element.parentElement.style.display = 'none'
|
|
||||||
this.hideTreeParents(item.element.parentElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideTreeParents(element) {
|
|
||||||
// Hide all parents if no children are visible
|
|
||||||
if (
|
|
||||||
element.parentElement?.classList.contains('comfyui-workflows-tree') ===
|
|
||||||
false
|
|
||||||
) {
|
|
||||||
for (let i = 1; i < element.parentElement.children.length; i++) {
|
|
||||||
const c = element.parentElement.children[i]
|
|
||||||
if (c.style.display !== 'none') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
element.parentElement.style.display = 'none'
|
|
||||||
this.hideTreeParents(element.parentElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showTreeParents(element) {
|
|
||||||
if (
|
|
||||||
element.parentElement?.classList.contains('comfyui-workflows-tree') ===
|
|
||||||
false
|
|
||||||
) {
|
|
||||||
element.parentElement.style.removeProperty('display')
|
|
||||||
this.showTreeParents(element.parentElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTree() {
|
|
||||||
const current = this.treeElement
|
|
||||||
const nodes = {}
|
|
||||||
let typingTimeout
|
|
||||||
|
|
||||||
this.treeFiles = {}
|
|
||||||
this.treeRoot = $el('ul.comfyui-workflows-tree')
|
|
||||||
this.treeElement = $el('section', [
|
|
||||||
$el('header', [
|
|
||||||
$el('h3', 'Browse'),
|
|
||||||
$el('div.comfy-ui-workflows-search', [
|
|
||||||
$el('i.mdi.mdi-18px.mdi-magnify'),
|
|
||||||
$el('input', {
|
|
||||||
placeholder: 'Search',
|
|
||||||
role: 'search',
|
|
||||||
value: this.filterText ?? '',
|
|
||||||
oninput: (e: InputEvent) => {
|
|
||||||
this.filterText = e.target['value']?.trim()
|
|
||||||
clearTimeout(typingTimeout)
|
|
||||||
typingTimeout = setTimeout(() => this.filterTree(), 250)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
this.treeRoot
|
|
||||||
])
|
|
||||||
|
|
||||||
for (const workflow of this.app.workflowManager.workflows) {
|
|
||||||
if (!workflow.pathParts) continue
|
|
||||||
|
|
||||||
let currentPath = ''
|
|
||||||
let currentRoot = this.treeRoot
|
|
||||||
|
|
||||||
for (let i = 0; i < workflow.pathParts.length; i++) {
|
|
||||||
currentPath += (currentPath ? '\\' : '') + workflow.pathParts[i]
|
|
||||||
const parentNode =
|
|
||||||
nodes[currentPath] ??
|
|
||||||
this.#createNode(currentPath, workflow, i, currentRoot)
|
|
||||||
|
|
||||||
nodes[currentPath] = parentNode
|
|
||||||
currentRoot = parentNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
current?.replaceWith(this.treeElement)
|
|
||||||
this.filterTree()
|
|
||||||
}
|
|
||||||
|
|
||||||
#expandNode(el, workflow, thisPath, i) {
|
|
||||||
const expanded = !el.classList.toggle('closed')
|
|
||||||
if (expanded) {
|
|
||||||
let c = ''
|
|
||||||
for (let j = 0; j <= i; j++) {
|
|
||||||
c += (c ? '\\' : '') + workflow.pathParts[j]
|
|
||||||
this.treeState[c] = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let c = thisPath
|
|
||||||
for (let j = i + 1; j < workflow.pathParts.length; j++) {
|
|
||||||
c += (c ? '\\' : '') + workflow.pathParts[j]
|
|
||||||
delete this.treeState[c]
|
|
||||||
}
|
|
||||||
delete this.treeState[thisPath]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateActive() {
|
|
||||||
this.#removeActive()
|
|
||||||
|
|
||||||
const active = this.app.workflowManager.activePrompt
|
|
||||||
if (!active?.workflow) return
|
|
||||||
|
|
||||||
const open = this.openFiles.get(active.workflow)
|
|
||||||
if (!open) return
|
|
||||||
|
|
||||||
this.activeElement = open
|
|
||||||
|
|
||||||
const total = Object.values(active.nodes)
|
|
||||||
const done = total.filter(Boolean)
|
|
||||||
const percent = done.length / total.length
|
|
||||||
open.element.classList.add('running')
|
|
||||||
open.element.style.setProperty('--progress', percent * 100 + '%')
|
|
||||||
open.primary.element.classList.remove('mdi-progress-pencil')
|
|
||||||
open.primary.element.classList.add('mdi-play')
|
|
||||||
}
|
|
||||||
|
|
||||||
#removeActive() {
|
|
||||||
if (!this.activeElement) return
|
|
||||||
this.activeElement.element.classList.remove('running')
|
|
||||||
this.activeElement.element.style.removeProperty('--progress')
|
|
||||||
this.activeElement.primary.element.classList.add('mdi-progress-pencil')
|
|
||||||
this.activeElement.primary.element.classList.remove('mdi-play')
|
|
||||||
}
|
|
||||||
|
|
||||||
#getFavoriteIcon(workflow: ComfyWorkflow) {
|
|
||||||
return workflow.isFavorite ? 'star' : 'file-outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
#getFavoriteOverIcon(workflow: ComfyWorkflow) {
|
|
||||||
return workflow.isFavorite ? 'star-off' : 'star-outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
#getFavoriteTooltip(workflow: ComfyWorkflow) {
|
|
||||||
return workflow.isFavorite
|
|
||||||
? 'Remove this workflow from your favorites'
|
|
||||||
: 'Add this workflow to your favorites'
|
|
||||||
}
|
|
||||||
|
|
||||||
#getFavoriteButton(workflow: ComfyWorkflow, primary: boolean) {
|
|
||||||
return new ComfyButton({
|
|
||||||
icon: this.#getFavoriteIcon(workflow),
|
|
||||||
overIcon: this.#getFavoriteOverIcon(workflow),
|
|
||||||
iconSize: 18,
|
|
||||||
classList:
|
|
||||||
'comfyui-button comfyui-workflows-file-action-favorite' +
|
|
||||||
(primary ? ' comfyui-workflows-file-action-primary' : ''),
|
|
||||||
tooltip: this.#getFavoriteTooltip(workflow),
|
|
||||||
action: (e) => {
|
|
||||||
e.stopImmediatePropagation()
|
|
||||||
workflow.favorite(!workflow.isFavorite)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#getDeleteButton(workflow: ComfyWorkflow) {
|
|
||||||
const deleteButton = new ComfyButton({
|
|
||||||
icon: 'delete',
|
|
||||||
tooltip: 'Delete this workflow',
|
|
||||||
classList: 'comfyui-button comfyui-workflows-file-action',
|
|
||||||
iconSize: 18,
|
|
||||||
action: async (e, btn) => {
|
|
||||||
e.stopImmediatePropagation()
|
|
||||||
|
|
||||||
if (btn.icon === 'delete-empty') {
|
|
||||||
btn.enabled = false
|
|
||||||
await workflow.delete()
|
|
||||||
await this.load()
|
|
||||||
} else {
|
|
||||||
btn.icon = 'delete-empty'
|
|
||||||
btn.element.style.background = 'red'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
deleteButton.element.addEventListener('mouseleave', () => {
|
|
||||||
deleteButton.icon = 'delete'
|
|
||||||
deleteButton.element.style.removeProperty('background')
|
|
||||||
})
|
|
||||||
return deleteButton
|
|
||||||
}
|
|
||||||
|
|
||||||
#getInsertButton(workflow: ComfyWorkflow) {
|
|
||||||
return new ComfyButton({
|
|
||||||
icon: 'file-move-outline',
|
|
||||||
iconSize: 18,
|
|
||||||
tooltip: 'Insert this workflow into the current workflow',
|
|
||||||
classList: 'comfyui-button comfyui-workflows-file-action',
|
|
||||||
action: (e) => {
|
|
||||||
if (!this.app.shiftDown) {
|
|
||||||
this.popup.open = false
|
|
||||||
}
|
|
||||||
e.stopImmediatePropagation()
|
|
||||||
if (!this.app.shiftDown) {
|
|
||||||
this.popup.open = false
|
|
||||||
}
|
|
||||||
workflow.insert()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {ComfyWorkflow} workflow */
|
|
||||||
#getRenameButton(workflow: ComfyWorkflow) {
|
|
||||||
return new ComfyButton({
|
|
||||||
icon: 'pencil',
|
|
||||||
tooltip: workflow.path
|
|
||||||
? 'Rename this workflow'
|
|
||||||
: "This workflow can't be renamed as it hasn't been saved.",
|
|
||||||
classList: 'comfyui-button comfyui-workflows-file-action',
|
|
||||||
iconSize: 18,
|
|
||||||
enabled: !!workflow.path,
|
|
||||||
action: async (e) => {
|
|
||||||
e.stopImmediatePropagation()
|
|
||||||
const newName = prompt('Enter new name', workflow.path)
|
|
||||||
if (newName) {
|
|
||||||
await workflow.rename(newName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#getWorkflowElement(workflow: ComfyWorkflow) {
|
|
||||||
return new WorkflowElement(this, workflow, {
|
|
||||||
primary: this.#getFavoriteButton(workflow, true),
|
|
||||||
buttons: [
|
|
||||||
this.#getInsertButton(workflow),
|
|
||||||
this.#getRenameButton(workflow),
|
|
||||||
this.#getDeleteButton(workflow)
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#createLeafNode(workflow: ComfyWorkflow) {
|
|
||||||
const fileNode = this.#getWorkflowElement(workflow)
|
|
||||||
this.treeFiles[workflow.path] = fileNode
|
|
||||||
return fileNode
|
|
||||||
}
|
|
||||||
|
|
||||||
#createNode(currentPath, workflow, i, currentRoot) {
|
|
||||||
const part = workflow.pathParts[i]
|
|
||||||
|
|
||||||
const parentNode = $el(
|
|
||||||
'ul' + (this.treeState[currentPath] ? '' : '.closed'),
|
|
||||||
{
|
|
||||||
$: (el) => {
|
|
||||||
el.onclick = (e) => {
|
|
||||||
this.#expandNode(el, workflow, currentPath, i)
|
|
||||||
e.stopImmediatePropagation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
currentRoot.append(parentNode)
|
|
||||||
|
|
||||||
// Create a node for the current part and an inner UL for its children if it isnt a leaf node
|
|
||||||
const leaf = i === workflow.pathParts.length - 1
|
|
||||||
let nodeElement
|
|
||||||
if (leaf) {
|
|
||||||
nodeElement = this.#createLeafNode(workflow).element
|
|
||||||
} else {
|
|
||||||
nodeElement = $el('li', [
|
|
||||||
$el('i.mdi.mdi-18px.mdi-folder'),
|
|
||||||
$el('span', part)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
parentNode.append(nodeElement)
|
|
||||||
return parentNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class WorkflowElement<TPrimary extends ComfyComponent = ComfyButton> {
|
|
||||||
parent: ComfyWorkflowsContent
|
|
||||||
workflow: ComfyWorkflow
|
|
||||||
primary: TPrimary
|
|
||||||
buttons: ComfyButton[]
|
|
||||||
element: HTMLElement
|
|
||||||
constructor(
|
|
||||||
parent: ComfyWorkflowsContent,
|
|
||||||
workflow: ComfyWorkflow,
|
|
||||||
{
|
|
||||||
tagName = 'li',
|
|
||||||
primary,
|
|
||||||
buttons
|
|
||||||
}: { tagName?: string; primary: TPrimary; buttons: ComfyButton[] }
|
|
||||||
) {
|
|
||||||
this.parent = parent
|
|
||||||
this.workflow = workflow
|
|
||||||
this.primary = primary
|
|
||||||
this.buttons = buttons
|
|
||||||
|
|
||||||
this.element = $el(
|
|
||||||
tagName + '.comfyui-workflows-tree-file',
|
|
||||||
{
|
|
||||||
onclick: () => {
|
|
||||||
workflow.load()
|
|
||||||
this.parent.popup.open = false
|
|
||||||
},
|
|
||||||
title: this.workflow.path
|
|
||||||
},
|
|
||||||
[
|
|
||||||
this.primary?.element,
|
|
||||||
$el('span', workflow.name),
|
|
||||||
...buttons.map((b) => b.element)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WidgetSelectionDialogOptions = Array<{
|
|
||||||
widget: { name: string }
|
|
||||||
node: { pos: [number, number]; title: string; id: string; type: string }
|
|
||||||
}>
|
|
||||||
|
|
||||||
class WidgetSelectionDialog extends ComfyAsyncDialog {
|
|
||||||
#options: WidgetSelectionDialogOptions
|
|
||||||
|
|
||||||
constructor(options: WidgetSelectionDialogOptions) {
|
|
||||||
super()
|
|
||||||
this.#options = options
|
|
||||||
}
|
|
||||||
|
|
||||||
show(app) {
|
|
||||||
this.element.classList.add('comfy-widget-selection-dialog')
|
|
||||||
return super.show(
|
|
||||||
$el('div', [
|
|
||||||
$el('h2', 'Select image target'),
|
|
||||||
$el(
|
|
||||||
'p',
|
|
||||||
"This workflow has multiple image loader nodes, you can rename a node to include 'input' in the title for it to be automatically selected, or select one below."
|
|
||||||
),
|
|
||||||
$el(
|
|
||||||
'section',
|
|
||||||
this.#options.map((opt) => {
|
|
||||||
return $el('div.comfy-widget-selection-item', [
|
|
||||||
$el(
|
|
||||||
'span',
|
|
||||||
{ dataset: { id: opt.node.id } },
|
|
||||||
`${opt.node.title ?? opt.node.type} ${opt.widget.name}`
|
|
||||||
),
|
|
||||||
$el(
|
|
||||||
'button.comfyui-button',
|
|
||||||
{
|
|
||||||
onclick: () => {
|
|
||||||
app.canvas.ds.offset[0] = -opt.node.pos[0] + 50
|
|
||||||
app.canvas.ds.offset[1] = -opt.node.pos[1] + 50
|
|
||||||
app.canvas.selectNode(opt.node)
|
|
||||||
app.graph.setDirtyCanvas(true, true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'Show'
|
|
||||||
),
|
|
||||||
$el(
|
|
||||||
'button.comfyui-button.primary',
|
|
||||||
{
|
|
||||||
onclick: () => {
|
|
||||||
this.close(opt)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'Select'
|
|
||||||
)
|
|
||||||
])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,21 @@ import type { ComfyApp } from './app'
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import { ChangeTracker } from './changeTracker'
|
import { ChangeTracker } from './changeTracker'
|
||||||
import { ComfyAsyncDialog } from './ui/components/asyncDialog'
|
import { ComfyAsyncDialog } from './ui/components/asyncDialog'
|
||||||
import { getStorageValue, setStorageValue } from './utils'
|
import { setStorageValue } from './utils'
|
||||||
import { LGraphCanvas, LGraph } from '@comfyorg/litegraph'
|
import { LGraphCanvas, LGraph } from '@comfyorg/litegraph'
|
||||||
import { appendJsonExt, trimJsonExt } from '@/utils/formatUtil'
|
import { appendJsonExt, trimJsonExt } from '@/utils/formatUtil'
|
||||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
import {
|
||||||
|
useWorkflowStore,
|
||||||
|
useWorkflowBookmarkStore
|
||||||
|
} from '@/stores/workflowStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { markRaw, toRaw } from 'vue'
|
import { markRaw, toRaw } from 'vue'
|
||||||
|
import { UserDataFullInfo } from '@/types/apiTypes'
|
||||||
|
|
||||||
export class ComfyWorkflowManager extends EventTarget {
|
export class ComfyWorkflowManager extends EventTarget {
|
||||||
executionStore: ReturnType<typeof useExecutionStore> | null
|
executionStore: ReturnType<typeof useExecutionStore> | null
|
||||||
workflowStore: ReturnType<typeof useWorkflowStore> | null
|
workflowStore: ReturnType<typeof useWorkflowStore> | null
|
||||||
|
workflowBookmarkStore: ReturnType<typeof useWorkflowBookmarkStore> | null
|
||||||
|
|
||||||
app: ComfyApp
|
app: ComfyApp
|
||||||
#unsavedCount = 0
|
#unsavedCount = 0
|
||||||
@@ -30,12 +35,12 @@ export class ComfyWorkflowManager extends EventTarget {
|
|||||||
|
|
||||||
get _activeWorkflow(): ComfyWorkflow | null {
|
get _activeWorkflow(): ComfyWorkflow | null {
|
||||||
if (!this.app.vueAppReady) return null
|
if (!this.app.vueAppReady) return null
|
||||||
return toRaw(useWorkflowStore().activeWorkflow) as ComfyWorkflow | null
|
return this.workflowStore!.activeWorkflow as ComfyWorkflow | null
|
||||||
}
|
}
|
||||||
|
|
||||||
set _activeWorkflow(workflow: ComfyWorkflow | null) {
|
set _activeWorkflow(workflow: ComfyWorkflow | null) {
|
||||||
if (!this.app.vueAppReady) return
|
if (!this.app.vueAppReady) return
|
||||||
useWorkflowStore().activeWorkflow = workflow ? workflow : null
|
this.workflowStore!.activeWorkflow = workflow ? workflow : null
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeWorkflow(): ComfyWorkflow | null {
|
get activeWorkflow(): ComfyWorkflow | null {
|
||||||
@@ -58,55 +63,32 @@ export class ComfyWorkflowManager extends EventTarget {
|
|||||||
|
|
||||||
async loadWorkflows() {
|
async loadWorkflows() {
|
||||||
try {
|
try {
|
||||||
let favorites
|
const [files, _] = await Promise.all([
|
||||||
const resp = await api.getUserData('workflows/.index.json')
|
api.listUserDataFullInfo('workflows'),
|
||||||
let info
|
this.workflowBookmarkStore?.loadBookmarks()
|
||||||
if (resp.status === 200) {
|
])
|
||||||
info = await resp.json()
|
|
||||||
favorites = new Set(info?.favorites ?? [])
|
|
||||||
} else {
|
|
||||||
favorites = new Set()
|
|
||||||
}
|
|
||||||
|
|
||||||
;(await api.listUserData('workflows', true, true)).forEach(
|
files.forEach((file: UserDataFullInfo) => {
|
||||||
(w: string[]) => {
|
let workflow = this.workflowLookup[file.path]
|
||||||
let workflow = this.workflowLookup[w[0]]
|
if (!workflow) {
|
||||||
if (!workflow) {
|
workflow = new ComfyWorkflow(this, file.path, file.path.split('/'))
|
||||||
workflow = new ComfyWorkflow(
|
this.workflowLookup[workflow.path] = workflow
|
||||||
this,
|
|
||||||
w[0],
|
|
||||||
w.slice(1),
|
|
||||||
favorites.has(w[0])
|
|
||||||
)
|
|
||||||
this.workflowLookup[workflow.path] = workflow
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Error loading workflows: ' + (error.message ?? error))
|
alert('Error loading workflows: ' + (error.message ?? error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveWorkflowMetadata() {
|
|
||||||
await api.storeUserData('workflows/.index.json', {
|
|
||||||
favorites: [
|
|
||||||
...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string | ComfyWorkflow | null} workflow
|
* @param {string | ComfyWorkflow | null} workflow
|
||||||
*/
|
*/
|
||||||
setWorkflow(workflow) {
|
setWorkflow(workflow) {
|
||||||
if (workflow && typeof workflow === 'string') {
|
if (workflow && typeof workflow === 'string') {
|
||||||
// Selected by path, i.e. on reload of last workflow
|
|
||||||
const found = this.workflows.find((w) => w.path === workflow)
|
const found = this.workflows.find((w) => w.path === workflow)
|
||||||
if (found) {
|
if (found) {
|
||||||
workflow = found
|
workflow = found
|
||||||
workflow.unsaved =
|
workflow.unsaved = !workflow
|
||||||
!workflow ||
|
|
||||||
getStorageValue('Comfy.PreviousWorkflowUnsaved') === 'true'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +100,12 @@ export class ComfyWorkflowManager extends EventTarget {
|
|||||||
'Unsaved Workflow' +
|
'Unsaved Workflow' +
|
||||||
(this.#unsavedCount++ ? ` (${this.#unsavedCount})` : '')
|
(this.#unsavedCount++ ? ` (${this.#unsavedCount})` : '')
|
||||||
)
|
)
|
||||||
|
this.workflowLookup[workflow.key] = workflow
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = this.openWorkflows.indexOf(workflow)
|
if (!workflow.isOpen) {
|
||||||
if (index === -1) {
|
|
||||||
// Opening a new workflow
|
// Opening a new workflow
|
||||||
this.openWorkflows.push(workflow)
|
workflow.track()
|
||||||
}
|
}
|
||||||
|
|
||||||
this._activeWorkflow = workflow
|
this._activeWorkflow = workflow
|
||||||
@@ -140,10 +122,7 @@ export class ComfyWorkflowManager extends EventTarget {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async closeWorkflow(workflow: ComfyWorkflow, warnIfUnsaved: boolean = true) {
|
||||||
* @param {ComfyWorkflow} workflow
|
|
||||||
*/
|
|
||||||
async closeWorkflow(workflow, warnIfUnsaved = true) {
|
|
||||||
if (!workflow.isOpen) {
|
if (!workflow.isOpen) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -172,8 +151,8 @@ export class ComfyWorkflowManager extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
workflow.changeTracker = null
|
workflow.changeTracker = null
|
||||||
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1)
|
workflow.isOpen = false
|
||||||
if (this.openWorkflows.length) {
|
if (this.openWorkflows.length > 0) {
|
||||||
this._activeWorkflow = this.openWorkflows[0]
|
this._activeWorkflow = this.openWorkflows[0]
|
||||||
await this._activeWorkflow.load()
|
await this._activeWorkflow.load()
|
||||||
} else {
|
} else {
|
||||||
@@ -185,27 +164,45 @@ export class ComfyWorkflowManager extends EventTarget {
|
|||||||
|
|
||||||
export class ComfyWorkflow {
|
export class ComfyWorkflow {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string | null
|
||||||
pathParts: string[]
|
pathParts: string[] | null
|
||||||
isFavorite = false
|
|
||||||
changeTracker: ChangeTracker | null = null
|
|
||||||
unsaved = false
|
unsaved = false
|
||||||
|
// Raw
|
||||||
manager: ComfyWorkflowManager
|
manager: ComfyWorkflowManager
|
||||||
|
changeTracker: ChangeTracker | null = null
|
||||||
|
isOpen: boolean = false
|
||||||
|
|
||||||
get isOpen() {
|
get isTemporary() {
|
||||||
return !!this.changeTracker
|
return !this.path
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPersisted() {
|
||||||
|
return !this.isTemporary
|
||||||
|
}
|
||||||
|
|
||||||
|
get key() {
|
||||||
|
return this.pathParts?.join('/') ?? this.name + '.json'
|
||||||
|
}
|
||||||
|
|
||||||
|
get isBookmarked() {
|
||||||
|
return this.manager.workflowBookmarkStore?.isBookmarked(this.path) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use isBookmarked instead
|
||||||
|
*/
|
||||||
|
get isFavorite() {
|
||||||
|
return this.isBookmarked
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
manager: ComfyWorkflowManager,
|
manager: ComfyWorkflowManager,
|
||||||
path: string,
|
path: string,
|
||||||
pathParts?: string[],
|
pathParts?: string[]
|
||||||
isFavorite?: boolean
|
|
||||||
) {
|
) {
|
||||||
this.manager = markRaw(manager)
|
this.manager = markRaw(manager)
|
||||||
if (pathParts) {
|
if (pathParts) {
|
||||||
this.updatePath(path, pathParts)
|
this.updatePath(path, pathParts)
|
||||||
this.isFavorite = isFavorite
|
|
||||||
} else {
|
} else {
|
||||||
this.name = path
|
this.name = path
|
||||||
this.unsaved = true
|
this.unsaved = true
|
||||||
@@ -238,7 +235,7 @@ export class ComfyWorkflow {
|
|||||||
return await resp.json()
|
return await resp.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
load = async () => {
|
async load() {
|
||||||
if (this.isOpen) {
|
if (this.isOpen) {
|
||||||
await this.manager.app.loadGraphData(
|
await this.manager.app.loadGraphData(
|
||||||
this.changeTracker.activeState,
|
this.changeTracker.activeState,
|
||||||
@@ -258,18 +255,17 @@ export class ComfyWorkflow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async save(saveAs = false) {
|
async save(saveAs = false) {
|
||||||
if (!this.path || saveAs) {
|
const createNewFile = !this.path || saveAs
|
||||||
return !!(await this.#save(null, false))
|
return !!(await this._save(
|
||||||
} else {
|
createNewFile ? null : this.path,
|
||||||
return !!(await this.#save(this.path, true))
|
/* overwrite */ !createNewFile
|
||||||
}
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async favorite(value: boolean) {
|
async favorite(value: boolean) {
|
||||||
try {
|
try {
|
||||||
if (this.isFavorite === value) return
|
if (this.isBookmarked === value) return
|
||||||
this.isFavorite = value
|
this.manager.workflowBookmarkStore?.setBookmarked(this.path, value)
|
||||||
await this.manager.saveWorkflowMetadata()
|
|
||||||
this.manager.dispatchEvent(new CustomEvent('favorite', { detail: this }))
|
this.manager.dispatchEvent(new CustomEvent('favorite', { detail: this }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(
|
alert(
|
||||||
@@ -365,9 +361,10 @@ export class ComfyWorkflow {
|
|||||||
} else {
|
} else {
|
||||||
this.changeTracker = markRaw(new ChangeTracker(this))
|
this.changeTracker = markRaw(new ChangeTracker(this))
|
||||||
}
|
}
|
||||||
|
this.isOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async #save(path: string | null, overwrite: boolean) {
|
private async _save(path: string | null, overwrite: boolean) {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
path = prompt(
|
path = prompt(
|
||||||
'Save workflow as:',
|
'Save workflow as:',
|
||||||
|
|||||||
@@ -1,23 +1,96 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||||
import { getStorageValue } from '@/scripts/utils'
|
import { buildTree } from '@/utils/treeUtil'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
|
||||||
export const useWorkflowStore = defineStore('workflow', () => {
|
export const useWorkflowStore = defineStore('workflow', () => {
|
||||||
const activeWorkflow = ref<ComfyWorkflow | null>(null)
|
const activeWorkflow = ref<ComfyWorkflow | null>(null)
|
||||||
const previousWorkflowUnsaved = ref<boolean>(
|
|
||||||
Boolean(getStorageValue('Comfy.PreviousWorkflowUnsaved'))
|
|
||||||
)
|
|
||||||
|
|
||||||
const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
|
const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
|
||||||
const workflows = computed(() => Object.values(workflowLookup.value))
|
const workflows = computed(() => Object.values(workflowLookup.value))
|
||||||
const openWorkflows = ref<ComfyWorkflow[]>([])
|
const persistedWorkflows = computed(() =>
|
||||||
|
workflows.value.filter((workflow) => workflow.isPersisted)
|
||||||
|
)
|
||||||
|
const openWorkflows = computed(() =>
|
||||||
|
workflows.value.filter((workflow) => workflow.isOpen)
|
||||||
|
)
|
||||||
|
const bookmarkedWorkflows = computed(() =>
|
||||||
|
workflows.value.filter((workflow) => workflow.isBookmarked)
|
||||||
|
)
|
||||||
|
const modifiedWorkflows = computed(() =>
|
||||||
|
workflows.value.filter((workflow) => workflow.unsaved)
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildWorkflowTree = (workflows: ComfyWorkflow[]) => {
|
||||||
|
return buildTree(workflows, (workflow: ComfyWorkflow) =>
|
||||||
|
workflow.key.split('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const workflowsTree = computed(() =>
|
||||||
|
buildWorkflowTree(persistedWorkflows.value)
|
||||||
|
)
|
||||||
|
// Bookmarked workflows tree is flat.
|
||||||
|
const bookmarkedWorkflowsTree = computed(() =>
|
||||||
|
buildTree(bookmarkedWorkflows.value, (workflow: ComfyWorkflow) => [
|
||||||
|
workflow.path
|
||||||
|
])
|
||||||
|
)
|
||||||
|
// Open workflows tree is flat.
|
||||||
|
const openWorkflowsTree = computed(() =>
|
||||||
|
buildTree(openWorkflows.value, (workflow: ComfyWorkflow) => [workflow.key])
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeWorkflow,
|
activeWorkflow,
|
||||||
previousWorkflowUnsaved,
|
|
||||||
workflows,
|
workflows,
|
||||||
openWorkflows,
|
openWorkflows,
|
||||||
workflowLookup
|
bookmarkedWorkflows,
|
||||||
|
modifiedWorkflows,
|
||||||
|
workflowLookup,
|
||||||
|
workflowsTree,
|
||||||
|
bookmarkedWorkflowsTree,
|
||||||
|
openWorkflowsTree,
|
||||||
|
buildWorkflowTree
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
|
||||||
|
const bookmarks = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const isBookmarked = (path: string) => bookmarks.value.has(path)
|
||||||
|
|
||||||
|
const loadBookmarks = async () => {
|
||||||
|
const resp = await api.getUserData('workflows/.index.json')
|
||||||
|
if (resp.status === 200) {
|
||||||
|
const info = await resp.json()
|
||||||
|
bookmarks.value = new Set(info?.favorites ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBookmarks = async () => {
|
||||||
|
await api.storeUserData('workflows/.index.json', {
|
||||||
|
favorites: Array.from(bookmarks.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setBookmarked = (path: string, value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
bookmarks.value.add(path)
|
||||||
|
} else {
|
||||||
|
bookmarks.value.delete(path)
|
||||||
|
}
|
||||||
|
saveBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleBookmarked = (path: string) => {
|
||||||
|
setBookmarked(path, !bookmarks.value.has(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isBookmarked,
|
||||||
|
loadBookmarks,
|
||||||
|
saveBookmarks,
|
||||||
|
setBookmarked,
|
||||||
|
toggleBookmarked
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user