Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
a2c8f4b3cc fix(litegraph): Resolve workflow corruption from subgraph serialization
This commit fixes a critical issue where saving a workflow with complex subgraphs could lead to a corrupted file, causing errors upon loading.

The root cause was twofold:

1.  **Unsafe Object Cloning:** The `LiteGraph.cloneObject` function used `JSON.stringify`, which cannot handle circular references that can exist in a nodes properties, especially in complex graphs with promoted widgets. This has been replaced with `_.cloneDeep` from lodash for robust and safe deep cloning.

2.  **Unsafe Node Configuration:** The `LGraphNode.configure` method used a generic loop that would overwrite the `inputs` and `outputs` arrays with plain objects from the serialized data. This created a temporary but dangerous state where class instances were replaced by plain objects, leading to `TypeError` exceptions when methods or private fields were accessed on them. The `configure` method has been refactored to handle `inputs`, `outputs`, and `properties` explicitly and safely, ensuring they are always proper class instances.

This fix makes the node loading process more robust and predictable, preventing data corruption and ensuring the stability of workflows with subgraphs.
2025-08-12 11:55:00 -07:00
59 changed files with 146 additions and 1618 deletions

View File

@@ -19,7 +19,7 @@ jobs:
should-proceed: ${{ steps.check-status.outputs.proceed }}
steps:
- name: Wait for other CI checks
uses: lewagon/wait-on-check-action@e106e5c43e8ca1edea6383a39a01c5ca495fd812
uses: lewagon/wait-on-check-action@v1.3.1
with:
ref: ${{ github.event.pull_request.head.sha }}
check-regexp: '^(lint-and-format|test|playwright-tests)'

View File

@@ -60,7 +60,7 @@ jobs:
strategy:
fail-fast: false
matrix:
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
browser: [chromium, chromium-2x, mobile-chrome]
steps:
- name: Wait for cache propagation
run: sleep 10

View File

@@ -75,7 +75,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'

1
.gitignore vendored
View File

@@ -41,7 +41,6 @@ tests-ui/workflows/examples
/blob-report/
/playwright/.cache/
browser_tests/**/*-win32.png
browser_tests/**/*-darwin.png
.env

View File

@@ -767,8 +767,8 @@ export class ComfyPage {
await this.nextFrame()
}
async rightClickCanvas(x: number = 10, y: number = 10) {
await this.page.mouse.click(x, y, { button: 'right' })
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
}

View File

@@ -1,161 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Command search box', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
})
test('Can trigger command mode with ">" prefix', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Type ">" to enter command mode
await comfyPage.searchBox.input.fill('>')
// Verify filter button is hidden in command mode
const filterButton = comfyPage.page.locator('.filter-button')
await expect(filterButton).not.toBeVisible()
// Verify placeholder text changes
await expect(comfyPage.searchBox.input).toHaveAttribute(
'placeholder',
expect.stringContaining('Search Commands')
)
})
test('Shows command list when entering command mode', async ({
comfyPage
}) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>')
// Wait for dropdown to appear
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
// Check that commands are shown
const firstItem = comfyPage.searchBox.dropdown.locator('li').first()
await expect(firstItem).toBeVisible()
// Verify it shows a command item with icon
const commandIcon = firstItem.locator('.item-icon')
await expect(commandIcon).toBeVisible()
})
test('Can search and filter commands', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>save')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500) // Wait for search to complete
// Get all visible command items
const items = comfyPage.searchBox.dropdown.locator('li')
const count = await items.count()
// Should have filtered results
expect(count).toBeGreaterThan(0)
expect(count).toBeLessThan(10) // Should be filtered, not showing all
// Verify first result contains "save"
const firstLabel = await items.first().locator('.item-label').textContent()
expect(firstLabel?.toLowerCase()).toContain('save')
})
test('Shows keybindings for commands that have them', async ({
comfyPage
}) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>undo')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Find the undo command
const undoItem = comfyPage.searchBox.dropdown
.locator('li')
.filter({ hasText: 'Undo' })
.first()
// Check if keybinding is shown (if configured)
const keybinding = undoItem.locator('.item-keybinding')
const keybindingCount = await keybinding.count()
// Keybinding might or might not be present depending on configuration
if (keybindingCount > 0) {
await expect(keybinding).toBeVisible()
}
})
test('Executes command on selection', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.fill('>new blank')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Count nodes before
const nodesBefore = await comfyPage.page
.locator('.litegraph.litenode')
.count()
// Select the new blank workflow command
const newBlankItem = comfyPage.searchBox.dropdown
.locator('li')
.filter({ hasText: 'New Blank Workflow' })
.first()
await newBlankItem.click()
// Search box should close
await expect(comfyPage.searchBox.input).not.toBeVisible()
// Verify workflow was cleared (no nodes)
const nodesAfter = await comfyPage.page
.locator('.litegraph.litenode')
.count()
expect(nodesAfter).toBe(0)
})
test('Returns to node search when removing ">"', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
// Enter command mode
await comfyPage.searchBox.input.fill('>')
await expect(comfyPage.page.locator('.filter-button')).not.toBeVisible()
// Return to node search by filling with empty string to trigger search
await comfyPage.searchBox.input.fill('')
// Small wait for UI update
await comfyPage.page.waitForTimeout(200)
// Filter button should be visible again
await expect(comfyPage.page.locator('.filter-button')).toBeVisible()
// Placeholder should change back
await expect(comfyPage.searchBox.input).toHaveAttribute(
'placeholder',
expect.stringContaining('Search Nodes')
)
})
test('Command search is case insensitive', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
// Search with lowercase
await comfyPage.searchBox.input.fill('>SAVE')
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await comfyPage.page.waitForTimeout(500)
// Should find save commands
const items = comfyPage.searchBox.dropdown.locator('li')
const count = await items.count()
expect(count).toBeGreaterThan(0)
// Verify it found save-related commands
const firstLabel = await items.first().locator('.item-label').textContent()
expect(firstLabel?.toLowerCase()).toContain('save')
})
})

View File

