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:
Chenlei Hu
2024-09-21 18:19:36 +09:00
parent 4ae066c57d
commit f4d4cc3439
17 changed files with 767 additions and 1348 deletions

View File

@@ -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<number> {
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)
}

View 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
}

View File

@@ -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 () => {

View File

@@ -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')

View File

@@ -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(

View File

@@ -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<boolean>(() => useWorkspaceStore().spinner)
@@ -52,6 +54,7 @@ const settingStore = useSettingStore()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const executionStore = useExecutionStore()
const workflowStore = useWorkflowStore()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const theme = computed<string>(() => 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<StatusWsMessageStatus>) => {
@@ -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

View File

@@ -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

View 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>

View 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>

View 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>

View File

@@ -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: '排序顺序'
},

View File

@@ -1738,12 +1738,6 @@ export class ComfyApp {
}
})
)
try {
this.menu.workflows.registerExtension(this)
} catch (error) {
console.error(error)
}
}
async #migrateSettings() {

View File

@@ -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'),

View File

@@ -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;

View File

@@ -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'
)
])
})
)
])
)
}
}

View File

@@ -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<typeof useExecutionStore> | null
workflowStore: ReturnType<typeof useWorkflowStore> | null
workflowBookmarkStore: ReturnType<typeof useWorkflowBookmarkStore> | 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:',

View File

@@ -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<ComfyWorkflow | null>(null)
const previousWorkflowUnsaved = ref<boolean>(
Boolean(getStorageValue('Comfy.PreviousWorkflowUnsaved'))
)
const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
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 {
activeWorkflow,
previousWorkflowUnsaved,
workflows,
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
}
})