Compare commits

..

25 Commits

Author SHA1 Message Date
huchenlei
5da195c925 nit 2025-08-10 14:06:52 -04:00
huchenlei
d3398944d5 Fix test 2025-08-10 14:05:40 -04:00
huchenlei
b70b2c89b2 Fix highlight 2025-08-10 12:53:37 -04:00
huchenlei
c0303a6553 tailwind style 2025-08-10 12:49:03 -04:00
huchenlei
1e6803fd65 Support i18n 2025-08-10 12:36:40 -04:00
huchenlei
38a77abecb nit 2025-08-10 12:17:18 -04:00
github-actions
fcffa51a24 Update locales [skip ci] 2025-08-10 16:16:10 +00:00
huchenlei
d0ef1bb81a nit 2025-08-10 11:01:46 -04:00
huchenlei
34b7fe14c4 Fix mode switch 2025-08-09 22:50:03 -04:00
huchenlei
a4d7b4dd55 Basic commandbox 2025-08-09 22:50:03 -04:00
Comfy Org PR Bot
109542dca3 1.26.1 (#4889)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-09 19:48:35 -07:00
Christian Byrne
ffc812a8f5 [refactor] Remove unused omitBy function (#4886)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 19:12:48 -07:00
Christian Byrne
b745f533ba [feat] Replace manual clamp function with lodash (#4874)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 15:34:18 -07:00
Christian Byrne
8f289c8e67 Fix Alt-Click-Drag-Copy of Subgraph Nodes (#4879) 2025-08-09 15:33:59 -07:00
Vivek Chavan
79b4c78116 fix: hide More menu when no submenu items are visible (#4837) 2025-08-09 15:12:31 -07:00
Vivek Chavan
48aea928e0 fix: hide Desktop User Guide menu item in web builds (#4828) 2025-08-09 15:08:33 -07:00
pythongosssss
03ad06ea14 Add preview to workflow tabs (#4290) 2025-08-09 14:39:40 -07:00
filtered
ff5943f770 Reorder subgraph context menu items (#4870)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 14:20:26 -07:00
Christian Byrne
b1117b9838 [ci] Add chromium-0.5x to test matrix (#4880)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 14:15:08 -07:00
filtered
2d11fb1f90 [CI] Pin third party GH actions to specific SHAs (#4878) 2025-08-09 13:18:43 -07:00
Christian Byrne
e70b127f2a Revert animated-image-preview-saved-webp snapshot change from #4863 (#4873)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 12:31:16 -07:00
Makki Shizu
0d8e4fe719 Fix Simplified Chinese Translation (#4865) 2025-08-09 11:23:30 -07:00
filtered
5f5f44b310 Fix execution breaks on multi/any-type slots (#4864) 2025-08-09 11:17:10 -07:00
filtered
b42878a9da Remove unused Litegraph context menu options (#4867)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 11:14:54 -07:00
Christian Byrne
5cc269eff1 Fix Alt+click create reroute (2/2) (#4863)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 11:13:37 -07:00
59 changed files with 1618 additions and 146 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@v1.3.1
uses: lewagon/wait-on-check-action@e106e5c43e8ca1edea6383a39a01c5ca495fd812
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, mobile-chrome]
browser: [chromium, chromium-2x, chromium-0.5x, 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@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
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,6 +41,7 @@ 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() {
await this.page.mouse.click(10, 10, { button: 'right' })
async rightClickCanvas(x: number = 10, y: number = 10) {
await this.page.mouse.click(x, y, { button: 'right' })
await this.nextFrame()
}

View File

@@ -0,0 +1,161 @@
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 { expect } from '@playwright/test'
import { Locator, expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
@@ -767,6 +767,17 @@ 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')
@@ -794,15 +805,13 @@ test.describe('Viewport settings', () => {
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
// Go back to Workflow A
await tabA.click()
await comfyPage.nextFrame()
await changeTab(tabA)
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotA
)
// And back to Workflow B
await tabB.click()
await comfyPage.nextFrame()
await changeTab(tabB)
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotB
)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

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,6 +372,68 @@ 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

@@ -0,0 +1,155 @@
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.0",
"version": "1.26.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.0",
"version": "1.26.1",
"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.0",
"version": "1.26.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -49,6 +49,13 @@ 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.label || command.id }}
{{ command.getTranslatedLabel() }}
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<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"
v-if="enableNodePreview && !isCommandMode"
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
>
<NodePreview
@@ -14,6 +14,7 @@
</div>
<Button
v-if="!isCommandMode"
icon="pi pi-filter"
severity="secondary"
class="filter-button z-10"
@@ -49,13 +50,24 @@
auto-option-focus
force-selection
multiple
:option-label="'display_name'"
:option-label="getOptionLabel"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@option-select="onOptionSelect($event.value)"
@focused-option-changed="setHoverSuggestion($event)"
@input="handleInput"
>
<template #option="{ option }">
<NodeSearchItem :node-def="option" :current-query="currentQuery" />
<!-- 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"
/>
</template>
<!-- FilterAndValue -->
<template #chip="{ value }">
@@ -80,13 +92,16 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } 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,
@@ -99,6 +114,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore()
const { t } = useI18n()
const commandStore = useCommandStore()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
@@ -111,18 +127,50 @@ const { filters, searchLimit = 64 } = defineProps<{
const nodeSearchFilterVisible = ref(false)
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
const suggestions = ref<ComfyNodeDefImpl[]>([])
const suggestions = ref<ComfyNodeDefImpl[] | ComfyCommandImpl[]>([])
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) => {
const queryIsEmpty = query === '' && filters.length === 0
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
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
@@ -132,7 +180,18 @@ const search = (query: string) => {
]
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
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
}>()
let inputElement: HTMLInputElement | null = null
const reFocusInput = async () => {
@@ -160,11 +219,47 @@ const onRemoveFilter = async (
await reFocusInput()
}
const setHoverSuggestion = (index: number) => {
if (index === -1) {
if (index === -1 || isCommandMode.value) {
hoveredSuggestion.value = null
return
}
const value = suggestions.value[index]
const value = suggestions.value[index] as ComfyNodeDefImpl
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,6 +26,7 @@
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@execute-command="executeCommand"
/>
</template>
</Dialog>
@@ -46,6 +47,7 @@ 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'
@@ -62,6 +64,7 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const litegraphService = useLitegraphService()
const commandStore = useCommandStore()
const { visible } = storeToRefs(useSearchBoxStore())
const dismissable = ref(true)
@@ -109,6 +112,14 @@ 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">
<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"
>
<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">
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
@@ -22,23 +22,33 @@
/>
</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, ref } from 'vue'
import { computed, onUnmounted, 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
@@ -55,6 +65,8 @@ 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(() =>
@@ -90,6 +102,27 @@ 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 (
@@ -135,6 +168,10 @@ usePragmaticDroppable(tabGetter, {
}
}
})
onUnmounted(() => {
popoverRef.value?.hidePopover()
})
</script>
<style scoped>

View File

@@ -0,0 +1,229 @@
<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,6 +696,7 @@ export function useMinimap() {
init,
destroy,
toggle,
renderMinimap,
handlePointerDown,
handlePointerMove,
handlePointerUp,

View File

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

View File

@@ -2299,6 +2299,8 @@ 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)
@@ -2384,6 +2386,7 @@ 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
@@ -2393,7 +2396,7 @@ export class LGraphCanvas
if (
(e.shiftKey || e.altKey) &&
linkSegment.path &&
this.ctx.isPointInStroke(linkSegment.path, x, y)
this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
) {
this.ctx.lineWidth = lineWidth
@@ -8067,18 +8070,6 @@ 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: () => {
@@ -8246,14 +8237,16 @@ 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,7 +769,12 @@ export class LGraphNode
this.graph._version++
}
for (const j in info) {
if (j === 'properties' || j === 'inputs' || j === 'outputs') {
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])
}
continue
}
@@ -793,28 +798,14 @@ 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
}
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)
)
}
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
@@ -824,17 +815,10 @@ export class LGraphNode
this.onInputAdded?.(input)
}
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)
)
}
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,5 +1,3 @@
import _ from 'lodash'
import { ContextMenu } from './ContextMenu'
import { CurveEditor } from './CurveEditor'
import { DragAndScale } from './DragAndScale'
@@ -643,7 +641,7 @@ export class LiteGraphGlobal {
): WhenNullish<T, null> {
if (obj == null) return null as WhenNullish<T, null>
const r = _.cloneDeep(obj)
const r = JSON.parse(JSON.stringify(obj))
if (!target) return r
for (const i in r) {

View File

@@ -1,9 +1,10 @@
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 { clamp, createBounds } from './measure'
export { createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'
export {
type ExecutableLGraphNode,

View File

@@ -450,7 +450,3 @@ 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 } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { Subgraph } from './Subgraph'
import type { SubgraphNode } from './SubgraphNode'
@@ -263,13 +263,8 @@ 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 parentInputIndexes = Object.keys(inputs).map(Number)
// Prioritise exact slot index
const indexes = [slot, ...parentInputIndexes]
const matchingIndex = indexes.find((i) => inputs[i]?.type === type)
const matchingIndex = this.#getBypassSlotIndex(slot, type)
// No input types match
if (matchingIndex === undefined) {
@@ -326,6 +321,44 @@ 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

@@ -1,8 +0,0 @@
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,5 +1,7 @@
import { clamp } from 'lodash'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
IComboWidget,
IStringComboWidget

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -370,6 +370,7 @@
"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,6 +370,7 @@
"resultsCount": "找到 {count} 个结果",
"save": "保存",
"saving": "正在保存",
"searchCommands": "搜尋指令",
"searchExtensions": "搜索扩展",
"searchFailedMessage": "我们找不到任何与您的搜索匹配的设置。请尝试调整您的搜索词。",
"searchKeybindings": "搜索快捷键",
@@ -410,7 +411,7 @@
"resetView": "重置视图",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"toggleMinimap": "切小地",
"toggleMinimap": "切小地",
"zoomIn": "放大",
"zoomOut": "缩小"
},
@@ -746,7 +747,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": "清除工作流",
@@ -808,21 +809,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

@@ -0,0 +1,74 @@
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,5 +1,6 @@
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'
@@ -21,6 +22,7 @@ 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> {
@@ -287,8 +289,14 @@ export const useWorkflowService = () => {
*/
const beforeLoadNewGraph = () => {
// Use workspaceStore here as it is patched in unit tests.
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
domWidgetStore.clear()
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()
}
}
/**

View File

@@ -2,7 +2,9 @@ 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'
@@ -66,6 +68,19 @@ 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.menubarLabel,
label: command.getTranslatedMenubarLabel(),
icon: command.icon,
tooltip: command.tooltip,
comfyCommand: command

View File

@@ -2,6 +2,7 @@ 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'
@@ -327,6 +328,8 @@ 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()
@@ -387,12 +390,14 @@ 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)
@@ -403,6 +408,9 @@ 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)
@@ -420,6 +428,8 @@ 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

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

View File

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

View File

@@ -0,0 +1,282 @@
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')
})
})