@@ -1,4 +1,4 @@
import { Locator, expect } from '@playwright/test'
import { expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
@@ -767,17 +767,6 @@ test.describe('Viewport settings', () => {
comfyPage,
comfyMouse
}) => {
const changeTab = async (tab: Locator) => {
await tab.click()
await comfyPage.nextFrame()
await comfyMouse.move(comfyPage.emptySpace)
// If tooltip is visible, wait for it to hide
await expect(
comfyPage.page.locator('.workflow-popover-fade')
).toHaveCount(0)
}
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
@@ -805,13 +794,15 @@ test.describe('Viewport settings', () => {
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
// Go back to Workflow A
await changeTab(tabA)
await tabA.click()
await comfyPage.nextFrame()
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotA
)
// And back to Workflow B
await changeTab(tabB)
await tabB.click()
await comfyPage.nextFrame()
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotB
)

View File

@@ -48,9 +48,7 @@ test.describe('LiteGraph Native Reroute Node', () => {
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
})
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
comfyPage
}) => {
test('Can add reroute by alt clicking on link', async ({ comfyPage }) => {
const loadCheckpointNode = (
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
)[0]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -372,68 +372,6 @@ test.describe('Subgraph Operations', () => {
const deletedNode = await comfyPage.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect a second subgraph node to be created (2 total)
expect(subgraphNodes.length).toBe(2)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes and expect all unique IDs
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect the second subgraph node to have a unique type
const nodeType1 = await subgraphNodes[0].getType()
const nodeType2 = await subgraphNodes[1].getType()
expect(nodeType1).not.toBe(nodeType2)
})
})
})
test.describe('Operations Inside Subgraphs', () => {

View File

@@ -1,155 +0,0 @@
import { expect } from '@playwright/test'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Workflow Tab Thumbnails', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
await comfyPage.setup()
})
async function getTab(comfyPage: ComfyPage, index: number) {
const tab = comfyPage.page
.locator(`.workflow-tabs .p-togglebutton`)
.nth(index)
return tab
}
async function getTabPopover(
comfyPage: ComfyPage,
index: number,
name?: string
) {
const tab = await getTab(comfyPage, index)
await tab.hover()
const popover = comfyPage.page.locator('.workflow-popover-fade')
await expect(popover).toHaveCount(1)
await expect(popover).toBeVisible({ timeout: 500 })
if (name) {
await expect(popover).toContainText(name)
}
return popover
}
async function getTabThumbnailImage(
comfyPage: ComfyPage,
index: number,
name?: string
) {
const popover = await getTabPopover(comfyPage, index, name)
const thumbnailImg = popover.locator('.workflow-preview-thumbnail img')
return thumbnailImg
}
async function getNodeThumbnailBase64(comfyPage: ComfyPage, index: number) {
const thumbnailImg = await getTabThumbnailImage(comfyPage, index)
const src = (await thumbnailImg.getAttribute('src'))!
// Convert blob to base64, need to execute a script to get the base64
const base64 = await comfyPage.page.evaluate(async (src: string) => {
const blob = await fetch(src).then((res) => res.blob())
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}, src)
return base64
}
test('Should show thumbnail when hovering over a non-active tab', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
0,
'Unsaved Workflow'
)
await expect(thumbnailImg).toBeVisible()
})
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
1,
'Unsaved Workflow (2)'
)
await expect(thumbnailImg).not.toBeVisible()
})
async function addNode(comfyPage: ComfyPage, category: string, node: string) {
const canvasArea = await comfyPage.canvas.boundingBox()
await comfyPage.page.mouse.move(
canvasArea!.x + canvasArea!.width - 100,
100
)
await comfyPage.delay(300) // Wait for the popover to hide
await comfyPage.rightClickCanvas(200, 200)
await comfyPage.page.getByText('Add Node').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText(category).click()
await comfyPage.nextFrame()
await comfyPage.page.getByText(node).click()
await comfyPage.nextFrame()
}
test('Thumbnail should update when switching tabs', async ({ comfyPage }) => {
// Wait for initial workflow to load
await comfyPage.nextFrame()
// Create a new workflow (tab 1) which will be empty
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.nextFrame()
// Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty)
// Tab 1 is currently active, so we can only get thumbnail for tab 0
// Step 1: Different tabs should show different previews
const tab0ThumbnailWithNodes = await getNodeThumbnailBase64(comfyPage, 0)
// Add a node to tab 1 (current active tab)
await addNode(comfyPage, 'loaders', 'Load Checkpoint')
await comfyPage.nextFrame()
// Switch to tab 0 so we can get tab 1's thumbnail
await (await getTab(comfyPage, 0)).click()
await comfyPage.nextFrame()
const tab1ThumbnailWithNode = await getNodeThumbnailBase64(comfyPage, 1)
// The thumbnails should be different
expect(tab0ThumbnailWithNodes).not.toBe(tab1ThumbnailWithNode)
// Step 2: Switching without changes shouldn't update thumbnail
const tab1ThumbnailBefore = await getNodeThumbnailBase64(comfyPage, 1)
// Switch to tab 1 and back to tab 0 without making changes
await (await getTab(comfyPage, 1)).click()
await comfyPage.nextFrame()
await (await getTab(comfyPage, 0)).click()
await comfyPage.nextFrame()
const tab1ThumbnailAfter = await getNodeThumbnailBase64(comfyPage, 1)
expect(tab1ThumbnailBefore).toBe(tab1ThumbnailAfter)
// Step 3: Adding another node should cause thumbnail to change
// We're on tab 0, add a node
await addNode(comfyPage, 'loaders', 'Load VAE')
await comfyPage.nextFrame()
// Switch to tab 1 and back to update tab 0's thumbnail
await (await getTab(comfyPage, 1)).click()
const tab0ThumbnailAfterNewNode = await getNodeThumbnailBase64(comfyPage, 0)
// The thumbnail should have changed after adding a node
expect(tab0ThumbnailWithNodes).not.toBe(tab0ThumbnailAfterNewNode)
})
})

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.1",
"version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.1",
"version": "1.26.0",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.26.1",
"version": "1.26.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -49,13 +49,6 @@ export default defineConfig({
grep: /@2x/ // Run all tests tagged with @2x
},
{
name: 'chromium-0.5x',
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 0.5 },
timeout: 15000,
grep: /@0.5x/ // Run all tests tagged with @0.5x
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },

View File

@@ -20,7 +20,7 @@
>
<div class="shortcut-info flex-grow pr-4">
<div class="shortcut-name text-sm font-medium">
{{ command.getTranslatedLabel() }}
{{ command.label || command.id }}
</div>
</div>

View File

