diff --git a/browser_tests/ComfyPage.ts b/browser_tests/ComfyPage.ts index 324133eb5e..d481d596d8 100644 --- a/browser_tests/ComfyPage.ts +++ b/browser_tests/ComfyPage.ts @@ -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 { expect } from '@playwright/test' import { ComfyAppMenu } from './helpers/appMenu' @@ -97,9 +98,11 @@ class ComfyNodeSearchBox { } } -class NodeLibrarySidebarTab { - public readonly tabId: string = 'node-library' - constructor(public readonly page: Page) {} +class SidebarTab { + constructor( + public readonly page: Page, + public readonly tabId: string + ) {} get tabButton() { 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() { return this.page.locator('.node-lib-search-box input[type="text"]') } @@ -132,11 +148,7 @@ class NodeLibrarySidebarTab { } async open() { - if (await this.selectedTabButton.isVisible()) { - return - } - - await this.tabButton.click() + await super.open() 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 { public readonly sideToolbar: Locator public readonly themeToggleButton: Locator @@ -198,6 +249,10 @@ class ComfyMenu { return new NodeLibrarySidebarTab(this.page) } + get workflowsTab() { + return new WorkflowsSidebarTab(this.page) + } + async toggleTheme() { await this.themeToggleButton.click() await this.page.evaluate(() => { @@ -222,6 +277,10 @@ class ComfyMenu { } } +type FolderStructure = { + [key: string]: FolderStructure | string +} + export class ComfyPage { public readonly url: string // All canvas position operations are based on default view of canvas. @@ -240,7 +299,10 @@ export class ComfyPage { public readonly menu: ComfyMenu 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.canvas = page.locator('#graph-canvas') this.widgetTextBox = page.getByPlaceholder('text').nth(1) @@ -252,13 +314,46 @@ export class ComfyPage { 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 { return await this.page.evaluate(() => { 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.page.evaluate(() => { localStorage.clear() @@ -285,9 +380,11 @@ export class ComfyPage { window['app']['canvas'].show_info = false }) await this.nextFrame() - // Reset view to force re-rendering of canvas. So that info fields like fps - // become hidden. - await this.resetView() + if (resetView) { + // Reset view to force re-rendering of canvas. So that info fields like fps + // become hidden. + await this.resetView() + } // Hide all badges by default. await this.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.None) @@ -630,6 +727,11 @@ export class ComfyPage { await this.nextFrame() } + async closeDialog() { + await this.page.locator('.p-dialog-close-button').click() + await expect(this.page.locator('.p-dialog')).toBeHidden() + } + async resizeNode( nodePos: Position, nodeSize: Size, @@ -903,8 +1005,8 @@ class NodeReference { } export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({ - comfyPage: async ({ page }, use) => { - const comfyPage = new ComfyPage(page) + comfyPage: async ({ page, request }, use) => { + const comfyPage = new ComfyPage(page, request) await comfyPage.setup() await use(comfyPage) } diff --git a/browser_tests/assets/default.json b/browser_tests/assets/default.json new file mode 100644 index 0000000000..b7dde5997c --- /dev/null +++ b/browser_tests/assets/default.json @@ -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 +} diff --git a/browser_tests/browserTabTitle.spec.ts b/browser_tests/browserTabTitle.spec.ts index d8944cd03a..0d28899746 100644 --- a/browser_tests/browserTabTitle.spec.ts +++ b/browser_tests/browserTabTitle.spec.ts @@ -19,7 +19,9 @@ test.describe('Browser tab title', () => { 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 }) => { const workflowName = await comfyPage.page.evaluate(async () => { diff --git a/browser_tests/dialog.spec.ts b/browser_tests/dialog.spec.ts index a74264f100..7c56a1859c 100644 --- a/browser_tests/dialog.spec.ts +++ b/browser_tests/dialog.spec.ts @@ -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 }) => { + await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default') + await comfyPage.loadWorkflow('missing_nodes') - await comfyPage.page.locator('.p-dialog-close-button').click() - await comfyPage.nextFrame() + await comfyPage.closeDialog() // Make a change to the graph - await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default') - await comfyPage.page.waitForTimeout(256) await comfyPage.doubleClickCanvas() await comfyPage.searchBox.fillAndSelectFirstNode('KSampler') diff --git a/browser_tests/menu.spec.ts b/browser_tests/menu.spec.ts index 088cb16152..acab00aefe 100644 --- a/browser_tests/menu.spec.ts +++ b/browser_tests/menu.spec.ts @@ -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 }) => { const [defaultSpeed, maxSpeed] = [1.1, 2.5] expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe( diff --git a/src/App.vue b/src/App.vue index f6761f3cbe..d7e05c6a32 100644 --- a/src/App.vue +++ b/src/App.vue @@ -33,7 +33,7 @@ import type { ToastMessageOptions } from 'primevue/toast' import { useToast } from 'primevue/usetoast' import { i18n } from '@/i18n' import { useExecutionStore } from '@/stores/executionStore' -import { useWorkflowStore } from '@/stores/workflowStore' +import { useWorkflowBookmarkStore, useWorkflowStore } from '@/stores/workflowStore' import BlockUI from 'primevue/blockui' import ProgressSpinner from 'primevue/progressspinner' 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 BrowserTabTitle from '@/components/BrowserTabTitle.vue' import AppMenu from '@/components/appMenu/AppMenu.vue' +import WorkflowsSidebarTab from './components/sidebar/tabs/WorkflowsSidebarTab.vue' +import { setupAutoQueueHandler } from './services/autoQueueService' const isLoading = computed(() => useWorkspaceStore().spinner) @@ -52,6 +54,7 @@ const settingStore = useSettingStore() const queuePendingTaskCountStore = useQueuePendingTaskCountStore() const executionStore = useExecutionStore() const workflowStore = useWorkflowStore() +const workflowBookmarkStore = useWorkflowBookmarkStore() const theme = computed(() => settingStore.get('Comfy.ColorPalette')) @@ -117,6 +120,18 @@ const init = () => { component: markRaw(NodeLibrarySidebarTab), 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) => { @@ -143,10 +158,8 @@ const onReconnected = () => { } app.workflowManager.executionStore = executionStore -watchEffect(() => { - app.menu.workflows.buttonProgress.style.width = `${executionStore.executionProgress}%` -}) app.workflowManager.workflowStore = workflowStore +app.workflowManager.workflowBookmarkStore = workflowBookmarkStore onMounted(() => { window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version diff --git a/src/components/BrowserTabTitle.vue b/src/components/BrowserTabTitle.vue index 55e4e852f5..9ffc29d66a 100644 --- a/src/components/BrowserTabTitle.vue +++ b/src/components/BrowserTabTitle.vue @@ -24,7 +24,7 @@ const betaMenuEnabled = computed( const workflowStore = useWorkflowStore() const isUnsavedText = computed(() => - workflowStore.previousWorkflowUnsaved ? ' *' : '' + workflowStore.activeWorkflow?.unsaved ? ' *' : '' ) const workflowNameText = computed(() => { const workflowName = workflowStore.activeWorkflow?.name diff --git a/src/components/common/TextDivider.vue b/src/components/common/TextDivider.vue new file mode 100644 index 0000000000..092bdae3d2 --- /dev/null +++ b/src/components/common/TextDivider.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/sidebar/tabs/WorkflowsSidebarTab.vue b/src/components/sidebar/tabs/WorkflowsSidebarTab.vue new file mode 100644 index 0000000000..cdd602b7c8 --- /dev/null +++ b/src/components/sidebar/tabs/WorkflowsSidebarTab.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/src/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue b/src/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue new file mode 100644 index 0000000000..ede1faee7c --- /dev/null +++ b/src/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/i18n.ts b/src/i18n.ts index 9e2cfb07d5..dd4d3c2e0c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -2,6 +2,7 @@ import { createI18n } from 'vue-i18n' const messages = { en: { + insert: 'Insert', systemInfo: 'System Info', devices: 'Devices', about: 'About', @@ -35,6 +36,7 @@ const messages = { loadWorkflow: 'Load Workflow', goToNode: 'Go to Node', settings: 'Settings', + searchWorkflows: 'Search Workflows', searchSettings: 'Search Settings', searchNodes: 'Search Nodes', noResultsFound: 'No Results Found', @@ -47,6 +49,7 @@ const messages = { themeToggle: 'Toggle Theme', queue: 'Queue', nodeLibrary: 'Node Library', + workflows: 'Workflows', nodeLibraryTab: { sortOrder: 'Sort Order' }, @@ -101,6 +104,7 @@ const messages = { customize: '定制', loadWorkflow: '加载工作流', settings: '设置', + searchWorkflows: '搜索工作流', searchSettings: '搜索设置', searchNodes: '搜索节点', noResultsFound: '未找到结果', @@ -113,6 +117,7 @@ const messages = { themeToggle: '主题切换', queue: '队列', nodeLibrary: '节点库', + workflows: '工作流', nodeLibraryTab: { sortOrder: '排序顺序' }, diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 7daece1e59..cd8a99a62a 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -1738,12 +1738,6 @@ export class ComfyApp { } }) ) - - try { - this.menu.workflows.registerExtension(this) - } catch (error) { - console.error(error) - } } async #migrateSettings() { diff --git a/src/scripts/ui/menu/index.ts b/src/scripts/ui/menu/index.ts index 7181cbd523..7b28853d28 100644 --- a/src/scripts/ui/menu/index.ts +++ b/src/scripts/ui/menu/index.ts @@ -6,7 +6,6 @@ import { ComfyButton } from '../components/button' import { ComfyButtonGroup } from '../components/buttonGroup' import { ComfySplitButton } from '../components/splitButton' import { ComfyQueueButton } from './queueButton' -import { ComfyWorkflowsMenu } from './workflows' import { getInterruptButton } from './interruptButton' import './menu.css' import type { ComfySettingsDialog } from '../settings' @@ -34,7 +33,6 @@ export class ComfyAppMenu { #cachedInnerSize = null #cacheTimeout = null app: ComfyApp - workflows: ComfyWorkflowsMenu logo: HTMLElement saveButton: ComfySplitButton actionsGroup: ComfyButtonGroup @@ -48,8 +46,6 @@ export class ComfyAppMenu { constructor(app: ComfyApp) { this.app = app - - this.workflows = new ComfyWorkflowsMenu(app) const getSaveButton = (t?: string) => new ComfyButton({ icon: 'content-save', @@ -145,7 +141,6 @@ export class ComfyAppMenu { this.element = $el('nav.comfyui-menu.lg', { style: { display: 'none' } }, [ this.logo, - this.workflows.element, this.saveButton.element, collapseOnMobile(this.actionsGroup).element, $el('section.comfyui-menu-push'), diff --git a/src/scripts/ui/menu/menu.css b/src/scripts/ui/menu/menu.css index b62a6b0926..ef0998a74e 100644 --- a/src/scripts/ui/menu/menu.css +++ b/src/scripts/ui/menu/menu.css @@ -1,12 +1,7 @@ :root { --comfy-floating-menu-height: 45px; } -.relative { - position: relative; -} -.hidden { - display: none !important; -} + .mdi.rotate270::before { transform: rotate(270deg); } @@ -148,6 +143,7 @@ overflow: auto; max-height: 90vh; } + .comfyui-menu.floating { width: max-content; padding: 8px 0 8px 12px; @@ -215,378 +211,6 @@ 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 */ .comfy-widget-selection-dialog { border: none; diff --git a/src/scripts/ui/menu/workflows.ts b/src/scripts/ui/menu/workflows.ts deleted file mode 100644 index 6a091c9d0e..0000000000 --- a/src/scripts/ui/menu/workflows.ts +++ /dev/null @@ -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 = {} - openFiles: Map> = new Map() - activeElement: WorkflowElement = 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 { - 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' - ) - ]) - }) - ) - ]) - ) - } -} diff --git a/src/scripts/workflows.ts b/src/scripts/workflows.ts index 072e5958f2..165762a742 100644 --- a/src/scripts/workflows.ts +++ b/src/scripts/workflows.ts @@ -2,16 +2,21 @@ import type { ComfyApp } from './app' import { api } from './api' import { ChangeTracker } from './changeTracker' import { ComfyAsyncDialog } from './ui/components/asyncDialog' -import { getStorageValue, setStorageValue } from './utils' +import { setStorageValue } from './utils' import { LGraphCanvas, LGraph } from '@comfyorg/litegraph' import { appendJsonExt, trimJsonExt } from '@/utils/formatUtil' -import { useWorkflowStore } from '@/stores/workflowStore' +import { + useWorkflowStore, + useWorkflowBookmarkStore +} from '@/stores/workflowStore' import { useExecutionStore } from '@/stores/executionStore' import { markRaw, toRaw } from 'vue' +import { UserDataFullInfo } from '@/types/apiTypes' export class ComfyWorkflowManager extends EventTarget { executionStore: ReturnType | null workflowStore: ReturnType | null + workflowBookmarkStore: ReturnType | null app: ComfyApp #unsavedCount = 0 @@ -30,12 +35,12 @@ export class ComfyWorkflowManager extends EventTarget { get _activeWorkflow(): ComfyWorkflow | 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) { if (!this.app.vueAppReady) return - useWorkflowStore().activeWorkflow = workflow ? workflow : null + this.workflowStore!.activeWorkflow = workflow ? workflow : null } get activeWorkflow(): ComfyWorkflow | null { @@ -58,55 +63,32 @@ export class ComfyWorkflowManager extends EventTarget { async loadWorkflows() { try { - let favorites - const resp = await api.getUserData('workflows/.index.json') - let info - if (resp.status === 200) { - info = await resp.json() - favorites = new Set(info?.favorites ?? []) - } else { - favorites = new Set() - } + const [files, _] = await Promise.all([ + api.listUserDataFullInfo('workflows'), + this.workflowBookmarkStore?.loadBookmarks() + ]) - ;(await api.listUserData('workflows', true, true)).forEach( - (w: string[]) => { - let workflow = this.workflowLookup[w[0]] - if (!workflow) { - workflow = new ComfyWorkflow( - this, - w[0], - w.slice(1), - favorites.has(w[0]) - ) - this.workflowLookup[workflow.path] = workflow - } + files.forEach((file: UserDataFullInfo) => { + let workflow = this.workflowLookup[file.path] + if (!workflow) { + workflow = new ComfyWorkflow(this, file.path, file.path.split('/')) + this.workflowLookup[workflow.path] = workflow } - ) + }) } catch (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 */ setWorkflow(workflow) { if (workflow && typeof workflow === 'string') { - // Selected by path, i.e. on reload of last workflow const found = this.workflows.find((w) => w.path === workflow) if (found) { workflow = found - workflow.unsaved = - !workflow || - getStorageValue('Comfy.PreviousWorkflowUnsaved') === 'true' + workflow.unsaved = !workflow } } @@ -118,12 +100,12 @@ export class ComfyWorkflowManager extends EventTarget { 'Unsaved Workflow' + (this.#unsavedCount++ ? ` (${this.#unsavedCount})` : '') ) + this.workflowLookup[workflow.key] = workflow } - const index = this.openWorkflows.indexOf(workflow) - if (index === -1) { + if (!workflow.isOpen) { // Opening a new workflow - this.openWorkflows.push(workflow) + workflow.track() } this._activeWorkflow = workflow @@ -140,10 +122,7 @@ export class ComfyWorkflowManager extends EventTarget { }) } - /** - * @param {ComfyWorkflow} workflow - */ - async closeWorkflow(workflow, warnIfUnsaved = true) { + async closeWorkflow(workflow: ComfyWorkflow, warnIfUnsaved: boolean = true) { if (!workflow.isOpen) { return true } @@ -172,8 +151,8 @@ export class ComfyWorkflowManager extends EventTarget { } } workflow.changeTracker = null - this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1) - if (this.openWorkflows.length) { + workflow.isOpen = false + if (this.openWorkflows.length > 0) { this._activeWorkflow = this.openWorkflows[0] await this._activeWorkflow.load() } else { @@ -185,27 +164,45 @@ export class ComfyWorkflowManager extends EventTarget { export class ComfyWorkflow { name: string - path: string - pathParts: string[] - isFavorite = false - changeTracker: ChangeTracker | null = null + path: string | null + pathParts: string[] | null unsaved = false + // Raw manager: ComfyWorkflowManager + changeTracker: ChangeTracker | null = null + isOpen: boolean = false - get isOpen() { - return !!this.changeTracker + get isTemporary() { + 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( manager: ComfyWorkflowManager, path: string, - pathParts?: string[], - isFavorite?: boolean + pathParts?: string[] ) { this.manager = markRaw(manager) if (pathParts) { this.updatePath(path, pathParts) - this.isFavorite = isFavorite } else { this.name = path this.unsaved = true @@ -238,7 +235,7 @@ export class ComfyWorkflow { return await resp.json() } - load = async () => { + async load() { if (this.isOpen) { await this.manager.app.loadGraphData( this.changeTracker.activeState, @@ -258,18 +255,17 @@ export class ComfyWorkflow { } async save(saveAs = false) { - if (!this.path || saveAs) { - return !!(await this.#save(null, false)) - } else { - return !!(await this.#save(this.path, true)) - } + const createNewFile = !this.path || saveAs + return !!(await this._save( + createNewFile ? null : this.path, + /* overwrite */ !createNewFile + )) } async favorite(value: boolean) { try { - if (this.isFavorite === value) return - this.isFavorite = value - await this.manager.saveWorkflowMetadata() + if (this.isBookmarked === value) return + this.manager.workflowBookmarkStore?.setBookmarked(this.path, value) this.manager.dispatchEvent(new CustomEvent('favorite', { detail: this })) } catch (error) { alert( @@ -365,9 +361,10 @@ export class ComfyWorkflow { } else { 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) { path = prompt( 'Save workflow as:', diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 7dd7dd7619..0baa88c043 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -1,23 +1,96 @@ import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, ref, watch } from 'vue' 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', () => { const activeWorkflow = ref(null) - const previousWorkflowUnsaved = ref( - Boolean(getStorageValue('Comfy.PreviousWorkflowUnsaved')) - ) - const workflowLookup = ref>({}) const workflows = computed(() => Object.values(workflowLookup.value)) - const openWorkflows = ref([]) + 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 { activeWorkflow, - previousWorkflowUnsaved, workflows, openWorkflows, - workflowLookup + bookmarkedWorkflows, + modifiedWorkflows, + workflowLookup, + workflowsTree, + bookmarkedWorkflowsTree, + openWorkflowsTree, + buildWorkflowTree + } +}) + +export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => { + const bookmarks = ref>(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 } })