@@ -139,6 +139,7 @@ import Message from 'primevue/message'
import Tag from 'primevue/tag'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import { useKeybindingService } from '@/services/keybindingService'
@@ -148,6 +149,7 @@ import {
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
@@ -159,6 +161,7 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const commandStore = useCommandStore()
const { t } = useI18n()
interface ICommandData {
id: string
@@ -170,7 +173,10 @@ interface ICommandData {
const commandsData = computed<ICommandData[]>(() => {
return Object.values(commandStore.commands).map((command) => ({
id: command.id,
label: command.getTranslatedLabel(),
label: t(
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
source: command.source
}))

View File

@@ -188,13 +188,16 @@ const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
const moreItems = computed<MenuItem[]>(() => {
const allMoreItems: MenuItem[] = [
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
const menuItems = computed<MenuItem[]>(() => {
const moreItems: MenuItem[] = [
{
key: 'desktop-guide',
type: 'item',
label: t('helpCenter.desktopUserGuide'),
visible: isElectron(),
action: () => {
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
emit('close')
@@ -227,19 +230,6 @@ const moreItems = computed<MenuItem[]>(() => {
}
]
// Filter for visible items only
return allMoreItems.filter((item) => item.visible !== false)
})
const hasVisibleMoreItems = computed(() => {
return !!moreItems.value.length
})
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
const menuItems = computed<MenuItem[]>(() => {
return [
{
key: 'docs',
@@ -286,9 +276,8 @@ const menuItems = computed<MenuItem[]>(() => {
type: 'item',
icon: '',
label: t('helpCenter.more'),
visible: hasVisibleMoreItems.value,
action: () => {}, // No action for more item
items: moreItems.value
items: moreItems
}
]
})

View File

@@ -1,50 +0,0 @@
<template>
<div class="flex items-center gap-3 px-3 py-2 w-full">
<span
class="flex-shrink-0 w-5 text-center text-muted item-icon"
:class="command.icon ?? 'pi pi-chevron-right'"
/>
<span
class="flex-grow overflow-hidden text-ellipsis whitespace-nowrap item-label"
>
<span
v-html="highlightQuery(command.getTranslatedLabel(), currentQuery)"
/>
</span>
<span
v-if="command.keybinding"
class="flex-shrink-0 text-xs px-1.5 py-0.5 border rounded font-mono keybinding-badge"
>
{{ command.keybinding.combo.toString() }}
</span>
</div>
</template>
<script setup lang="ts">
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { highlightQuery } from '@/utils/formatUtil'
const { command, currentQuery } = defineProps<{
command: ComfyCommandImpl
currentQuery: string
}>()
</script>
<style scoped>
:deep(.highlight) {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
.keybinding-badge {
border-color: var(--p-content-border-color);
background-color: var(--p-content-hover-background);
color: var(--p-text-muted-color);
}
</style>

View File

@@ -3,7 +3,7 @@
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96"
>
<div
v-if="enableNodePreview && !isCommandMode"
v-if="enableNodePreview"
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
>
<NodePreview
@@ -14,7 +14,6 @@
</div>
<Button
v-if="!isCommandMode"
icon="pi pi-filter"
severity="secondary"
class="filter-button z-10"
@@ -50,24 +49,13 @@
auto-option-focus
force-selection
multiple
:option-label="getOptionLabel"
:option-label="'display_name'"
@complete="search($event.query)"
@option-select="onOptionSelect($event.value)"
@option-select="emit('addNode', $event.value)"
@focused-option-changed="setHoverSuggestion($event)"
@input="handleInput"
>
<template #option="{ option }">
<!-- Command search item, Remove the '>' prefix from the query -->
<CommandSearchItem
v-if="isCommandMode"
:command="option"
:current-query="currentQuery.substring(1)"
/>
<NodeSearchItem
v-else
:node-def="option"
:current-query="currentQuery"
/>
<NodeSearchItem :node-def="option" :current-query="currentQuery" />
</template>
<!-- FilterAndValue -->
<template #chip="{ value }">
@@ -92,16 +80,13 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NodePreview from '@/components/node/NodePreview.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import CommandSearchItem from '@/components/searchbox/CommandSearchItem.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { CommandSearchService } from '@/services/commandSearchService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import {
ComfyNodeDefImpl,
useNodeDefStore,
@@ -114,7 +99,6 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore()
const { t } = useI18n()
const commandStore = useCommandStore()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
@@ -127,50 +111,18 @@ const { filters, searchLimit = 64 } = defineProps<{
const nodeSearchFilterVisible = ref(false)
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDefImpl[] | ComfyCommandImpl[]>([])
const suggestions = ref<ComfyNodeDefImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const currentQuery = ref('')
const isCommandMode = ref(false)
// Initialize command search service
const commandSearchService = ref<CommandSearchService | null>(null)
const placeholder = computed(() => {
if (isCommandMode.value) {
return t('g.searchCommands', 'Search commands') + '...'
}
return filters.length === 0 ? t('g.searchNodes') + '...' : ''
})
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
// Initialize command search service with commands
watch(
() => commandStore.commands,
(commands) => {
commandSearchService.value = new CommandSearchService(commands)
},
{ immediate: true }
)
const search = (query: string) => {
currentQuery.value = query
// Check if we're in command mode (query starts with ">")
if (query.startsWith('>')) {
isCommandMode.value = true
if (commandSearchService.value) {
suggestions.value = commandSearchService.value.searchCommands(query, {
limit: searchLimit
})
}
return
}
// Normal node search mode
isCommandMode.value = false
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
@@ -180,18 +132,7 @@ const search = (query: string) => {
]
}
const emit = defineEmits<{
(
e: 'addFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
(
e: 'removeFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
(e: 'addNode', nodeDef: ComfyNodeDefImpl): void
(e: 'executeCommand', command: ComfyCommandImpl): void
}>()
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
let inputElement: HTMLInputElement | null = null
const reFocusInput = async () => {
@@ -219,47 +160,11 @@ const onRemoveFilter = async (
await reFocusInput()
}
const setHoverSuggestion = (index: number) => {
if (index === -1 || isCommandMode.value) {
if (index === -1) {
hoveredSuggestion.value = null
return
}
const value = suggestions.value[index] as ComfyNodeDefImpl
const value = suggestions.value[index]
hoveredSuggestion.value = value
}
const onOptionSelect = (option: ComfyNodeDefImpl | ComfyCommandImpl) => {
if (isCommandMode.value) {
emit('executeCommand', option as ComfyCommandImpl)
} else {
emit('addNode', option as ComfyNodeDefImpl)
}
}
const getOptionLabel = (
option: ComfyNodeDefImpl | ComfyCommandImpl
): string => {
if ('display_name' in option) {
return option.display_name
}
return option.label || option.id
}
/**
* Handles direct input changes on the AutoCompletePlus component.
* This ensures search mode switching works properly when users clear the input
* or modify it directly, as the @complete event may not always trigger.
*
* @param event - The input event from the AutoCompletePlus component
* @note Known issue on empty input complete state:
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/4887
*/
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const inputValue = target.value
// Trigger search to handle mode switching between node and command search
if (inputValue === '') {
search('')
}
}
</script>

View File

@@ -26,7 +26,6 @@
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@execute-command="executeCommand"
/>
</template>
</Dialog>
@@ -47,7 +46,6 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useLitegraphService } from '@/services/litegraphService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -64,7 +62,6 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const litegraphService = useLitegraphService()
const commandStore = useCommandStore()
const { visible } = storeToRefs(useSearchBoxStore())
const dismissable = ref(true)
@@ -112,14 +109,6 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
window.requestAnimationFrame(closeDialog)
}
const executeCommand = async (command: ComfyCommandImpl) => {
// Close the dialog immediately
closeDialog()
// Execute the command
await commandStore.execute(command.id)
}
const newSearchBoxEnabled = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)

View File

@@ -1,13 +1,13 @@
<template>
<div
ref="workflowTabRef"
class="flex p-2 gap-2 workflow-tab"
v-bind="$attrs"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<span class="workflow-label text-sm max-w-[150px] truncate inline-block">
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
<span
v-tooltip.bottom="{
value: workflowOption.workflow.key,
class: 'workflow-tab-tooltip',
showDelay: 512
}"
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
>
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
@@ -22,33 +22,23 @@
/>
</div>
</div>
<WorkflowTabPopover
ref="popoverRef"
:workflow-filename="workflowOption.workflow.filename"
:thumbnail-url="thumbnailUrl"
:is-active-tab="isActiveTab"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import { useWorkflowService } from '@/services/workflowService'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
interface WorkflowOption {
value: string
workflow: ComfyWorkflow
@@ -65,8 +55,6 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
// Use computed refs to cache autosave settings
const autoSaveSetting = computed(() =>
@@ -102,27 +90,6 @@ const shouldShowStatusIndicator = computed(() => {
return false
})
const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})
// Event handlers that delegate to the popover component
const handleMouseEnter = (event: Event) => {
popoverRef.value?.showPopover(event)
}
const handleMouseLeave = () => {
popoverRef.value?.hidePopover()
}
const handleClick = (event: Event) => {
popoverRef.value?.togglePopover(event)
}
const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (
@@ -168,10 +135,6 @@ usePragmaticDroppable(tabGetter, {
}
}
})
onUnmounted(() => {
popoverRef.value?.hidePopover()
})
</script>
<style scoped>

View File

@@ -1,229 +0,0 @@
<template>
<div
ref="positionRef"
class="absolute left-1/2 -translate-x-1/2"
:class="positions.positioner"
></div>
<Popover
ref="popoverRef"
append-to="body"
:pt="{
root: {
class: 'workflow-popover-fade fit-content ' + positions.root,
'data-popover-id': id,
style: {
transform: positions.active
}
}
}"
@mouseenter="cancelHidePopover"
@mouseleave="hidePopover"
>
<div class="workflow-preview-content">
<div
v-if="thumbnailUrl && !isActiveTab"
class="workflow-preview-thumbnail relative"
>
<img
:src="thumbnailUrl"
class="block h-[200px] object-cover rounded-lg p-2"
:style="{ width: `${POPOVER_WIDTH}px` }"
/>
</div>
<div class="workflow-preview-footer">
<span class="workflow-preview-name">{{ workflowFilename }}</span>
</div>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, nextTick, ref, toRefs, useId } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
const POPOVER_WIDTH = 250
interface Props {
workflowFilename: string
thumbnailUrl?: string
isActiveTab: boolean
}
const props = defineProps<Props>()
const { thumbnailUrl, isActiveTab } = toRefs(props)
const settingStore = useSettingStore()
const positions = computed<{
positioner: string
root?: string
active?: string
}>(() => {
if (
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') === 'Topbar' &&
settingStore.get('Comfy.UseNewMenu') === 'Bottom'
) {
return {
positioner: 'top-0',
root: 'p-popover-flipped',
active: isActiveTab.value ? 'translateY(-100%)' : undefined
}
}
return {
positioner: 'bottom-0'
}
})
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
const positionRef = ref<HTMLElement | null>(null)
let hideTimeout: ReturnType<typeof setTimeout> | null = null
let showTimeout: ReturnType<typeof setTimeout> | null = null
const id = useId()
const showPopover = (event: Event) => {
// Clear any existing timeouts
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
// Show popover after a short delay
showTimeout = setTimeout(async () => {
if (popoverRef.value && positionRef.value) {
popoverRef.value.show(event, positionRef.value)
await nextTick()
// PrimeVue has a bug where when the tabs are scrolled, it positions the element incorrectly
// Manually set the position to the middle of the tab and prevent it from going off the left/right edge
const el = document.querySelector(
`.workflow-popover-fade[data-popover-id="${id}"]`
) as HTMLElement
if (el) {
const middle = positionRef.value!.getBoundingClientRect().left
const popoverWidth = el.getBoundingClientRect().width
const halfWidth = popoverWidth / 2
let pos = middle - halfWidth
let shift = 0
// Calculate shift when clamping is needed
if (pos < 0) {
shift = pos - 8 // Negative shift to move arrow left
pos = 8
} else if (pos + popoverWidth > window.innerWidth) {
const newPos = window.innerWidth - popoverWidth - 16
shift = pos - newPos // Positive shift to move arrow right
pos = newPos
}
if (shift + halfWidth < 0) {
shift = -halfWidth + 24
}
el.style.left = `${pos}px`
el.style.setProperty('--shift', `${shift}px`)
}
}
}, 200) // 200ms delay before showing
}
const cancelHidePopover = () => {
// Temporarily disable this functionality until we need the popover to be interactive:
/*
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
*/
}
const hidePopover = () => {
// Clear show timeout if mouse leaves before popover appears
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
hideTimeout = setTimeout(() => {
if (popoverRef.value) {
popoverRef.value.hide()
}
}, 100) // Minimal delay to allow moving to popover
}
const togglePopover = (event: Event) => {
if (popoverRef.value) {
popoverRef.value.toggle(event)
}
}
defineExpose({
showPopover,
hidePopover,
togglePopover
})
</script>
<style scoped>
.workflow-preview-content {
@apply flex flex-col rounded-xl overflow-hidden;
max-width: var(--popover-width);
background-color: var(--comfy-menu-secondary-bg);
color: var(--fg-color);
}
.workflow-preview-thumbnail {
@apply relative p-2;
}
.workflow-preview-thumbnail img {
@apply shadow-md;
background-color: color-mix(
in srgb,
var(--comfy-menu-secondary-bg) 70%,
black
);
}
.dark-theme .workflow-preview-thumbnail img {
@apply shadow-lg;
}
.workflow-preview-footer {
@apply pt-1 pb-2 px-3;
}
.workflow-preview-name {
@apply block text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap;
color: var(--fg-color);
}
</style>
<style>
.workflow-popover-fade {
--p-popover-background: transparent;
--p-popover-content-padding: 0;
@apply bg-transparent rounded-xl shadow-lg;
transition: opacity 0.15s ease-out !important;
}
.workflow-popover-fade.p-popover-flipped {
@apply -translate-y-full;
}
.dark-theme .workflow-popover-fade {
@apply shadow-2xl;
}
.workflow-popover-fade.p-popover:after,
.workflow-popover-fade.p-popover:before {
--p-popover-border-color: var(--comfy-menu-secondary-bg);
left: 50%;
transform: translateX(calc(-50% + var(--shift)));
margin-left: 0;
}
</style>

View File

@@ -696,7 +696,6 @@ export function useMinimap() {
init,
destroy,
toggle,
renderMinimap,
handlePointerDown,
handlePointerMove,
handlePointerUp,

View File

@@ -1,108 +0,0 @@
import { ref } from 'vue'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useMinimap } from './useMinimap'
// Store thumbnails for each workflow
const workflowThumbnails = ref<Map<string, string>>(new Map())
// Shared minimap instance
let minimap: ReturnType<typeof useMinimap> | null = null
export const useWorkflowThumbnail = () => {
/**
* Capture a thumbnail of the canvas
*/
const createMinimapPreview = (): Promise<string | null> => {
try {
if (!minimap) {
minimap = useMinimap()
minimap.canvasRef.value = document.createElement('canvas')
minimap.canvasRef.value.width = minimap.width
minimap.canvasRef.value.height = minimap.height
}
minimap.renderMinimap()
return new Promise((resolve) => {
minimap!.canvasRef.value!.toBlob((blob) => {
if (blob) {
resolve(URL.createObjectURL(blob))
} else {
resolve(null)
}
})
})
} catch (error) {
console.error('Failed to capture canvas thumbnail:', error)
return Promise.resolve(null)
}
}
/**
* Store a thumbnail for a workflow
*/
const storeThumbnail = async (workflow: ComfyWorkflow) => {
const thumbnail = await createMinimapPreview()
if (thumbnail) {
// Clean up existing thumbnail if it exists
const existingThumbnail = workflowThumbnails.value.get(workflow.key)
if (existingThumbnail) {
URL.revokeObjectURL(existingThumbnail)
}
workflowThumbnails.value.set(workflow.key, thumbnail)
}
}
/**
* Get a thumbnail for a workflow
*/
const getThumbnail = (workflowKey: string): string | undefined => {
return workflowThumbnails.value.get(workflowKey)
}
/**
* Clear a thumbnail for a workflow
*/
const clearThumbnail = (workflowKey: string) => {
const thumbnail = workflowThumbnails.value.get(workflowKey)
if (thumbnail) {
URL.revokeObjectURL(thumbnail)
}
workflowThumbnails.value.delete(workflowKey)
}
/**
* Clear all thumbnails
*/
const clearAllThumbnails = () => {
for (const thumbnail of workflowThumbnails.value.values()) {
URL.revokeObjectURL(thumbnail)
}
workflowThumbnails.value.clear()
}
/**
* Move a thumbnail from one workflow key to another (useful for workflow renaming)
*/
const moveWorkflowThumbnail = (oldKey: string, newKey: string) => {
// Don't do anything if moving to the same key
if (oldKey === newKey) return
const thumbnail = workflowThumbnails.value.get(oldKey)
if (thumbnail) {
workflowThumbnails.value.set(newKey, thumbnail)
workflowThumbnails.value.delete(oldKey)
}
}
return {
createMinimapPreview,
storeThumbnail,
getThumbnail,
clearThumbnail,
clearAllThumbnails,
moveWorkflowThumbnail,
workflowThumbnails
}
}

View File

@@ -1,7 +1,5 @@
import { clamp } from 'lodash'
import type { Point, Rect } from './interfaces'
import { LGraphCanvas } from './litegraph'
import { LGraphCanvas, clamp } from './litegraph'
import { distance } from './measure'
// used by some widgets to render a curve editor

View File

@@ -2299,8 +2299,6 @@ export class LGraphCanvas
const node_data = node.clone()?.serialize()
if (node_data?.type != null) {
// Ensure the cloned node is configured against the correct type (especially for SubgraphNodes)
node_data.type = newType
const cloned = LiteGraph.createNode(newType)
if (cloned) {
cloned.configure(node_data)
@@ -2386,7 +2384,6 @@ export class LGraphCanvas
// Set the width of the line for isPointInStroke checks
const { lineWidth } = this.ctx
this.ctx.lineWidth = this.connections_width + 7
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
@@ -2396,7 +2393,7 @@ export class LGraphCanvas
if (
(e.shiftKey || e.altKey) &&
linkSegment.path &&
this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
this.ctx.isPointInStroke(linkSegment.path, x, y)
) {
this.ctx.lineWidth = lineWidth
@@ -8070,6 +8067,18 @@ export class LGraphCanvas
options = node.getMenuOptions(this)
} else {
options = [
{
content: 'Inputs',
has_submenu: true,
disabled: true
},
{
content: 'Outputs',
has_submenu: true,
disabled: true,
callback: LGraphCanvas.showMenuNodeOptionalOutputs
},
null,
{
content: 'Convert to Subgraph 🆕',
callback: () => {
@@ -8237,16 +8246,14 @@ export class LGraphCanvas
'Both in put and output slots were null when processing context menu.'
)
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
menu_info.push({ content: 'Rename Slot', slot })
}
if (_slot.removable) {
menu_info.push(null)
menu_info.push(
_slot.locked ? 'Cannot remove' : { content: 'Remove Slot', slot }
)
}
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
menu_info.push({ content: 'Rename Slot', slot })
}
if (node.getExtraSlotMenuOptions) {
menu_info.push(...node.getExtraSlotMenuOptions(slot))

View File

@@ -769,12 +769,7 @@ export class LGraphNode
this.graph._version++
}
for (const j in info) {
if (j == 'properties') {
// i don't want to clone properties, I want to reuse the old container
for (const k in info.properties) {
this.properties[k] = info.properties[k]
this.onPropertyChanged?.(k, info.properties[k])
}
if (j === 'properties' || j === 'inputs' || j === 'outputs') {
continue
}
@@ -798,14 +793,28 @@ export class LGraphNode
}
}
if (info.properties) {
for (const k in info.properties) {
this.properties[k] = info.properties[k]
this.onPropertyChanged?.(k, info.properties[k])
}
}
if (!info.title) {
this.title = this.constructor.title
}
this.inputs ??= []
this.inputs = this.inputs.map((input) =>
toClass(NodeInputSlot, input, this)
)
if (info.inputs) {
this.inputs = info.inputs.map((input) =>
toClass(NodeInputSlot, input, this)
)
} else {
this.inputs ??= []
this.inputs = this.inputs.map((input) =>
toClass(NodeInputSlot, input, this)
)
}
for (const [i, input] of this.inputs.entries()) {
const link =
this.graph && input.link != null
@@ -815,10 +824,17 @@ export class LGraphNode
this.onInputAdded?.(input)
}
this.outputs ??= []
this.outputs = this.outputs.map((output) =>
toClass(NodeOutputSlot, output, this)
)
if (info.outputs) {
this.outputs = info.outputs.map((output) =>
toClass(NodeOutputSlot, output, this)
)
} else {
this.outputs ??= []
this.outputs = this.outputs.map((output) =>
toClass(NodeOutputSlot, output, this)
)
}
for (const [i, output] of this.outputs.entries()) {
if (!output.links) continue

View File

@@ -1,3 +1,5 @@
import _ from 'lodash'
import { ContextMenu } from './ContextMenu'
import { CurveEditor } from './CurveEditor'
import { DragAndScale } from './DragAndScale'
@@ -641,7 +643,7 @@ export class LiteGraphGlobal {
): WhenNullish<T, null> {
if (obj == null) return null as WhenNullish<T, null>
const r = JSON.parse(JSON.stringify(obj))
const r = _.cloneDeep(obj)
if (!target) return r
for (const i in r) {

View File

@@ -1,10 +1,9 @@
import { clamp } from 'lodash'
import type {
ReadOnlyRect,
ReadOnlySize,
Size
} from '@/lib/litegraph/src/interfaces'
import { clamp } from '@/lib/litegraph/src/litegraph'
/**
* Basic width and height, with min/max constraints.

View File

@@ -134,7 +134,7 @@ export { LGraphCanvas, type LGraphCanvasState } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'
export { LGraphNode, type NodeId } from './LGraphNode'
export { type LinkId, LLink } from './LLink'
export { createBounds } from './measure'
export { clamp, createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'
export {
type ExecutableLGraphNode,

View File

@@ -450,3 +450,7 @@ export function alignOutsideContainer(
}
return rect
}
export function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value
}

View File

@@ -9,7 +9,7 @@ import type {
CallbackReturn,
ISlotType
} from '@/lib/litegraph/src/interfaces'
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { Subgraph } from './Subgraph'
import type { SubgraphNode } from './SubgraphNode'
@@ -263,8 +263,13 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
if (this.mode === LGraphEventMode.BYPASS) {
const { inputs } = this
// Bypass nodes by finding first input with matching type
const matchingIndex = this.#getBypassSlotIndex(slot, type)
const parentInputIndexes = Object.keys(inputs).map(Number)
// Prioritise exact slot index
const indexes = [slot, ...parentInputIndexes]
const matchingIndex = indexes.find((i) => inputs[i]?.type === type)
// No input types match
if (matchingIndex === undefined) {
@@ -321,44 +326,6 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
}
}
/**
* Finds the index of the input slot on this node that matches the given output {@link slot} index.
* Used when bypassing nodes.
* @param slot The output slot index on this node
* @param type The type of the final target input (so type list matches are accurate)
* @returns The index of the input slot on this node, otherwise `undefined`.
*/
#getBypassSlotIndex(slot: number, type: ISlotType) {
const { inputs } = this
const oppositeInput = inputs[slot]
const outputType = this.node.outputs[slot].type
// Any type short circuit - match slot ID, fallback to first slot
if (type === '*' || type === '') {
return inputs.length > slot ? slot : 0
}
// Prefer input with the same slot ID
if (
oppositeInput &&
LiteGraph.isValidConnection(oppositeInput.type, outputType) &&
LiteGraph.isValidConnection(oppositeInput.type, type)
) {
return slot
}
// Find first matching slot - prefer exact type
return (
// Preserve legacy behaviour; use exact match first.
inputs.findIndex((input) => input.type === type) ??
inputs.findIndex(
(input) =>
LiteGraph.isValidConnection(input.type, outputType) &&
LiteGraph.isValidConnection(input.type, type)
)
)
}
/**
* Resolves the link inside a subgraph node, from the subgraph IO node to the node inside the subgraph.
* @param slot The slot index of the output on the subgraph node.

View File

@@ -0,0 +1,8 @@
export function omitBy<T extends object>(
obj: T,
predicate: (value: any) => boolean
): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_key, value]) => !predicate(value))
) as Partial<T>
}

View File

@@ -1,7 +1,5 @@
import { clamp } from 'lodash'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
import type {
IComboWidget,
IStringComboWidget

View File

@@ -1,5 +1,4 @@
import { clamp } from 'lodash'
import { clamp } from '@/lib/litegraph/src/litegraph'
import type { IKnobWidget } from '@/lib/litegraph/src/types/widgets'
import { getWidgetStep } from '@/lib/litegraph/src/utils/widget'

View File

@@ -1,5 +1,4 @@
import { clamp } from 'lodash'
import { clamp } from '@/lib/litegraph/src/litegraph'
import type { ISliderWidget } from '@/lib/litegraph/src/types/widgets'
import {

View File

@@ -1,8 +1,7 @@
import { clamp } from 'lodash'
import { beforeEach, describe, expect, vi } from 'vitest'
import { LiteGraphGlobal } from '@/lib/litegraph/src/LiteGraphGlobal'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'

View File

@@ -73,7 +73,6 @@
"searchWorkflows": "Search Workflows",
"searchSettings": "Search Settings",
"searchNodes": "Search Nodes",
"searchCommands": "Search Commands",
"searchModels": "Search Models",
"searchKeybindings": "Search Keybindings",
"searchExtensions": "Search Extensions",

View File

@@ -370,7 +370,6 @@
"resultsCount": "Encontrados {count} resultados",
"save": "Guardar",
"saving": "Guardando",
"searchCommands": "Buscar comandos",
"searchExtensions": "Buscar extensiones",
"searchFailedMessage": "No pudimos encontrar ninguna configuración que coincida con tu búsqueda. Intenta ajustar tus términos de búsqueda.",
"searchKeybindings": "Buscar combinaciones de teclas",

View File

@@ -370,7 +370,6 @@
"resultsCount": "{count} Résultats Trouvés",
"save": "Enregistrer",
"saving": "Enregistrement",
"searchCommands": "Rechercher des commandes",
"searchExtensions": "Rechercher des extensions",
"searchFailedMessage": "Nous n'avons trouvé aucun paramètre correspondant à votre recherche. Essayez d'ajuster vos termes de recherche.",
"searchKeybindings": "Rechercher des raccourcis clavier",

View File

@@ -370,7 +370,6 @@
"resultsCount": "{count}件の結果が見つかりました",
"save": "保存",
"saving": "保存中",
"searchCommands": "コマンドを検索",
"searchExtensions": "拡張機能を検索",
"searchFailedMessage": "検索に一致する設定が見つかりませんでした。検索キーワードを調整してみてください。",
"searchKeybindings": "キーバインディングを検索",

View File

@@ -370,7 +370,6 @@
"resultsCount": "{count} 개의 결과를 찾았습니다",
"save": "저장",
"saving": "저장 중",
"searchCommands": "명령어 검색",
"searchExtensions": "확장 검색",
"searchFailedMessage": "검색어와 일치하는 설정을 찾을 수 없습니다. 검색어를 조정해 보세요.",
"searchKeybindings": "키 바인딩 검색",

View File

@@ -370,7 +370,6 @@
"resultsCount": "Найдено {count} результатов",
"save": "Сохранить",
"saving": "Сохранение",
"searchCommands": "Поиск команд",
"searchExtensions": "Поиск расширений",
"searchFailedMessage": "Мы не смогли найти настройки, соответствующие вашему запросу. Попробуйте изменить поисковые термины.",
"searchKeybindings": "Поиск сочетаний клавиш",

View File

@@ -370,7 +370,6 @@
"resultsCount": "找到 {count} 筆結果",
"save": "儲存",
"saving": "儲存中",
"searchCommands": "搜尋指令",
"searchExtensions": "搜尋擴充套件",
"searchFailedMessage": "找不到符合您搜尋的設定。請嘗試調整搜尋條件。",
"searchKeybindings": "搜尋快捷鍵",

View File

@@ -72,7 +72,7 @@
"label": "锁定视图"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "布切小地"
"label": "布切小地"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "忽略/取消忽略选中节点"
@@ -237,13 +237,13 @@
"label": "切换日志底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "切基本下方面板"
"label": "切基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切换检视控制底部面板"
"label": "切換檢視控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "示快捷键对话框"
"label": "示快捷鍵對話框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"

View File

@@ -370,7 +370,6 @@
"resultsCount": "找到 {count} 个结果",
"save": "保存",
"saving": "正在保存",
"searchCommands": "搜尋指令",
"searchExtensions": "搜索扩展",
"searchFailedMessage": "我们找不到任何与您的搜索匹配的设置。请尝试调整您的搜索词。",
"searchKeybindings": "搜索快捷键",
@@ -411,7 +410,7 @@
"resetView": "重置视图",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"toggleMinimap": "切小地",
"toggleMinimap": "切小地",
"zoomIn": "放大",
"zoomOut": "缩小"
},
@@ -747,7 +746,7 @@
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
"Canvas Toggle Link Visibility": "切换连线可见性",
"Canvas Toggle Lock": "切换视图锁定",
"Canvas Toggle Minimap": "布切小地",
"Canvas Toggle Minimap": "布切小地",
"Check for Updates": "检查更新",
"Clear Pending Tasks": "清除待处理任务",
"Clear Workflow": "清除工作流",
@@ -809,21 +808,21 @@
"Restart": "重启",
"Save": "保存",
"Save As": "另存为",
"Show Keybindings Dialog": "示快捷键对话框",
"Show Keybindings Dialog": "示快捷鍵對話框",
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Bottom Panel": "切换底部面板",
"Toggle Essential Bottom Panel": "切基本下方面板",
"Toggle Essential Bottom Panel": "切基本下方面板",
"Toggle Focus Mode": "切换专注模式",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Model Library Sidebar": "切模型庫側邊欄",
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
"Toggle Queue Sidebar": "切換佇列側邊欄",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle View Controls Bottom Panel": "切换检视控制下方面板",
"Toggle Workflows Sidebar": "切工作流程侧边栏",
"Toggle View Controls Bottom Panel": "切換檢視控制下方面板",
"Toggle Workflows Sidebar": "切工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",

View File

@@ -1,74 +0,0 @@
import Fuse from 'fuse.js'
import type { ComfyCommandImpl } from '@/stores/commandStore'
export interface CommandSearchOptions {
limit?: number
}
export class CommandSearchService {
private fuse: Fuse<ComfyCommandImpl>
private commands: ComfyCommandImpl[]
constructor(commands: ComfyCommandImpl[]) {
this.commands = commands
this.fuse = new Fuse(commands, {
keys: [
{
name: 'translatedLabel',
weight: 2,
getFn: (command: ComfyCommandImpl) => command.getTranslatedLabel()
},
{ name: 'id', weight: 1 }
],
includeScore: true,
threshold: 0.4,
shouldSort: true,
minMatchCharLength: 1
})
}
public updateCommands(commands: ComfyCommandImpl[]) {
this.commands = commands
const options = {
keys: [
{
name: 'translatedLabel',
weight: 2,
getFn: (command: ComfyCommandImpl) => command.getTranslatedLabel()
},
{ name: 'id', weight: 1 }
],
includeScore: true,
threshold: 0.4,
shouldSort: true,
minMatchCharLength: 1
}
this.fuse = new Fuse(commands, options)
}
public searchCommands(
query: string,
options?: CommandSearchOptions
): ComfyCommandImpl[] {
// Remove the leading ">" if present
const searchQuery = query.startsWith('>') ? query.slice(1).trim() : query
// If empty query, return all commands sorted alphabetically by translated label
if (!searchQuery) {
const sortedCommands = [...this.commands].sort((a, b) => {
const labelA = a.getTranslatedLabel()
const labelB = b.getTranslatedLabel()
return labelA.localeCompare(labelB)
})
return options?.limit
? sortedCommands.slice(0, options.limit)
: sortedCommands
}
const results = this.fuse.search(searchQuery)
const commands = results.map((result) => result.item)
return options?.limit ? commands.slice(0, options.limit) : commands
}
}

View File

@@ -1,6 +1,5 @@
import { toRaw } from 'vue'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import { t } from '@/i18n'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
@@ -22,7 +21,6 @@ export const useWorkflowService = () => {
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
async function getFilename(defaultName: string): Promise<string | null> {
@@ -289,14 +287,8 @@ export const useWorkflowService = () => {
*/
const beforeLoadNewGraph = () => {
// Use workspaceStore here as it is patched in unit tests.
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()
}
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
domWidgetStore.clear()
}
/**

View File

@@ -2,9 +2,7 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import type { ComfyExtension } from '@/types/comfy'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
@@ -68,19 +66,6 @@ export class ComfyCommandImpl implements ComfyCommand {
get keybinding(): KeybindingImpl | null {
return useKeybindingStore().getKeybindingByCommandId(this.id)
}
getTranslatedLabel(): string {
// Use the same pattern as KeybindingPanel to get translated labels
return t(`commands.${normalizeI18nKey(this.id)}.label`, this.label ?? '')
}
getTranslatedMenubarLabel(): string {
// Use the same pattern but for menubar labels
return t(
`commands.${normalizeI18nKey(this.id)}.menubarLabel`,
this.menubarLabel ?? this.getTranslatedLabel()
)
}
}
export const useCommandStore = defineStore('command', () => {

View File

@@ -54,7 +54,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
(command) =>
({
command: () => commandStore.execute(command.id),
label: command.getTranslatedMenubarLabel(),
label: command.menubarLabel,
icon: command.icon,
tooltip: command.tooltip,
comfyCommand: command

View File

@@ -2,7 +2,6 @@ import _ from 'lodash'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
@@ -328,8 +327,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
(path) => path !== workflow.path
)
if (workflow.isTemporary) {
// Clear thumbnail when temporary workflow is closed
clearThumbnail(workflow.key)
delete workflowLookup.value[workflow.path]
} else {
workflow.unload()
@@ -390,14 +387,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
const isBusy = ref<boolean>(false)
const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail()
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
isBusy.value = true
try {
// Capture all needed values upfront
const oldPath = workflow.path
const oldKey = workflow.key
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
const openIndex = detachWorkflow(workflow)
@@ -408,9 +403,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
attachWorkflow(workflow, openIndex)
}
// Move thumbnail from old key to new key (using workflow keys, not full paths)
const newKey = workflow.key
moveWorkflowThumbnail(oldKey, newKey)
// Update bookmarks
if (wasBookmarked) {
await bookmarkStore.setBookmarked(oldPath, false)
@@ -428,8 +420,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
if (bookmarkStore.isBookmarked(workflow.path)) {
await bookmarkStore.setBookmarked(workflow.path, false)
}
// Clear thumbnail when workflow is deleted
clearThumbnail(workflow.key)
delete workflowLookup.value[workflow.path]
} finally {
isBusy.value = false

View File

@@ -1,131 +0,0 @@
import { describe, expect, it } from 'vitest'
import { CommandSearchService } from '@/services/commandSearchService'
import { ComfyCommandImpl } from '@/stores/commandStore'
describe('CommandSearchService', () => {
// Mock commands
const mockCommands: ComfyCommandImpl[] = [
new ComfyCommandImpl({
id: 'Comfy.NewBlankWorkflow',
label: 'New Blank Workflow',
icon: 'pi pi-plus',
function: () => {}
}),
new ComfyCommandImpl({
id: 'Comfy.SaveWorkflow',
label: 'Save Workflow',
icon: 'pi pi-save',
function: () => {}
}),
new ComfyCommandImpl({
id: 'Comfy.OpenWorkflow',
label: 'Open Workflow',
icon: 'pi pi-folder-open',
function: () => {}
}),
new ComfyCommandImpl({
id: 'Comfy.ClearWorkflow',
label: 'Clear Workflow',
icon: 'pi pi-trash',
function: () => {}
}),
new ComfyCommandImpl({
id: 'Comfy.Undo',
label: 'Undo',
icon: 'pi pi-undo',
function: () => {}
})
]
describe('searchCommands', () => {
it('should return all commands sorted alphabetically when query is empty', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('')
expect(results).toHaveLength(mockCommands.length)
expect(results[0].label).toBe('Clear Workflow')
expect(results[1].label).toBe('New Blank Workflow')
expect(results[2].label).toBe('Open Workflow')
expect(results[3].label).toBe('Save Workflow')
expect(results[4].label).toBe('Undo')
})
it('should handle query with leading ">"', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('>workflow')
expect(results.length).toBeGreaterThan(0)
expect(
results.every(
(cmd) =>
cmd.label?.toLowerCase().includes('workflow') ||
cmd.id.toLowerCase().includes('workflow')
)
).toBe(true)
})
it('should search by label', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('save')
expect(results).toHaveLength(1)
expect(results[0].label).toBe('Save Workflow')
})
it('should search by id', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('ClearWorkflow')
expect(results.length).toBeGreaterThan(0)
expect(results[0].id).toBe('Comfy.ClearWorkflow')
})
it('should respect search limit', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('', { limit: 2 })
expect(results).toHaveLength(2)
})
it('should handle partial matches', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('work')
expect(results.length).toBeGreaterThan(1)
expect(
results.every(
(cmd) =>
cmd.label?.toLowerCase().includes('work') ||
cmd.id.toLowerCase().includes('work')
)
).toBe(true)
})
it('should return empty array for no matches', () => {
const service = new CommandSearchService(mockCommands)
const results = service.searchCommands('xyz123')
expect(results).toHaveLength(0)
})
})
describe('updateCommands', () => {
it('should update the commands list', () => {
const service = new CommandSearchService(mockCommands)
const newCommands = [
new ComfyCommandImpl({
id: 'Test.Command',
label: 'Test Command',
function: () => {}
})
]
service.updateCommands(newCommands)
const results = service.searchCommands('')
expect(results).toHaveLength(1)
expect(results[0].id).toBe('Test.Command')
})
})
})

View File

@@ -41,9 +41,7 @@ const mockCommands: ComfyCommandImpl[] = [
icon: 'pi pi-test',
tooltip: 'Test tooltip',
menubarLabel: 'Other Command',
keybinding: null,
getTranslatedLabel: () => 'Other Command',
getTranslatedMenubarLabel: () => 'Other Command'
keybinding: null
} as ComfyCommandImpl
]

View File

@@ -32,8 +32,7 @@ describe('ShortcutsList', () => {
combo: {
getKeySequences: () => ['Control', 'n']
}
},
getTranslatedLabel: () => 'New Workflow'
}
} as ComfyCommandImpl,
{
id: 'Node.Add',
@@ -43,8 +42,7 @@ describe('ShortcutsList', () => {
combo: {
getKeySequences: () => ['Shift', 'a']
}
},
getTranslatedLabel: () => 'Add Node'
}
} as ComfyCommandImpl,
{
id: 'Queue.Clear',
@@ -54,8 +52,7 @@ describe('ShortcutsList', () => {
combo: {
getKeySequences: () => ['Control', 'Shift', 'c']
}
},
getTranslatedLabel: () => 'Clear Queue'
}
} as ComfyCommandImpl
]
@@ -107,8 +104,7 @@ describe('ShortcutsList', () => {
id: 'No.Keybinding',
label: 'No Keybinding',
category: 'essentials',
keybinding: null,
getTranslatedLabel: () => 'No Keybinding'
keybinding: null
} as ComfyCommandImpl
]
@@ -134,8 +130,7 @@ describe('ShortcutsList', () => {
combo: {
getKeySequences: () => ['Meta', 'ArrowUp', 'Enter', 'Escape', ' ']
}
},
getTranslatedLabel: () => 'Special Keys'
}
} as ComfyCommandImpl
const wrapper = mount(ShortcutsList, {

View File

@@ -1,282 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
vi.mock('@/composables/useMinimap', () => ({
useMinimap: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
moveUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
addEventListener: vi.fn(),
getUserData: vi.fn(),
storeUserData: vi.fn(),
apiURL: vi.fn((path: string) => `/api${path}`)
}
}))
const { useWorkflowThumbnail } = await import(
'@/composables/useWorkflowThumbnail'
)
const { useMinimap } = await import('@/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useWorkflowThumbnail', () => {
let mockMinimapInstance: any
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
setActivePinia(createPinia())
workflowStore = useWorkflowStore()
// Clear any existing thumbnails from previous tests BEFORE mocking
const { clearAllThumbnails } = useWorkflowThumbnail()
clearAllThumbnails()
// Now set up mocks
vi.clearAllMocks()
const blob = new Blob()
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test')
global.URL.revokeObjectURL = vi.fn()
// Mock API responses
vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response)
mockMinimapInstance = {
renderMinimap: vi.fn(),
canvasRef: {
value: {
toBlob: vi.fn((cb) => cb(blob))
}
},
width: 250,
height: 200
}
vi.mocked(useMinimap).mockReturnValue(mockMinimapInstance)
})
it('should capture minimap thumbnail', async () => {
const { createMinimapPreview } = useWorkflowThumbnail()
const thumbnail = await createMinimapPreview()
expect(useMinimap).toHaveBeenCalledOnce()
expect(mockMinimapInstance.renderMinimap).toHaveBeenCalledOnce()
expect(thumbnail).toBe('data:image/png;base64,test')
})
it('should store and retrieve thumbnails', async () => {
const { storeThumbnail, getThumbnail } = useWorkflowThumbnail()
const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow
await storeThumbnail(mockWorkflow)
const thumbnail = getThumbnail('test-workflow-key')
expect(thumbnail).toBe('data:image/png;base64,test')
})
it('should clear thumbnail', async () => {
const { storeThumbnail, getThumbnail, clearThumbnail } =
useWorkflowThumbnail()
const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow
await storeThumbnail(mockWorkflow)
expect(getThumbnail('test-workflow-key')).toBeDefined()
clearThumbnail('test-workflow-key')
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'data:image/png;base64,test'
)
expect(getThumbnail('test-workflow-key')).toBeUndefined()
})
it('should clear all thumbnails', async () => {
const { storeThumbnail, getThumbnail, clearAllThumbnails } =
useWorkflowThumbnail()
const mockWorkflow1 = { key: 'workflow-1' } as ComfyWorkflow
const mockWorkflow2 = { key: 'workflow-2' } as ComfyWorkflow
await storeThumbnail(mockWorkflow1)
await storeThumbnail(mockWorkflow2)
expect(getThumbnail('workflow-1')).toBeDefined()
expect(getThumbnail('workflow-2')).toBeDefined()
clearAllThumbnails()
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
expect(getThumbnail('workflow-1')).toBeUndefined()
expect(getThumbnail('workflow-2')).toBeUndefined()
})
it('should automatically handle thumbnail cleanup when workflow is renamed', async () => {
const { storeThumbnail, getThumbnail, workflowThumbnails } =
useWorkflowThumbnail()
// Create a temporary workflow
const workflow = workflowStore.createTemporary('test-workflow.json')
const originalKey = workflow.key
// Store thumbnail for the workflow
await storeThumbnail(workflow)
expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test')
expect(workflowThumbnails.value.size).toBe(1)
// Rename the workflow - this should automatically handle thumbnail cleanup
const newPath = 'workflows/renamed-workflow.json'
await workflowStore.renameWorkflow(workflow, newPath)
const newKey = workflow.key // The workflow's key should now be the new path
// The thumbnail should be moved from old key to new key
expect(getThumbnail(originalKey)).toBeUndefined()
expect(getThumbnail(newKey)).toBe('data:image/png;base64,test')
expect(workflowThumbnails.value.size).toBe(1)
// No URL should be revoked since we're moving the thumbnail, not deleting it
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
})
it('should properly revoke old URL when storing thumbnail over existing one', async () => {
const { storeThumbnail, getThumbnail } = useWorkflowThumbnail()
const mockWorkflow = { key: 'test-workflow' } as ComfyWorkflow
// Store first thumbnail
await storeThumbnail(mockWorkflow)
const firstThumbnail = getThumbnail('test-workflow')
expect(firstThumbnail).toBe('data:image/png;base64,test')
// Reset the mock to track new calls and create different URL
vi.clearAllMocks()
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2')
// Store second thumbnail for same workflow - should revoke the first URL
await storeThumbnail(mockWorkflow)
const secondThumbnail = getThumbnail('test-workflow')
expect(secondThumbnail).toBe('data:image/png;base64,test2')
// URL.revokeObjectURL should have been called for the first thumbnail
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'data:image/png;base64,test'
)
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
})
it('should clear thumbnail when workflow is deleted', async () => {
const { storeThumbnail, getThumbnail, workflowThumbnails } =
useWorkflowThumbnail()
// Create a workflow and store thumbnail
const workflow = workflowStore.createTemporary('test-delete.json')
await storeThumbnail(workflow)
expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test')
expect(workflowThumbnails.value.size).toBe(1)
// Delete the workflow - this should clear the thumbnail
await workflowStore.deleteWorkflow(workflow)
// Thumbnail should be cleared and URL revoked
expect(getThumbnail(workflow.key)).toBeUndefined()
expect(workflowThumbnails.value.size).toBe(0)
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'data:image/png;base64,test'
)
})
it('should clear thumbnail when temporary workflow is closed', async () => {
const { storeThumbnail, getThumbnail, workflowThumbnails } =
useWorkflowThumbnail()
// Create a temporary workflow and store thumbnail
const workflow = workflowStore.createTemporary('temp-workflow.json')
await storeThumbnail(workflow)
expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test')
expect(workflowThumbnails.value.size).toBe(1)
// Close the workflow - this should clear the thumbnail for temporary workflows
await workflowStore.closeWorkflow(workflow)
// Thumbnail should be cleared and URL revoked
expect(getThumbnail(workflow.key)).toBeUndefined()
expect(workflowThumbnails.value.size).toBe(0)
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'data:image/png;base64,test'
)
})
it('should handle multiple renames without leaking', async () => {
const { storeThumbnail, getThumbnail, workflowThumbnails } =
useWorkflowThumbnail()
// Create workflow and store thumbnail
const workflow = workflowStore.createTemporary('original.json')
await storeThumbnail(workflow)
const originalKey = workflow.key
expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test')
expect(workflowThumbnails.value.size).toBe(1)
// Rename multiple times
await workflowStore.renameWorkflow(workflow, 'workflows/renamed1.json')
const firstRenameKey = workflow.key
expect(getThumbnail(originalKey)).toBeUndefined()
expect(getThumbnail(firstRenameKey)).toBe('data:image/png;base64,test')
expect(workflowThumbnails.value.size).toBe(1)
await workflowStore.renameWorkflow(workflow, 'workflows/renamed2.json')
const secondRenameKey = workflow.key
expect(getThumbnail(originalKey)).toBeUndefined()
expect(getThumbnail(firstRenameKey)).toBeUndefined()
expect(getThumbnail(secondRenameKey)).toBe('data:image/png;base64,test')
expect(workflowThumbnails.value.size).toBe(1)
// No URLs should be revoked since we're just moving thumbnails
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
})
it('should handle edge cases like empty keys or invalid operations', async () => {
const {
getThumbnail,
clearThumbnail,
moveWorkflowThumbnail,
workflowThumbnails
} = useWorkflowThumbnail()
// Test getting non-existent thumbnail
expect(getThumbnail('non-existent')).toBeUndefined()
// Test clearing non-existent thumbnail (should not throw)
expect(() => clearThumbnail('non-existent')).not.toThrow()
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
// Test moving non-existent thumbnail (should not throw)
expect(() => moveWorkflowThumbnail('non-existent', 'target')).not.toThrow()
expect(workflowThumbnails.value.size).toBe(0)
// Test moving to same key (should not cause issues)
const { storeThumbnail } = useWorkflowThumbnail()
const mockWorkflow = { key: 'test-key' } as ComfyWorkflow
await storeThumbnail(mockWorkflow)
expect(workflowThumbnails.value.size).toBe(1)
moveWorkflowThumbnail('test-key', 'test-key')
expect(workflowThumbnails.value.size).toBe(1)
expect(getThumbnail('test-key')).toBe('data:image/png;base64,test')
})
})