Compare commits

...

41 Commits

Author SHA1 Message Date
Christian Byrne
2ee5541b6a [release] Bump version to 1.25.7 (#5026) 2025-08-15 15:42:49 -07:00
Comfy Org PR Bot
5918dde255 [bugfix] Preserve nested subgraph widget values during serialization (#5023) (#5025)
When saving workflows with nested subgraphs, promoted widget values were not being synchronized back to the subgraph definitions before serialization. This caused widget values to revert to their original defaults when reloading the workflow.

The fix overrides the serialize() method in SubgraphNode to sync promoted widget values to their corresponding widgets in the subgraph definition before serialization occurs.

Fixes the issue where nested subgraph widget values would be lost after save/reload.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-15 15:01:28 -07:00
Comfy Org PR Bot
a7cbf1b90c Deep copy subgraphs to clipboard, update nested ids on paste (#5003) (#5022)
* Deep copy to clipboard, update nested ids on paste

The copyToClipboard function wasn't walking subgraphs and leaving nested
subgraphs unserialized. This has now been fixed.

This requires that equivalent support be added to _pasteFromClipboard to
update the ids of nested subgraphs which are pasted.

* Add extra advisory comments

Co-authored-by: AustinMroz <austin@comfy.org>
2025-08-15 14:26:53 -07:00
Comfy Org PR Bot
30009e2786 fix: improve minimap subgraph navigation with graph UUID callback tracking (#5018) (#5020)
- Replace single callback storage with Map using graph UUIDs as keys
- Fix minimap not updating when navigating between subgraphs
- Add proper cleanup and error handling for callback management
- Switch from app.canvas.graph to reactive workflowStore.activeSubgraph
- Prevent callback wrapping recursion by tracking setup state per graph

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-08-15 14:01:34 -07:00
Comfy Org PR Bot
3818b7ce57 Fix widget disconnection issue in subgraphs #4922 (#5015) (#5019)
* [bugfix] Fix widget disconnection issue in subgraphs

When disconnecting a node from a SubgraphInput, the target input's link
reference was not being cleared in LLink.disconnect(). This caused
widgets to remain greyed out because they still thought they were
connected (slot.link was not null).

The fix ensures that when a link is disconnected, the target node's
input slot is properly cleaned up by setting input.link = null.

Fixes #4922

🤖 Generated with [Claude Code](https://claude.ai/code)



* [test] Add tests for LLink disconnect fix for widget issue

Add comprehensive tests for the LLink.disconnect() method to verify
that target input link references are properly cleared when disconnecting.
This prevents widgets from remaining greyed out after disconnection.

Tests cover:
- Basic disconnect functionality with link reference cleanup
- Edge cases with invalid target nodes
- Preventing interference between different connections

Related to #4922

🤖 Generated with [Claude Code](https://claude.ai/code)



---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-15 13:36:48 -07:00
Comfy Org PR Bot
aaaa8c820f api_nodes: added prices for gpt-5 series models (#4958) (#5016)
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-08-15 13:04:32 -07:00
Comfy Org PR Bot
f98443dbb6 Translated Keyboard Shortcuts (#5007) (#5012)
* fix: Update command label rendering to use i18n normalization

* fix: Replace deprecated  with t for command label rendering

* fix: Simplify command rendering check in ShortcutsList tests

* fix: Add missing translation for command label in ShortcutsList tests

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-08-15 12:12:54 -07:00
Comfy Org PR Bot
1094b57eb5 [test] Add tests for --disable-api-nodes release fetch skip functionality (#4799) (#5008)
- Add comprehensive test coverage for the new --disable-api-nodes argument handling
- Tests verify release fetching is properly skipped when argument is present
- Cover edge cases including multiple args, null argv, and missing system stats
- Ensures backward compatibility when argument is not present

Co-authored-by: Yoland Yan <4950057+yoland68@users.noreply.github.com>
2025-08-15 11:45:36 -07:00
Christian Byrne
f5409ea20c fix: Correct traditional Chinese to simplified Chinese in translations (#5005) (#5011)
* Correct some translations that use traditional Chinese to simplified Chinese.

* Update locales [skip ci]

* Correct the rest of the translations

---------

Co-authored-by: ComfyUI Wiki <contact@comfyui-wiki.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-15 11:43:45 -07:00
Christian Byrne
bfed966ae7 Feature/arabic translation (#4916) (#5006)
Co-authored-by: arab-future-academy <arabfutueacademy@gmail.com>
2025-08-15 10:32:01 -07:00
Christian Byrne
d903891cea [release] Bump version to 1.25.6 (#5004) 2025-08-15 09:48:59 -07:00
Christian Byrne
1068d1bc9a Remove unused Litegraph context menu options (#4867) (#4998)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-15 08:58:02 -07:00
Christian Byrne
6904029fad [backport] Restore group node conversion menu with deprecated label (cherry-pick #4967) (#4987)
* [feat] Restore group node conversion menu with deprecated label (#4967)

* Fix screenshot conflicts - use core/1.25 baseline versions

* Revert screenshots to PR version with deprecated menu item
2025-08-14 23:05:43 -07:00
Comfy Org PR Bot
263f52f539 Fix inconsistency on bypass from context menu (#4988) (#4997)
When a node is bypassed from the selection toolbox or by pressing a
keybind for bypass, it will also recursively bypass the contents of a
subgraph. This effect was not applied when clicking the bypass button
from the context menu. The context menu option has been updated to
perform the same action as the others so that behaviour is consistent.

Co-authored-by: AustinMroz <austin@comfy.org>
2025-08-14 23:02:38 -07:00
Christian Byrne
be7d239087 [fix] Prevent incompatible connections to SubgraphInputNode occupied slots (#4984) (#4993) 2025-08-14 21:34:03 -07:00
Comfy Org PR Bot
6265dfac38 fix: Handle missing subgraph inputs gracefully during workflow import (#4985) (#4986)
When loading workflows, SubgraphNode would throw an error if an input
exists in the serialized data that doesn't exist in the current subgraph
definition. This can happen when:
- Subgraph definitions change after workflows are saved
- Workflows are shared between users with different subgraph versions
- Dynamic inputs were added that don't exist in the base definition

This change converts the hard error to a warning and continues processing,
allowing workflows to load even with mismatched subgraph configurations.

Fixes #4905

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-08-14 21:22:12 -07:00
Comfy Org PR Bot
97d95e5574 [backport 1.25] fix pricing for KlingImage2VideoNode (#4976)
Backport of #4957 to `core/1.25`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4976-backport-1-25-fix-pricing-for-KlingImage2VideoNode-24f6d73d365081ac9481dfbd87aa96e3)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-08-14 12:11:12 -07:00
Comfy Org PR Bot
be4e5b0ade [backport 1.25] show group self color in minimap (#4969)
Backport of #4954 to `core/1.25`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4969-backport-1-25-show-group-self-color-in-minimap-24e6d73d36508111b8f5ee86949b7144)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-08-14 12:10:55 -07:00
Comfy Org PR Bot
f6967d889e [backport 1.25] fix: Add guards for _listenerController.abort() calls in SubgraphNode (#4970)
Backport of #4968 to `core/1.25`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4970-backport-1-25-fix-Add-guards-for-_listenerController-abort-calls-in-SubgraphNode-24e6d73d365081838ca0c1d0a1734ba0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-08-13 15:59:44 -07:00
Comfy Org PR Bot
a9c80e91d3 [backport 1.25] Bundled subgraph fixes (#4965)
Backport of #4964 to `core/1.25`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4965-backport-1-25-Bundled-subgraph-fixes-24e6d73d3650816194baeeaff87d02f1)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-08-13 13:30:18 -07:00
Comfy Org PR Bot
ccee1fa7c0 [backport 1.25] Trigger updateSelectedItems on subgraph conversion (#4956)
Backport of #4949 to `core/1.25`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4956-backport-1-25-Trigger-updateSelectedItems-on-subgraph-conversion-24e6d73d36508164ba29c52f31c092ed)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-13 13:05:10 -07:00
Comfy Org PR Bot
3897a75621 [backport 1.25] gemini-2.5-pro and flash models; corrected prices (#4952)
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-08-12 15:17:30 -07:00
Comfy Org PR Bot
e8c70545e3 [backport 1.25] pricing update for MinimaxHailuoVideo node and Kling kling-v2-1 model (#4951)
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-08-12 14:46:05 -07:00
Comfy Org PR Bot
b0223187fe [backport 1.25] Implement subgraph unpacking (#4950)
Co-authored-by: AustinMroz <austin@comfy.org>
2025-08-12 14:45:35 -07:00
Comfy Org PR Bot
ab766694e9 [backport 1.25] Add automatic trackpad / mouse sensing (#4944)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-08-12 12:14:13 -07:00
Comfy Org PR Bot
08f834b93c [backport 1.25] minimap improve (#4923)
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-11 14:53:39 -07:00
Comfy Org PR Bot
ad3eede075 [backport 1.25] [feat] Add red styling to Remove Slot context menu option (#4921)
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 14:53:24 -07:00
Comfy Org PR Bot
fc294112e7 [backport 1.25] Fix subgraph reroute serialization (#4920)
Co-authored-by: AustinMroz <austin@comfy.org>
2025-08-11 14:53:09 -07:00
Comfy Org PR Bot
bbf7b4801c [backport 1.25] [feat] Make hotkey for exiting subgraphs configurable in user keybindings (#4914)
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-11 11:45:53 -07:00
Christian Byrne
d1434d1c80 [backport 1.25] Add bounds checking for clipspace indices to prevent paste errors (core/1.25 backport) (#4904) 2025-08-10 20:57:12 -07:00
Comfy Org PR Bot
980e3ebfab [backport 1.25] Add preview to workflow tabs (#4882)
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2025-08-09 17:28:40 -07:00
Comfy Org PR Bot
694ff47269 [backport 1.25] Fix Alt-Click-Drag-Copy of Subgraph Nodes (#4884)
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-08-09 17:28:03 -07:00
Comfy Org PR Bot
b35525578c [backport 1.25] Reorder subgraph context menu items (#4881)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 17:26:24 -07:00
Christian Byrne
8872caaf4d [backport 1.25] Revert animated-image-preview-saved-webp snapshot change from #4863 (#4883)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-09 15:32:59 -07:00
Comfy Org PR Bot
3def157b96 [backport 1.25] Fix execution breaks on multi/any-type slots (#4871)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-08-09 14:12:13 -07:00
Comfy Org PR Bot
1abf9a5e86 [backport 1.25] Fix Alt+click create reroute (2/2) (#4869)
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-09 13:33:36 -07:00
Comfy Org PR Bot
2d4dba3f19 [backport 1.25] Fix: Alt+click reroute creation on high-DPI displays (#4862)
Co-authored-by: Vivek Chavan <111511821+vivekchavan14@users.noreply.github.com>
2025-08-09 10:24:31 -07:00
Comfy Org PR Bot
aa9b70656e [backport 1.25] Fix disconnection from subgraph inputs (#4859)
Co-authored-by: AustinMroz <austin@comfy.org>
2025-08-09 08:03:49 -07:00
Comfy Org PR Bot
80d54eca2f [backport 1.25] Rename subgraph widgets when slot is renamed (#4825)
Co-authored-by: AustinMroz <austin@comfy.org>
2025-08-07 15:46:48 -07:00
Comfy Org PR Bot
a9f05bd604 [backport 1.25] Remove subgraphs from add node context menu (#4822)
Co-authored-by: AustinMroz <austin@comfy.org>
2025-08-07 15:15:37 -07:00
Comfy Org PR Bot
53f5927d4b [backport 1.25] Keyboard Shortcut Bottom Panel (#4813)
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-08-07 12:15:56 -07:00
103 changed files with 15581 additions and 260 deletions

View File

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

View File

@@ -75,7 +75,7 @@ The development of successive minor versions overlaps. For example, while versio
<summary>v1.5: Native translation (i18n)</summary>
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, Korean, or Arabic. This native
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
More details available here: https://blog.comfy.org/p/native-localization-support-i18n

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,280 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Bottom Panel Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
// Initially shortcuts panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
// Click shortcuts toggle button in sidebar
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Shortcuts panel should now be visible
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Click toggle button again to hide
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Panel should be hidden again
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
})
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Essentials tab should be visible and active by default
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toBeVisible()
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
// Should display shortcut categories
await expect(
comfyPage.page.locator('.subcategory-title').first()
).toBeVisible()
// Should display some keyboard shortcuts
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
// Should have workflow, node, and queue sections
await expect(
comfyPage.page.getByRole('heading', { name: 'Workflow' })
).toBeVisible()
await expect(
comfyPage.page.getByRole('heading', { name: 'Node' })
).toBeVisible()
await expect(
comfyPage.page.getByRole('heading', { name: 'Queue' })
).toBeVisible()
})
test('should display view controls shortcuts tab', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Click view controls tab
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
// View controls tab should be active
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
// Should display view controls shortcuts
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
// Should have view and panel controls sections
await expect(
comfyPage.page.getByRole('heading', { name: 'View' })
).toBeVisible()
await expect(
comfyPage.page.getByRole('heading', { name: 'Panel Controls' })
).toBeVisible()
})
test('should switch between shortcuts tabs', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Essentials should be active initially
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
// Click view controls tab
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
// View controls should now be active
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).not.toHaveAttribute('aria-selected', 'true')
// Switch back to essentials
await comfyPage.page.getByRole('tab', { name: /Essential/i }).click()
// Essentials should be active again
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).not.toHaveAttribute('aria-selected', 'true')
})
test('should display formatted keyboard shortcuts', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Wait for shortcuts to load
await comfyPage.page.waitForSelector('.key-badge')
// Check for common formatted keys
const keyBadges = comfyPage.page.locator('.key-badge')
const count = await keyBadges.count()
expect(count).toBeGreaterThanOrEqual(1)
// Should show formatted modifier keys
const badgeText = await keyBadges.allTextContents()
const hasModifiers = badgeText.some((text) =>
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
)
expect(hasModifiers).toBeTruthy()
})
test('should maintain panel state when switching to terminal', async ({
comfyPage
}) => {
// Open shortcuts panel first
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Open terminal panel (should switch panels)
await comfyPage.page
.locator('button[aria-label*="Toggle Bottom Panel"]')
.click()
// Panel should still be visible but showing terminal content
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Switch back to shortcuts
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Should show shortcuts content again
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
})
test('should handle keyboard navigation', async ({ comfyPage }) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Focus the first tab
await comfyPage.page.getByRole('tab', { name: /Essential/i }).focus()
// Use arrow keys to navigate between tabs
await comfyPage.page.keyboard.press('ArrowRight')
// View controls tab should now have focus
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toBeFocused()
// Press Enter to activate the tab
await comfyPage.page.keyboard.press('Enter')
// Tab should be selected
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
})
test('should close panel by clicking shortcuts button again', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Click shortcuts button again to close
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
})
test('should display shortcuts in organized columns', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Should have 3-column grid layout
await expect(comfyPage.page.locator('.md\\:grid-cols-3')).toBeVisible()
// Should have multiple subcategory sections
const subcategoryTitles = comfyPage.page.locator('.subcategory-title')
const titleCount = await subcategoryTitles.count()
expect(titleCount).toBeGreaterThanOrEqual(2)
})
test('should open shortcuts panel with Ctrl+Shift+K', async ({
comfyPage
}) => {
// Initially shortcuts panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
// Press Ctrl+Shift+K to open shortcuts panel
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
// Shortcuts panel should now be visible
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Should show essentials tab by default
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
})
test('should open settings dialog when clicking manage shortcuts button', async ({
comfyPage
}) => {
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Manage shortcuts button should be visible
await expect(
comfyPage.page.getByRole('button', { name: /Manage Shortcuts/i })
).toBeVisible()
// Click manage shortcuts button
await comfyPage.page
.getByRole('button', { name: /Manage Shortcuts/i })
.click()
// Settings dialog should open with keybinding tab
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
// Should show keybinding settings (check for keybinding-related content)
await expect(
comfyPage.page.getByRole('option', { name: 'Keybinding' })
).toBeVisible()
})
})

View File

@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test.skip('Can be added to canvas using node library sidebar', async ({
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
).toHaveLength(0)
})
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
)
})
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test.skip('Manage group opens with the correct group selected', async ({
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name, type1, type2) => {
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
expect(visibleInputCount).toBe(2)
})
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {

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

@@ -24,8 +24,14 @@ test.describe('Minimap', () => {
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'absolute')
await expect(minimapContainer).toHaveCSS('z-index', '1000')
await expect(minimapContainer).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
const minimapMainContainer = comfyPage.page.locator(
'.minimap-main-container'
)
await expect(minimapMainContainer).toHaveCSS('position', 'absolute')
await expect(minimapMainContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {

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

View File

@@ -24,11 +24,11 @@ test.describe('Canvas Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test.skip('Can convert to group node', async ({ comfyPage }) => {
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Convert to Group Node')
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -196,6 +196,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', () => {
@@ -466,4 +528,103 @@ test.describe('Subgraph Operations', () => {
expect(finalCount).toBe(parentCount)
})
})
test.describe('Navigation Hotkeys', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
await comfyPage.nextFrame()
// Change the Exit Subgraph keybinding from Escape to Alt+Q
await comfyPage.setSetting('Comfy.Keybinding.NewBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'q',
ctrl: false,
alt: true,
shift: false
}
}
])
await comfyPage.setSetting('Comfy.Keybinding.UnsetBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'Escape',
ctrl: false,
alt: false,
shift: false
}
}
])
// Reload the page
await comfyPage.page.reload()
await comfyPage.page.waitForTimeout(1024)
// Navigate into subgraph
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container', {
state: 'visible'
})
// Press Escape - should close dialog, not exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Dialog should be closed
await expect(
comfyPage.page.locator('.settings-container')
).not.toBeVisible()
// Should still be in subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
})
})

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

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.25.5",
"version": "1.25.7",
"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

@@ -11,18 +11,33 @@
class="p-3 border-none"
>
<span class="font-bold">
{{ tab.title.toUpperCase() }}
{{
shouldCapitalizeTab(tab.id)
? tab.title.toUpperCase()
: tab.title
}}
</span>
</Tab>
</div>
<Button
class="justify-self-end"
icon="pi pi-times"
severity="secondary"
size="small"
text
@click="bottomPanelStore.bottomPanelVisible = false"
/>
<div class="flex items-center gap-2">
<Button
v-if="isShortcutsTabActive"
:label="$t('shortcuts.manageShortcuts')"
icon="pi pi-cog"
severity="secondary"
size="small"
text
@click="openKeybindingSettings"
/>
<Button
class="justify-self-end"
icon="pi pi-times"
severity="secondary"
size="small"
text
@click="closeBottomPanel"
/>
</div>
</div>
</TabList>
</Tabs>
@@ -44,9 +59,32 @@ import Button from 'primevue/button'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import Tabs from 'primevue/tabs'
import { computed } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import { useDialogService } from '@/services/dialogService'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const bottomPanelStore = useBottomPanelStore()
const dialogService = useDialogService()
const isShortcutsTabActive = computed(() => {
const activeTabId = bottomPanelStore.activeBottomPanelTabId
return (
activeTabId === 'shortcuts-essentials' ||
activeTabId === 'shortcuts-view-controls'
)
})
const shouldCapitalizeTab = (tabId: string): boolean => {
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
}
const openKeybindingSettings = async () => {
dialogService.showSettingsDialog('keybinding')
}
const closeBottomPanel = () => {
bottomPanelStore.activePanel = null
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="h-full flex flex-col p-4">
<div class="flex-1 min-h-0 overflow-auto">
<ShortcutsList
:commands="essentialsCommands"
:subcategories="essentialsSubcategories"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
ESSENTIALS_CONFIG,
useCommandSubcategories
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
import { useCommandStore } from '@/stores/commandStore'
import ShortcutsList from './ShortcutsList.vue'
const commandStore = useCommandStore()
const essentialsCommands = computed(() =>
commandStore.commands.filter((cmd) => cmd.category === 'essentials')
)
const { subcategories: essentialsSubcategories } = useCommandSubcategories(
essentialsCommands,
ESSENTIALS_CONFIG
)
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div class="shortcuts-list flex justify-center">
<div class="grid gap-4 md:gap-24 h-full grid-cols-1 md:grid-cols-3 w-[90%]">
<div
v-for="(subcategoryCommands, subcategory) in filteredSubcategories"
:key="subcategory"
class="flex flex-col"
>
<h3
class="subcategory-title text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 mb-4"
>
{{ getSubcategoryTitle(subcategory) }}
</h3>
<div class="flex flex-col gap-1">
<div
v-for="command in subcategoryCommands"
:key="command.id"
class="shortcut-item flex justify-between items-center py-2 rounded hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors duration-200"
>
<div class="shortcut-info flex-grow pr-4">
<div class="shortcut-name text-sm font-medium">
{{ t(`commands.${normalizeI18nKey(command.id)}.label`) }}
</div>
</div>
<div class="keybinding-display flex-shrink-0">
<div
class="keybinding-combo flex gap-1"
:aria-label="`Keyboard shortcut: ${command.keybinding!.combo.getKeySequences().join(' + ')}`"
>
<span
v-for="key in command.keybinding!.combo.getKeySequences()"
:key="key"
class="key-badge px-2 py-1 text-xs font-mono bg-surface-200 dark-theme:bg-surface-600 rounded border min-w-6 text-center"
>
{{ formatKey(key) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()
const { subcategories } = defineProps<{
commands: ComfyCommandImpl[]
subcategories: Record<string, ComfyCommandImpl[]>
}>()
const filteredSubcategories = computed(() => {
const result: Record<string, ComfyCommandImpl[]> = {}
for (const [subcategory, commands] of Object.entries(subcategories)) {
result[subcategory] = commands.filter((cmd) => !!cmd.keybinding)
}
return result
})
const getSubcategoryTitle = (subcategory: string): string => {
const titleMap: Record<string, string> = {
workflow: t('shortcuts.subcategories.workflow'),
node: t('shortcuts.subcategories.node'),
queue: t('shortcuts.subcategories.queue'),
view: t('shortcuts.subcategories.view'),
'panel-controls': t('shortcuts.subcategories.panelControls')
}
return titleMap[subcategory] || subcategory
}
const formatKey = (key: string): string => {
const keyMap: Record<string, string> = {
Control: 'Ctrl',
Meta: 'Cmd',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Backspace: '⌫',
Delete: '⌦',
Enter: '↵',
Escape: 'Esc',
Tab: '⇥',
' ': 'Space'
}
return keyMap[key] || key
}
</script>
<style scoped>
.subcategory-title {
color: var(--p-text-muted-color);
}
.key-badge {
background-color: var(--p-surface-200);
border: 1px solid var(--p-surface-300);
min-width: 1.5rem;
text-align: center;
}
.dark-theme .key-badge {
background-color: var(--p-surface-600);
border-color: var(--p-surface-500);
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="h-full flex flex-col p-4">
<div class="flex-1 min-h-0 overflow-auto">
<ShortcutsList
:commands="viewControlsCommands"
:subcategories="viewControlsSubcategories"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
VIEW_CONTROLS_CONFIG,
useCommandSubcategories
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
import { useCommandStore } from '@/stores/commandStore'
import ShortcutsList from './ShortcutsList.vue'
const commandStore = useCommandStore()
const viewControlsCommands = computed(() =>
commandStore.commands.filter((cmd) => cmd.category === 'view-controls')
)
const { subcategories: viewControlsSubcategories } = useCommandSubcategories(
viewControlsCommands,
VIEW_CONTROLS_CONFIG
)
</script>

View File

@@ -32,7 +32,6 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
@@ -98,18 +97,6 @@ const home = computed(() => ({
}
}))
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {

View File

@@ -1,31 +1,66 @@
<template>
<div
v-if="visible && initialized"
ref="containerRef"
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
:style="containerStyles"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
<MiniMapPanel
v-if="showOptionsPanel"
:panel-styles="panelStyles"
:node-colors="nodeColors"
:show-links="showLinks"
:show-groups="showGroups"
:render-bypass="renderBypass"
:render-error="renderError"
@update-option="updateOption"
/>
<div class="minimap-viewport" :style="viewportStyles" />
<div
ref="containerRef"
class="litegraph-minimap relative"
:style="containerStyles"
>
<Button
class="absolute z-10"
size="small"
text
severity="secondary"
@click.stop="toggleOptionsPanel"
>
<template #icon>
<i-lucide:settings-2 />
</template>
</Button>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
<div
class="absolute inset-0"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
import MiniMapPanel from './MiniMapPanel.vue'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
@@ -38,6 +73,13 @@ const {
viewportStyles,
width,
height,
panelStyles,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
updateOption,
init,
destroy,
handlePointerDown,
@@ -46,6 +88,12 @@ const {
handleWheel
} = minimap
const showOptionsPanel = ref(false)
const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
watch(
() => canvasStore.canvas,
async (canvas) => {

View File

@@ -0,0 +1,97 @@
<template>
<div
class="minimap-panel p-3 mr-2 flex flex-col gap-3 text-sm"
:style="panelStyles"
>
<div class="flex items-center gap-2">
<Checkbox
input-id="node-colors"
name="node-colors"
:model-value="nodeColors"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.NodeColors', value)
"
/>
<i-lucide:palette />
<label for="node-colors">{{ $t('minimap.nodeColors') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="show-links"
name="show-links"
:model-value="showLinks"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.ShowLinks', value)
"
/>
<i-lucide:route />
<label for="show-links">{{ $t('minimap.showLinks') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="show-groups"
name="show-groups"
:model-value="showGroups"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.ShowGroups', value)
"
/>
<i-lucide:frame />
<label for="show-groups">{{ $t('minimap.showGroups') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="render-bypass"
name="render-bypass"
:model-value="renderBypass"
binary
@update:model-value="
(value) =>
$emit('updateOption', 'Comfy.Minimap.RenderBypassState', value)
"
/>
<i-lucide:circle-slash-2 />
<label for="render-bypass">{{ $t('minimap.renderBypassState') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="render-error"
name="render-error"
:model-value="renderError"
binary
@update:model-value="
(value) =>
$emit('updateOption', 'Comfy.Minimap.RenderErrorState', value)
"
/>
<i-lucide:message-circle-warning />
<label for="render-error">{{ $t('minimap.renderErrorState') }}</label>
</div>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { MinimapOptionKey } from '@/composables/useMinimap'
defineProps<{
panelStyles: any
nodeColors: boolean
showLinks: boolean
showGroups: boolean
renderBypass: boolean
renderError: boolean
}>()
defineEmits<{
updateOption: [key: MinimapOptionKey, value: boolean]
}>()
</script>

View File

@@ -1,6 +1,20 @@
<template>
<Button
v-show="isVisible"
v-if="isUnpackVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_UnpackSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
>
<template #icon>
<i-lucide:expand />
</template>
</Button>
<Button
v-else-if="isConvertVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
@@ -20,6 +34,7 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
@@ -27,7 +42,13 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isVisible = computed(() => {
const isUnpackVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
const isConvertVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||

View File

@@ -16,6 +16,7 @@
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarHelpCenterIcon />
<SidebarBottomPanelToggleButton />
<SidebarShortcutsToggleButton />
</div>
</nav>
</teleport>
@@ -32,6 +33,7 @@ import { computed } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useSettingStore } from '@/stores/settingStore'
import { useUserStore } from '@/stores/userStore'

View File

@@ -1,7 +1,7 @@
<template>
<SidebarIcon
:tooltip="$t('menu.toggleBottomPanel')"
:selected="bottomPanelStore.bottomPanelVisible"
:selected="bottomPanelStore.activePanel == 'terminal'"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>

View File

@@ -0,0 +1,44 @@
<template>
<SidebarIcon
:tooltip="
$t('shortcuts.keyboardShortcuts') +
' (' +
formatKeySequence(command.keybinding!.combo.getKeySequences()) +
')'
"
:selected="isShortcutsPanelVisible"
@click="toggleShortcutsPanel"
>
<template #icon>
<i-lucide:keyboard />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import SidebarIcon from './SidebarIcon.vue'
const bottomPanelStore = useBottomPanelStore()
const command = useCommandStore().getCommand(
'Workspace.ToggleBottomPanel.Shortcuts'
)
const isShortcutsPanelVisible = computed(
() => bottomPanelStore.activePanel === 'shortcuts'
)
const toggleShortcutsPanel = () => {
bottomPanelStore.togglePanel('shortcuts')
}
const formatKeySequence = (sequences: string[]): string => {
return sequences
.map((seq) => seq.replace(/Control/g, 'Ctrl').replace(/Shift/g, 'Shift'))
.join(' + ')
}
</script>

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

@@ -0,0 +1,78 @@
import { type ComputedRef, computed } from 'vue'
import { type ComfyCommandImpl } from '@/stores/commandStore'
export type SubcategoryRule = {
pattern: string | RegExp
subcategory: string
}
export type SubcategoryConfig = {
defaultSubcategory: string
rules: SubcategoryRule[]
}
/**
* Composable for grouping commands by subcategory based on configurable rules
*/
export function useCommandSubcategories(
commands: ComputedRef<ComfyCommandImpl[]>,
config: SubcategoryConfig
) {
const subcategories = computed(() => {
const result: Record<string, ComfyCommandImpl[]> = {}
for (const command of commands.value) {
let subcategory = config.defaultSubcategory
// Find the first matching rule
for (const rule of config.rules) {
const matches =
typeof rule.pattern === 'string'
? command.id.includes(rule.pattern)
: rule.pattern.test(command.id)
if (matches) {
subcategory = rule.subcategory
break
}
}
if (!result[subcategory]) {
result[subcategory] = []
}
result[subcategory].push(command)
}
return result
})
return {
subcategories
}
}
/**
* Predefined configuration for view controls subcategories
*/
export const VIEW_CONTROLS_CONFIG: SubcategoryConfig = {
defaultSubcategory: 'view',
rules: [
{ pattern: 'Zoom', subcategory: 'view' },
{ pattern: 'Fit', subcategory: 'view' },
{ pattern: 'Panel', subcategory: 'panel-controls' },
{ pattern: 'Sidebar', subcategory: 'panel-controls' }
]
}
/**
* Predefined configuration for essentials subcategories
*/
export const ESSENTIALS_CONFIG: SubcategoryConfig = {
defaultSubcategory: 'workflow',
rules: [
{ pattern: 'Workflow', subcategory: 'workflow' },
{ pattern: 'Node', subcategory: 'node' },
{ pattern: 'Queue', subcategory: 'queue' }
]
}

View File

@@ -0,0 +1,27 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ViewControlsPanel from '@/components/bottomPanel/tabs/shortcuts/ViewControlsPanel.vue'
import { BottomPanelExtension } from '@/types/extensionTypes'
export const useShortcutsTab = (): BottomPanelExtension[] => {
const { t } = useI18n()
return [
{
id: 'shortcuts-essentials',
title: t('shortcuts.essentials'),
component: markRaw(EssentialsPanel),
type: 'vue',
targetPanel: 'shortcuts'
},
{
id: 'shortcuts-view-controls',
title: t('shortcuts.viewControls'),
component: markRaw(ViewControlsPanel),
type: 'vue',
targetPanel: 'shortcuts'
}
]
}

View File

@@ -261,7 +261,10 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.14-2.80/Run (varies with model, mode & duration)'
const modelValue = String(modelWidget.value)
if (modelValue.includes('v2-master')) {
if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
return '$1.40/Run'
} else if (
modelValue.includes('v1-6') ||
@@ -280,12 +283,19 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
console.log('durationValue', durationValue)
// Same pricing matrix as KlingTextToVideoNode
if (modelValue.includes('v2-master')) {
if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
if (durationValue.includes('10')) {
return '$2.80/Run'
}
return '$1.40/Run' // 5s default
} else if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) {
} else if (
modelValue.includes('v2-1') ||
modelValue.includes('v1-6') ||
modelValue.includes('v1-5')
) {
if (modeValue.includes('pro')) {
return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run'
} else {
@@ -418,7 +428,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
// Pricing matrix from CSV data based on mode string content
if (modeValue.includes('v2-master')) {
if (modeValue.includes('v2-1-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run' // price is the same as for v2-master model
}
return '$1.40/Run' // price is the same as for v2-master model
} else if (modeValue.includes('v2-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run'
}
@@ -558,6 +573,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
MinimaxTextToVideoNode: {
displayPrice: '$0.43/Run'
},
MinimaxHailuoVideoNode: {
displayPrice: (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!resolutionWidget || !durationWidget) {
return '$0.28-0.56/Run (varies with resolution & duration)'
}
const resolution = String(resolutionWidget.value)
const duration = String(durationWidget.value)
if (resolution.includes('768P')) {
if (duration.includes('6')) return '$0.28/Run'
if (duration.includes('10')) return '$0.56/Run'
} else if (resolution.includes('1080P')) {
if (duration.includes('6')) return '$0.49/Run'
}
return '$0.43/Run' // default median
}
},
OpenAIDalle2: {
displayPrice: (node: LGraphNode): string => {
const sizeWidget = node.widgets?.find(
@@ -1278,9 +1319,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
// Google Veo video generation
if (model.includes('veo-2.0')) {
return '$0.5/second'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.00016/$0.0006 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.0003/$0.0025 per 1K tokens'
} else if (model.includes('gemini-2.5-flash')) {
return '$0.0003/$0.0025 per 1K tokens'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.00125/$0.01 per 1K tokens'
} else if (model.includes('gemini-2.5-pro')) {
return '$0.00125/$0.01 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
@@ -1317,6 +1362,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.0004/$0.0016 per 1K tokens'
} else if (model.includes('gpt-4.1')) {
return '$0.002/$0.008 per 1K tokens'
} else if (model.includes('gpt-5-nano')) {
return '$0.00005/$0.0004 per 1K tokens'
} else if (model.includes('gpt-5-mini')) {
return '$0.00025/$0.002 per 1K tokens'
} else if (model.includes('gpt-5')) {
return '$0.00125/$0.01 per 1K tokens'
}
return 'Token-based'
}
@@ -1358,6 +1409,7 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIGPTImage1: ['quality', 'n'],

View File

@@ -21,8 +21,10 @@ import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useToastStore } from '@/stores/toastStore'
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -46,6 +48,9 @@ export function useCoreCommands(): ComfyCommand[] {
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const bottomPanelStore = useBottomPanelStore()
const { getSelectedNodes, toggleSelectedNodesMode } =
useSelectedLiteGraphItems()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
@@ -70,6 +75,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-plus',
label: 'New Blank Workflow',
menubarLabel: 'New',
category: 'essentials' as const,
function: () => workflowService.loadBlankWorkflow()
},
{
@@ -77,6 +83,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-folder-open',
label: 'Open Workflow',
menubarLabel: 'Open',
category: 'essentials' as const,
function: () => {
app.ui.loadFile()
}
@@ -92,6 +99,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-save',
label: 'Save Workflow',
menubarLabel: 'Save',
category: 'essentials' as const,
function: async () => {
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
if (!workflow) return
@@ -104,6 +112,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-save',
label: 'Save Workflow As',
menubarLabel: 'Save As',
category: 'essentials' as const,
function: async () => {
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
if (!workflow) return
@@ -116,6 +125,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-download',
label: 'Export Workflow',
menubarLabel: 'Export',
category: 'essentials' as const,
function: async () => {
await workflowService.exportWorkflow('workflow', 'workflow')
}
@@ -133,6 +143,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Undo',
icon: 'pi pi-undo',
label: 'Undo',
category: 'essentials' as const,
function: async () => {
await getTracker()?.undo?.()
}
@@ -141,6 +152,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Redo',
icon: 'pi pi-refresh',
label: 'Redo',
category: 'essentials' as const,
function: async () => {
await getTracker()?.redo?.()
}
@@ -149,6 +161,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.ClearWorkflow',
icon: 'pi pi-trash',
label: 'Clear Workflow',
category: 'essentials' as const,
function: () => {
const settingStore = useSettingStore()
if (
@@ -190,6 +203,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.RefreshNodeDefinitions',
icon: 'pi pi-refresh',
label: 'Refresh Node Definitions',
category: 'essentials' as const,
function: async () => {
await app.refreshComboInNodes()
}
@@ -198,6 +212,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Interrupt',
icon: 'pi pi-stop',
label: 'Interrupt',
category: 'essentials' as const,
function: async () => {
await api.interrupt(executionStore.activePromptId)
toastStore.add({
@@ -212,6 +227,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.ClearPendingTasks',
icon: 'pi pi-stop',
label: 'Clear Pending Tasks',
category: 'essentials' as const,
function: async () => {
await useQueueStore().clear(['queue'])
toastStore.add({
@@ -234,6 +250,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.ZoomIn',
icon: 'pi pi-plus',
label: 'Zoom In',
category: 'view-controls' as const,
function: () => {
const ds = app.canvas.ds
ds.changeScale(
@@ -247,6 +264,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.ZoomOut',
icon: 'pi pi-minus',
label: 'Zoom Out',
category: 'view-controls' as const,
function: () => {
const ds = app.canvas.ds
ds.changeScale(
@@ -260,6 +278,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.FitView',
icon: 'pi pi-expand',
label: 'Fit view to selected nodes',
category: 'view-controls' as const,
function: () => {
if (app.canvas.empty) {
toastStore.add({
@@ -325,6 +344,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-play',
label: 'Queue Prompt',
versionAdded: '1.3.7',
category: 'essentials' as const,
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
await app.queuePrompt(0, batchCount)
@@ -335,6 +355,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-play',
label: 'Queue Prompt (Front)',
versionAdded: '1.3.7',
category: 'essentials' as const,
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
await app.queuePrompt(-1, batchCount)
@@ -371,6 +392,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-cog',
label: 'Show Settings Dialog',
versionAdded: '1.3.7',
category: 'view-controls' as const,
function: () => {
dialogService.showSettingsDialog()
}
@@ -380,6 +402,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-sitemap',
label: 'Group Selected Nodes',
versionAdded: '1.3.7',
category: 'essentials' as const,
function: () => {
const { canvas } = app
if (!canvas.selectedItems?.size) {
@@ -423,6 +446,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-volume-off',
label: 'Mute/Unmute Selected Nodes',
versionAdded: '1.3.11',
category: 'essentials' as const,
function: () => {
toggleSelectedNodesMode(LGraphEventMode.NEVER)
app.canvas.setDirty(true, true)
@@ -433,6 +457,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-shield',
label: 'Bypass/Unbypass Selected Nodes',
versionAdded: '1.3.11',
category: 'essentials' as const,
function: () => {
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true)
@@ -443,6 +468,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-pin',
label: 'Pin/Unpin Selected Nodes',
versionAdded: '1.3.11',
category: 'essentials' as const,
function: () => {
getSelectedNodes().forEach((node) => {
node.pin(!node.pinned)
@@ -516,8 +542,9 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-list',
label: 'Toggle Bottom Panel',
versionAdded: '1.3.22',
category: 'view-controls' as const,
function: () => {
useBottomPanelStore().toggleBottomPanel()
bottomPanelStore.toggleBottomPanel()
}
},
{
@@ -525,6 +552,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-eye',
label: 'Toggle Focus Mode',
versionAdded: '1.3.27',
category: 'view-controls' as const,
function: () => {
useWorkspaceStore().toggleFocusMode()
}
@@ -750,6 +778,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-sitemap',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
@@ -767,6 +796,48 @@ export function useCoreCommands(): ComfyCommand[] {
}
const { node } = res
canvas.select(node)
canvasStore.updateSelectedItems()
}
},
{
id: 'Comfy.Graph.UnpackSubgraph',
icon: 'pi pi-sitemap',
label: 'Unpack the selected Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const subgraphNode = app.canvas.selectedItems.values().next().value
useNodeOutputStore().revokeSubgraphPreviews(subgraphNode)
graph.unpackSubgraph(subgraphNode)
}
},
{
id: 'Workspace.ToggleBottomPanel.Shortcuts',
icon: 'pi pi-key',
label: 'Show Keybindings Dialog',
versionAdded: '1.24.1',
category: 'view-controls' as const,
function: () => {
bottomPanelStore.togglePanel('shortcuts')
}
},
{
id: 'Comfy.Graph.ExitSubgraph',
icon: 'pi pi-arrow-up',
label: 'Exit Subgraph',
versionAdded: '1.20.1',
function: () => {
const canvas = useCanvasStore().getCanvas()
const navigationStore = useSubgraphNavigationStore()
if (!canvas.graph) return
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
}
]

View File

@@ -2,13 +2,14 @@ import { useRafFn, useThrottleFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
@@ -16,9 +17,17 @@ interface GraphCallbacks {
onConnectionChange?: (node: LGraphNode) => void
}
export type MinimapOptionKey =
| 'Comfy.Minimap.NodeColors'
| 'Comfy.Minimap.ShowLinks'
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const colorPaletteStore = useColorPaletteStore()
const containerRef = ref<HTMLDivElement>()
@@ -27,6 +36,27 @@ export function useMinimap() {
const visible = ref(true)
const nodeColors = computed(() =>
settingStore.get('Comfy.Minimap.NodeColors')
)
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
const showGroups = computed(() =>
settingStore.get('Comfy.Minimap.ShowGroups')
)
const renderBypass = computed(() =>
settingStore.get('Comfy.Minimap.RenderBypassState')
)
const renderError = computed(() =>
settingStore.get('Comfy.Minimap.RenderErrorState')
)
const updateOption = async (key: MinimapOptionKey, value: boolean) => {
await settingStore.set(key, value)
needsFullRedraw.value = true
updateMinimap()
}
const initialized = ref(false)
const bounds = ref({
minX: 0,
@@ -63,10 +93,22 @@ export function useMinimap() {
const nodeColor = computed(
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
)
const nodeColorDefault = computed(
() => (isLightTheme.value ? '#D9D9D9' : '#353535') // this is the default node color when using nodeColors setting
)
const linkColor = computed(
() => (isLightTheme.value ? '#FFB347' : '#F99614') // lighter orange for light theme
() => (isLightTheme.value ? '#616161' : '#B3B3B3') // lighter orange for light theme
)
const slotColor = computed(() => linkColor.value)
const groupColor = computed(() =>
isLightTheme.value ? '#A2D3EC' : '#1F547A'
)
const groupColorDefault = computed(
() => (isLightTheme.value ? '#283640' : '#B3C1CB') // this is the default group color when using nodeColors setting
)
const bypassColor = computed(() =>
isLightTheme.value ? '#DBDBDB' : '#4B184B'
)
const containerRect = ref({
left: 0,
@@ -106,7 +148,11 @@ export function useMinimap() {
}
const canvas = computed(() => canvasStore.canvas)
const graph = ref(app.canvas?.graph)
const graph = computed(() => {
// If we're in a subgraph, use that; otherwise use the canvas graph
const activeSubgraph = workflowStore.activeSubgraph
return activeSubgraph || canvas.value?.graph
})
const containerStyles = computed(() => ({
width: `${width}px`,
@@ -116,6 +162,14 @@ export function useMinimap() {
borderRadius: '8px'
}))
const panelStyles = computed(() => ({
width: `210px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
const viewportStyles = computed(() => ({
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
@@ -189,6 +243,35 @@ export function useMinimap() {
return Math.min(scaleX, scaleY) * 0.9
}
const renderGroups = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g || !g._groups || g._groups.length === 0) return
for (const group of g._groups) {
const x = (group.pos[0] - bounds.value.minX) * scale.value + offsetX
const y = (group.pos[1] - bounds.value.minY) * scale.value + offsetY
const w = group.size[0] * scale.value
const h = group.size[1] * scale.value
let color = groupColor.value
if (nodeColors.value) {
color = group.color ?? groupColorDefault.value
if (isLightTheme.value) {
color = adjustColor(color, { opacity: 0.5 })
}
}
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
}
}
const renderNodes = (
ctx: CanvasRenderingContext2D,
offsetX: number,
@@ -203,9 +286,29 @@ export function useMinimap() {
const w = node.size[0] * scale.value
const h = node.size[1] * scale.value
let color = nodeColor.value
if (renderBypass.value && node.mode === LGraphEventMode.BYPASS) {
color = bypassColor.value
} else if (nodeColors.value) {
color = nodeColorDefault.value
if (node.bgcolor) {
color = isLightTheme.value
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
}
// Render solid node blocks
ctx.fillStyle = nodeColor.value
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
if (renderError.value && node.has_errors) {
ctx.strokeStyle = '#FF0000'
ctx.lineWidth = 0.3
ctx.strokeRect(x, y, w, h)
}
}
}
@@ -218,9 +321,9 @@ export function useMinimap() {
if (!g) return
ctx.strokeStyle = linkColor.value
ctx.lineWidth = 1.4
ctx.lineWidth = 0.3
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
const slotRadius = Math.max(scale.value, 0.5) // Larger slots that scale
const connections: Array<{
x1: number
y1: number
@@ -304,8 +407,15 @@ export function useMinimap() {
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
if (showGroups.value) {
renderGroups(ctx, offsetX, offsetY)
}
if (showLinks.value) {
renderConnections(ctx, offsetX, offsetY)
}
renderNodes(ctx, offsetX, offsetY)
renderConnections(ctx, offsetX, offsetY)
needsFullRedraw.value = false
updateFlags.value.nodes = false
@@ -522,7 +632,8 @@ export function useMinimap() {
c.setDirty(true, true)
}
let originalCallbacks: GraphCallbacks = {}
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
const handleGraphChanged = useThrottleFn(() => {
needsFullRedraw.value = true
@@ -536,11 +647,18 @@ export function useMinimap() {
const g = graph.value
if (!g) return
originalCallbacks = {
// Check if we've already wrapped this graph's callbacks
if (originalCallbacksMap.has(g.id)) {
return
}
// Store the original callbacks for this graph
const originalCallbacks: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
}
originalCallbacksMap.set(g.id, originalCallbacks)
g.onNodeAdded = function (node) {
originalCallbacks.onNodeAdded?.call(this, node)
@@ -565,15 +683,18 @@ export function useMinimap() {
const g = graph.value
if (!g) return
if (originalCallbacks.onNodeAdded !== undefined) {
g.onNodeAdded = originalCallbacks.onNodeAdded
}
if (originalCallbacks.onNodeRemoved !== undefined) {
g.onNodeRemoved = originalCallbacks.onNodeRemoved
}
if (originalCallbacks.onConnectionChange !== undefined) {
g.onConnectionChange = originalCallbacks.onConnectionChange
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
throw new Error(
'Attempted to cleanup event listeners for graph that was never set up'
)
}
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
originalCallbacksMap.delete(g.id)
}
const init = async () => {
@@ -646,6 +767,19 @@ export function useMinimap() {
{ immediate: true, flush: 'post' }
)
// Watch for graph changes (e.g., when navigating to/from subgraphs)
watch(graph, (newGraph, oldGraph) => {
if (newGraph && newGraph !== oldGraph) {
cleanupEventListeners()
setupEventListeners()
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateMinimap()
}
})
watch(visible, async (isVisible) => {
if (isVisible) {
if (containerRef.value) {
@@ -690,16 +824,25 @@ export function useMinimap() {
canvasRef,
containerStyles,
viewportStyles,
panelStyles,
width,
height,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
init,
destroy,
toggle,
renderMinimap,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef
setMinimapRef,
updateOption
}
}

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

@@ -182,5 +182,19 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
alt: true
},
commandId: 'Comfy.Canvas.ToggleMinimap'
},
{
combo: {
ctrl: true,
shift: true,
key: 'k'
},
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
},
{
combo: {
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
}
]

View File

@@ -300,7 +300,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' }
{ value: 'es', text: 'Español' },
{ value: 'ar', text: 'عربي' }
],
defaultValue: () => navigator.language.split('-')[0] || 'en'
},
@@ -830,6 +831,41 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.25.0'
},
{
id: 'Comfy.Minimap.NodeColors',
name: 'Display node with its original color on minimap',
type: 'hidden',
defaultValue: false,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.ShowLinks',
name: 'Display links on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.ShowGroups',
name: 'Display node groups on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.RenderBypassState',
name: 'Render bypass state on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.RenderErrorState',
name: 'Render error state on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Workflow.AutoSaveDelay',
name: 'Auto Save Delay (ms)',

View File

@@ -3,6 +3,7 @@ import { type NodeId } from '@/lib/litegraph/src/LGraphNode'
import {
type ExecutableLGraphNode,
type ExecutionId,
LGraphCanvas,
LGraphNode,
LiteGraph,
SubgraphNode
@@ -1172,8 +1173,7 @@ export class GroupNodeHandler {
// @ts-expect-error fixme ts strict error
getExtraMenuOptions?.apply(this, arguments)
// @ts-expect-error fixme ts strict error
let optionIndex = options.findIndex((o) => o.content === 'Outputs')
let optionIndex = options.findIndex((o) => o?.content === 'Outputs')
if (optionIndex === -1) optionIndex = options.length
else optionIndex++
options.splice(
@@ -1634,6 +1634,57 @@ export class GroupNodeHandler {
}
}
function addConvertToGroupOptions() {
// @ts-expect-error fixme ts strict error
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {})
const disabled =
selected.length < 2 ||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
options.splice(index, null, {
content: `Convert to Group Node (Deprecated)`,
disabled,
callback: convertSelectedNodesToGroupNode
})
}
// @ts-expect-error fixme ts strict error
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes
const disabled = !groups || !Object.keys(groups).length
options.splice(index, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => manageGroupNodes()
})
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
// @ts-expect-error fixme ts strict error
const options = getCanvasMenuOptions.apply(this, arguments)
const index = options.findIndex((o) => o?.content === 'Add Group')
const insertAt = index === -1 ? options.length - 1 : index + 2
addConvertOption(options, insertAt)
addManageOption(options, insertAt + 1)
return options
}
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
// @ts-expect-error fixme ts strict error
const options = getNodeMenuOptions.apply(this, arguments)
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === 'Properties')
const insertAt = index === -1 ? options.length - 1 : index
addConvertOption(options, insertAt)
}
return options
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
@@ -1729,6 +1780,9 @@ const ext: ComfyExtension = {
}
}
],
setup() {
addConvertToGroupOptions()
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]

View File

@@ -3976,13 +3976,19 @@ class UIManager {
const mainImageFilename =
new URL(mainImageUrl).searchParams.get('filename') ?? undefined
const combinedImageFilename =
let combinedImageFilename: string | null | undefined
if (
ComfyApp.clipspace?.combinedIndex !== undefined &&
ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace.combinedIndex]?.src
? new URL(
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
).searchParams.get('filename')
: undefined
ComfyApp.clipspace?.imgs &&
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length &&
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src
) {
combinedImageFilename = new URL(
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
).searchParams.get('filename')
} else {
combinedImageFilename = undefined
}
const imageLayerFilenames =
mainImageFilename !== undefined

View File

@@ -1,5 +1,9 @@
import { createI18n } from 'vue-i18n'
import arCommands from './locales/ar/commands.json'
import ar from './locales/ar/main.json'
import arNodes from './locales/ar/nodeDefs.json'
import arSettings from './locales/ar/settings.json'
import enCommands from './locales/en/commands.json'
import en from './locales/en/main.json'
import enNodes from './locales/en/nodeDefs.json'
@@ -50,7 +54,8 @@ const messages = {
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
ko: buildLocale(ko, koNodes, koCommands, koSettings),
fr: buildLocale(fr, frNodes, frCommands, frSettings),
es: buildLocale(es, esNodes, esCommands, esSettings)
es: buildLocale(es, esNodes, esCommands, esSettings),
ar: buildLocale(ar, arNodes, arCommands, arSettings)
}
export const i18n = createI18n({

View File

@@ -495,6 +495,16 @@
padding-left: 12px;
}
.graphmenu-entry.danger,
.litemenu-entry.danger {
color: var(--error-text) !important;
}
.litegraph .litemenu-entry.danger:hover:not(.disabled) {
color: var(--error-text) !important;
opacity: 0.8;
}
.graphmenu-entry.disabled {
opacity: 0.3;
}

View File

@@ -43,6 +43,19 @@ export class CanvasPointer {
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
static #maxClickDrift2 = this.#maxClickDrift ** 2
/** Assume that "wheel" events with both deltaX and deltaY less than this value are trackpad gestures. */
static trackpadThreshold = 60
/**
* The minimum time between "wheel" events to allow switching between trackpad
* and mouse modes.
*
* This prevents trackpad "flick" panning from registering as regular mouse wheel.
* After a flick gesture is complete, the automatic wheel events are sent with
* reduced frequency, but much higher deltaX and deltaY values.
*/
static trackpadMaxGap = 200
/** The element this PointerState should capture input against when dragging. */
element: Element
/** Pointer ID used by drag capture. */
@@ -77,6 +90,9 @@ export class CanvasPointer {
/** The last pointerup event for the primary button */
eUp?: CanvasPointerEvent
/** The last pointermove event that was treated as a trackpad gesture. */
lastTrackpadEvent?: WheelEvent
/**
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
* @param pointer [DEPRECATED] This parameter will be removed in a future release.
@@ -257,6 +273,35 @@ export class CanvasPointer {
delete this.onDragStart
}
/**
* Checks if the given wheel event is part of a continued trackpad gesture.
* @param e The wheel event to check
* @returns `true` if the event is part of a continued trackpad gesture, otherwise `false`
*/
#isContinuationOfGesture(e: WheelEvent): boolean {
const { lastTrackpadEvent } = this
if (!lastTrackpadEvent) return false
return (
e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap
)
}
/**
* Checks if the given wheel event is part of a trackpad gesture.
* @param e The wheel event to check
* @returns `true` if the event is part of a trackpad gesture, otherwise `false`
*/
isTrackpadGesture(e: WheelEvent): boolean {
if (this.#isContinuationOfGesture(e)) {
this.lastTrackpadEvent = e
return true
}
const threshold = CanvasPointer.trackpadThreshold
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
}
/**
* Resets the state of this {@link CanvasPointer} instance.
*

View File

@@ -18,6 +18,7 @@ import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
import type {
DefaultConnectionColors,
Dictionary,
HasBoundingRect,
IContextMenuValue,
INodeInputSlot,
INodeOutputSlot,
@@ -26,7 +27,8 @@ import type {
MethodNames,
OptionalProps,
Point,
Positionable
Positionable,
Size
} from './interfaces'
import { LiteGraph, SubgraphNode } from './litegraph'
import {
@@ -1568,6 +1570,9 @@ export class LGraph
boundingRect
)
//Correct for title height. It's included in bounding box, but not _posSize
subgraphNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2
// Add the subgraph node to the graph
this.add(subgraphNode)
@@ -1663,6 +1668,271 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
unpackSubgraph(subgraphNode: SubgraphNode) {
if (!(subgraphNode instanceof SubgraphNode))
throw new Error('Can only unpack Subgraph Nodes')
this.beforeChange()
//NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized.
//NOTE: NODE_TITLE_HEIGHT is explicitly excluded here
const positionables = [
...subgraphNode.subgraph.nodes,
...subgraphNode.subgraph.reroutes.values(),
...subgraphNode.subgraph.groups
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
return {
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
}
})
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]
const center = [bounds[0] + bounds[2] / 2, bounds[1] + bounds[3] / 2]
const toSelect: Positionable[] = []
const offsetX = subgraphNode.pos[0] - center[0] + subgraphNode.size[0] / 2
const offsetY = subgraphNode.pos[1] - center[1] + subgraphNode.size[1] / 2
const movedNodes = multiClone(subgraphNode.subgraph.nodes)
const nodeIdMap = new Map<NodeId, NodeId>()
for (const n_info of movedNodes) {
const node = LiteGraph.createNode(String(n_info.type), n_info.title)
if (!node) {
throw new Error('Node not found')
}
nodeIdMap.set(n_info.id, ++this.last_node_id)
node.id = this.last_node_id
n_info.id = this.last_node_id
this.add(node, true)
node.configure(n_info)
node.pos[0] += offsetX
node.pos[1] += offsetY
for (const input of node.inputs) {
input.link = null
}
for (const output of node.outputs) {
output.links = []
}
toSelect.push(node)
}
const groups = structuredClone(
[...subgraphNode.subgraph.groups].map((g) => g.serialize())
)
for (const g_info of groups) {
const group = new LGraphGroup(g_info.title, g_info.id)
this.add(group, true)
group.configure(g_info)
group.pos[0] += offsetX
group.pos[1] += offsetY
toSelect.push(group)
}
//cleanup reoute.linkIds now, but leave link.parentIds dangling
for (const islot of subgraphNode.inputs) {
if (!islot.link) continue
const link = this.links.get(islot.link)
if (!link) {
console.warn('Broken link', islot, islot.link)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
for (const oslot of subgraphNode.outputs) {
for (const linkId of oslot.links ?? []) {
const link = this.links.get(linkId)
if (!link) {
console.warn('Broken link', oslot, linkId)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
}
const newLinks: {
oid: NodeId
oslot: number
tid: NodeId
tslot: number
id: LinkId
iparent?: RerouteId
eparent?: RerouteId
externalFirst: boolean
}[] = []
for (const [, link] of subgraphNode.subgraph._links) {
let externalParentId: RerouteId | undefined
if (link.origin_id === SUBGRAPH_INPUT_ID) {
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
if (!outerLinkId) {
console.error('Missing Link ID when unpacking')
continue
}
const outerLink = this.links[outerLinkId]
link.origin_id = outerLink.origin_id
link.origin_slot = outerLink.origin_slot
externalParentId = outerLink.parentId
} else {
const origin_id = nodeIdMap.get(link.origin_id)
if (!origin_id) {
console.error('Missing Link ID when unpacking')
continue
}
link.origin_id = origin_id
}
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
[]) {
const sublink = this.links[linkId]
newLinks.push({
oid: link.origin_id,
oslot: link.origin_slot,
tid: sublink.target_id,
tslot: sublink.target_slot,
id: link.id,
iparent: link.parentId,
eparent: sublink.parentId,
externalFirst: true
})
sublink.parentId = undefined
}
continue
} else {
const target_id = nodeIdMap.get(link.target_id)
if (!target_id) {
console.error('Missing Link ID when unpacking')
continue
}
link.target_id = target_id
}
newLinks.push({
oid: link.origin_id,
oslot: link.origin_slot,
tid: link.target_id,
tslot: link.target_slot,
id: link.id,
iparent: link.parentId,
eparent: externalParentId,
externalFirst: false
})
}
this.remove(subgraphNode)
this.subgraphs.delete(subgraphNode.subgraph.id)
const linkIdMap = new Map<LinkId, LinkId[]>()
for (const newLink of newLinks) {
let created: LLink | null | undefined
if (newLink.oid == SUBGRAPH_INPUT_ID) {
if (!(this instanceof Subgraph)) {
console.error('Ignoring link to subgraph outside subgraph')
continue
}
const tnode = this._nodes_by_id[newLink.tid]
created = this.inputNode.slots[newLink.oslot].connect(
tnode.inputs[newLink.tslot],
tnode
)
} else if (newLink.tid == SUBGRAPH_OUTPUT_ID) {
if (!(this instanceof Subgraph)) {
console.error('Ignoring link to subgraph outside subgraph')
continue
}
const tnode = this._nodes_by_id[newLink.oid]
created = this.outputNode.slots[newLink.tslot].connect(
tnode.outputs[newLink.oslot],
tnode
)
} else {
created = this._nodes_by_id[newLink.oid].connect(
newLink.oslot,
this._nodes_by_id[newLink.tid],
newLink.tslot
)
}
if (!created) {
console.error('Failed to create link')
continue
}
//This is a little unwieldy since Map.has isn't a type guard
const linkIds = linkIdMap.get(newLink.id) ?? []
linkIds.push(created.id)
if (!linkIdMap.has(newLink.id)) {
linkIdMap.set(newLink.id, linkIds)
}
newLink.id = created.id
}
const rerouteIdMap = new Map<RerouteId, RerouteId>()
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
if (
reroute.parentId !== undefined &&
rerouteIdMap.get(reroute.parentId) === undefined
) {
console.error('Missing Parent ID')
}
const migratedReroute = new Reroute(++this.state.lastRerouteId, this, [
reroute.pos[0] + offsetX,
reroute.pos[1] + offsetY
])
rerouteIdMap.set(reroute.id, migratedReroute.id)
this.reroutes.set(migratedReroute.id, migratedReroute)
toSelect.push(migratedReroute)
}
//iterate over newly created links to update reroute parentIds
for (const newLink of newLinks) {
const linkInstance = this.links.get(newLink.id)
if (!linkInstance) {
continue
}
let instance: Reroute | LLink | undefined = linkInstance
let parentId: RerouteId | undefined = undefined
if (newLink.externalFirst) {
parentId = newLink.eparent
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
parentId = newLink.iparent
while (parentId) {
const migratedId = rerouteIdMap.get(parentId)
if (!migratedId) throw new Error('Broken Id link when unpacking')
instance.parentId = migratedId
instance = this.reroutes.get(migratedId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
if (!oldReroute) throw new Error('Broken Id link when unpacking')
parentId = oldReroute.parentId
}
if (!newLink.externalFirst) {
parentId = newLink.eparent
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
}
for (const nodeId of nodeIdMap.values()) {
const node = this._nodes_by_id[nodeId]
node._setConcreteSlots()
node.arrange()
}
this.canvasAction((c) => c.selectItems(toSelect))
this.afterChange()
}
/**
* Resolve a path of subgraph node IDs into a list of subgraph nodes.
* Not intended to be run from subgraphs.
@@ -2331,6 +2601,9 @@ export class Subgraph
nodes: this.nodes.map((node) => node.serialize()),
groups: this.groups.map((group) => group.serialize()),
links: [...this.links.values()].map((x) => x.asSerialisable()),
reroutes: this.reroutes.size
? [...this.reroutes.values()].map((x) => x.asSerialisable())
: undefined,
extra: this.extra
}
}

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,7 +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 = window?.devicePixelRatio || 1
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
@@ -3453,10 +3455,6 @@ export class LGraphCanvas
processMouseWheel(e: WheelEvent): void {
if (!this.graph || !this.allow_dragcanvas) return
// TODO: Mouse wheel zoom rewrite
// @ts-expect-error wheelDeltaY is non-standard property on WheelEvent
const delta = e.wheelDeltaY ?? e.detail * -60
this.adjustMouseEvent(e)
const pos: Point = [e.clientX, e.clientY]
@@ -3464,35 +3462,34 @@ export class LGraphCanvas
let { scale } = this.ds
if (
LiteGraph.canvasNavigationMode === 'legacy' ||
(LiteGraph.canvasNavigationMode === 'standard' && e.ctrlKey)
) {
if (delta > 0) {
scale *= this.zoom_speed
} else if (delta < 0) {
scale *= 1 / this.zoom_speed
}
this.ds.changeScale(scale, [e.clientX, e.clientY])
} else if (
LiteGraph.macTrackpadGestures &&
(!LiteGraph.macGesturesRequireMac || navigator.userAgent.includes('Mac'))
) {
if (e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
if (e.deltaY > 0) {
scale *= 1 / this.zoom_speed
} else if (e.deltaY < 0) {
scale *= this.zoom_speed
}
this.ds.changeScale(scale, [e.clientX, e.clientY])
} else if (e.ctrlKey) {
// Detect if this is a trackpad gesture or mouse wheel
const isTrackpad = this.pointer.isTrackpadGesture(e)
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
// Legacy mode or standard mode with ctrl - use wheel for zoom
if (isTrackpad) {
// Trackpad gesture - use smooth scaling
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
} else if (e.shiftKey) {
this.ds.offset[0] -= e.deltaY * 1.18 * (1 / scale)
} else {
this.ds.offset[0] -= e.deltaX * 1.18 * (1 / scale)
this.ds.offset[1] -= e.deltaY * 1.18 * (1 / scale)
// Mouse wheel - use stepped scaling
if (e.deltaY < 0) {
scale *= this.zoom_speed
} else if (e.deltaY > 0) {
scale *= 1 / this.zoom_speed
}
this.ds.changeScale(scale, [e.clientX, e.clientY])
}
} else {
// Standard mode without ctrl - use wheel / gestures to pan
// Trackpads and mice work on significantly different scales
const factor = isTrackpad ? 0.18 : 0.008_333
if (!isTrackpad && e.shiftKey && e.deltaX === 0) {
this.ds.offset[0] -= e.deltaY * (1 + factor) * (1 / scale)
} else {
this.ds.offset[0] -= e.deltaX * (1 + factor) * (1 / scale)
this.ds.offset[1] -= e.deltaY * (1 + factor) * (1 / scale)
}
}
@@ -3610,6 +3607,7 @@ export class LGraphCanvas
subgraphs: []
}
// NOTE: logic for traversing nested subgraphs depends on this being a set.
const subgraphs = new Set<Subgraph>()
// Create serialisable objects
@@ -3648,8 +3646,13 @@ export class LGraphCanvas
}
// Add unique subgraph entries
// TODO: Must find all nested subgraphs
// NOTE: subgraphs is appended to mid iteration.
for (const subgraph of subgraphs) {
for (const node of subgraph.nodes) {
if (node instanceof SubgraphNode) {
subgraphs.add(node.subgraph)
}
}
const cloned = subgraph.clone(true).asSerialisable()
serialisable.subgraphs.push(cloned)
}
@@ -3766,12 +3769,19 @@ export class LGraphCanvas
created.push(group)
}
// Update subgraph ids with nesting
function updateSubgraphIds(nodes: { type: string }[]) {
for (const info of nodes) {
const subgraph = results.subgraphs.get(info.type)
if (!subgraph) continue
info.type = subgraph.id
updateSubgraphIds(subgraph.nodes)
}
}
updateSubgraphIds(parsed.nodes)
// Nodes
for (const info of parsed.nodes) {
// If the subgraph was cloned, update references to use the new subgraph ID.
const subgraph = results.subgraphs.get(info.type)
if (subgraph) info.type = subgraph.id
const node = info.type == null ? null : LiteGraph.createNode(info.type)
if (!node) {
// failedNodes.push(info)
@@ -8063,18 +8073,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: () => {
@@ -8242,15 +8240,19 @@ export class LGraphCanvas
'Both in put and output slots were null when processing context menu.'
)
if (_slot.removable) {
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 (_slot.removable) {
menu_info.push(null)
menu_info.push(
_slot.locked
? 'Cannot remove'
: { content: 'Remove Slot', slot, className: 'danger' }
)
}
if (node.getExtraSlotMenuOptions) {
menu_info.push(...node.getExtraSlotMenuOptions(slot))
}

View File

@@ -414,6 +414,18 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
* If `input` or `output`, reroutes will not be automatically removed, and retain a connection to the input or output, respectively.
*/
disconnect(network: LinkNetwork, keepReroutes?: 'input' | 'output'): void {
// Clean up the target node's input slot
if (this.target_id !== -1) {
const targetNode = network.getNodeById(this.target_id)
if (targetNode) {
const targetInput = targetNode.inputs?.[this.target_slot]
if (targetInput && targetInput.link === this.id) {
targetInput.link = null
targetNode.setDirtyCanvas?.(true, false)
}
}
}
const reroutes = LLink.getReroutes(network, this)
const lastReroute = reroutes.at(-1)

View File

@@ -284,6 +284,7 @@ export class LiteGraphGlobal {
]
/**
* @deprecated Removed; has no effect.
* If `true`, mouse wheel events will be interpreted as trackpad gestures.
* Tested on MacBook M4 Pro.
* @default false
@@ -292,6 +293,7 @@ export class LiteGraphGlobal {
macTrackpadGestures: boolean = false
/**
* @deprecated Removed; has no effect.
* If both this setting and {@link macTrackpadGestures} are `true`, trackpad gestures will
* only be enabled when the browser user agent includes "Mac".
* @default true

View File

@@ -135,6 +135,10 @@ export class FloatingRenderLink implements RenderLink {
return true
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return this.toType === 'output' && input.isValidTarget(this.fromSlot)
}
connectToInput(
node: LGraphNode,
input: INodeInputSlot,

View File

@@ -651,6 +651,20 @@ export class LinkConnector {
if (!input) throw new Error('No input slot found for link.')
for (const link of renderLinks) {
// Validate the connection type before proceeding
if (
'canConnectToSubgraphInput' in link &&
!link.canConnectToSubgraphInput(input)
) {
console.warn(
'Invalid connection type',
link.fromSlot.type,
'->',
input.type
)
continue
}
link.connectToSubgraphInput(input, this.events)
}
} else {
@@ -795,7 +809,10 @@ export class LinkConnector {
*/
disconnectLinks(): void {
for (const link of this.renderLinks) {
if (link instanceof MovingLinkBase) {
if (
link instanceof MovingLinkBase ||
link instanceof ToInputFromIoNodeLink
) {
link.disconnect()
}
}
@@ -892,6 +909,14 @@ export class LinkConnector {
)
}
isSubgraphInputValidDrop(input: SubgraphInput): boolean {
return this.renderLinks.some(
(link) =>
'canConnectToSubgraphInput' in link &&
link.canConnectToSubgraphInput(input)
)
}
/**
* Checks if a reroute is a valid drop target for any of the links being connected.
* @param reroute The reroute that would be dropped on.

View File

@@ -55,6 +55,10 @@ export class MovingOutputLink extends MovingLinkBase {
return reroute.origin_id !== this.outputNode.id
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return input.isValidTarget(this.fromSlot)
}
connectToInput(): never {
throw new Error('MovingOutputLink cannot connect to an input.')
}

View File

@@ -135,4 +135,9 @@ export class ToInputFromIoNodeLink implements RenderLink {
connectToRerouteOutput() {
throw new Error('ToInputRenderLink cannot connect to an output.')
}
disconnect(): boolean {
if (!this.existingLink) return false
this.existingLink.disconnect(this.network, 'input')
return true
}
}

View File

@@ -58,6 +58,10 @@ export class ToOutputRenderLink implements RenderLink {
return true
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return input.isValidTarget(this.fromSlot)
}
connectToOutput(
node: LGraphNode,
output: INodeOutputSlot,

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

@@ -176,7 +176,7 @@ export abstract class SubgraphIONodeBase<
* @param event The event that triggered the context menu.
*/
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
const options: IContextMenuValue[] = this.#getSlotMenuOptions(slot)
const options: (IContextMenuValue | null)[] = this.#getSlotMenuOptions(slot)
if (!(options.length > 0)) return
new LiteGraph.ContextMenu(options, {
@@ -193,20 +193,26 @@ export abstract class SubgraphIONodeBase<
* @param slot The slot to get the context menu options for.
* @returns The context menu options.
*/
#getSlotMenuOptions(slot: TSlot): IContextMenuValue[] {
const options: IContextMenuValue[] = []
#getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
const options: (IContextMenuValue | null)[] = []
// Disconnect option if slot has connections
if (slot !== this.emptySlot && slot.linkIds.length > 0) {
options.push({ content: 'Disconnect Links', value: 'disconnect' })
}
// Remove / rename slot option (except for the empty slot)
// Rename slot option (except for the empty slot)
if (slot !== this.emptySlot) {
options.push(
{ content: 'Remove Slot', value: 'remove' },
{ content: 'Rename Slot', value: 'rename' }
)
options.push({ content: 'Rename Slot', value: 'rename' })
}
if (slot !== this.emptySlot) {
options.push(null) // separator
options.push({
content: 'Remove Slot',
value: 'remove',
className: 'danger'
})
}
return options

View File

@@ -16,7 +16,10 @@ import type {
GraphOrSubgraph,
Subgraph
} from '@/lib/litegraph/src/subgraph/Subgraph'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import type {
ExportedSubgraphInstance,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
@@ -125,6 +128,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!input) throw new Error('Subgraph input not found')
input.label = newName
if (input._widget) {
input._widget.label = newName
}
},
{ signal }
)
@@ -168,7 +174,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput: SubgraphInput,
input: INodeInputSlot & Partial<ISubgraphInput>
) {
input._listenerController?.abort()
if (
input._listenerController &&
typeof input._listenerController.abort === 'function'
) {
input._listenerController.abort()
}
input._listenerController = new AbortController()
const { signal } = input._listenerController
@@ -204,7 +215,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override configure(info: ExportedSubgraphInstance): void {
for (const input of this.inputs) {
input._listenerController?.abort()
if (
input._listenerController &&
typeof input._listenerController.abort === 'function'
) {
input._listenerController.abort()
}
}
this.inputs.length = 0
@@ -253,10 +269,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
if (!subgraphInput)
throw new Error(
`[SubgraphNode.configure] No subgraph input found for input ${input.name}`
if (!subgraphInput) {
// Skip inputs that don't exist in the subgraph definition
// This can happen when loading workflows with dynamically added inputs
console.warn(
`[SubgraphNode.configure] No subgraph input found for input ${input.name}, skipping`
)
continue
}
this.#addSubgraphInputListeners(subgraphInput, input)
@@ -515,7 +535,44 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
for (const input of this.inputs) {
input._listenerController?.abort()
if (
input._listenerController &&
typeof input._listenerController.abort === 'function'
) {
input._listenerController.abort()
}
}
}
/**
* Synchronizes widget values from this SubgraphNode instance to the
* corresponding widgets in the subgraph definition before serialization.
* This ensures nested subgraph widget values are preserved when saving.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization
for (let i = 0; i < this.widgets.length; i++) {
const widget = this.widgets[i]
const input = this.inputs.find((inp) => inp.name === widget.name)
if (input) {
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
if (subgraphInput) {
// Find all widgets connected to this subgraph input
const connectedWidgets = subgraphInput.getConnectedWidgets()
// Update the value of all connected widgets
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = widget.value
}
}
}
}
// Call parent serialize method
return super.serialize()
}
}

View File

@@ -1,6 +1,6 @@
import { describe, expect } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'
@@ -14,4 +14,84 @@ describe('LLink', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
describe('disconnect', () => {
it('should clear the target input link reference when disconnecting', () => {
// Create a graph and nodes
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
const targetNode = new LGraphNode('Target')
// Add nodes to graph
graph.add(sourceNode)
graph.add(targetNode)
// Add slots
sourceNode.addOutput('out', 'number')
targetNode.addInput('in', 'number')
// Connect the nodes
const link = sourceNode.connect(0, targetNode, 0)
expect(link).toBeDefined()
expect(targetNode.inputs[0].link).toBe(link?.id)
// Mock setDirtyCanvas
const setDirtyCanvasSpy = vi.spyOn(targetNode, 'setDirtyCanvas')
// Disconnect the link
link?.disconnect(graph)
// Verify the target input's link reference is cleared
expect(targetNode.inputs[0].link).toBeNull()
// Verify setDirtyCanvas was called
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(true, false)
})
it('should handle disconnecting when target node is not found', () => {
// Create a link with invalid target
const graph = new LGraph()
const link = new LLink(1, 'number', 1, 0, 999, 0) // Invalid target id
// Should not throw when disconnecting
expect(() => link.disconnect(graph)).not.toThrow()
})
it('should only clear link reference if it matches the current link id', () => {
// Create a graph and nodes
const graph = new LGraph()
const sourceNode1 = new LGraphNode('Source1')
const sourceNode2 = new LGraphNode('Source2')
const targetNode = new LGraphNode('Target')
// Add nodes to graph
graph.add(sourceNode1)
graph.add(sourceNode2)
graph.add(targetNode)
// Add slots
sourceNode1.addOutput('out', 'number')
sourceNode2.addOutput('out', 'number')
targetNode.addInput('in', 'number')
// Create first connection
const link1 = sourceNode1.connect(0, targetNode, 0)
expect(link1).toBeDefined()
// Disconnect first connection
targetNode.disconnectInput(0)
// Create second connection
const link2 = sourceNode2.connect(0, targetNode, 0)
expect(link2).toBeDefined()
expect(targetNode.inputs[0].link).toBe(link2?.id)
// Try to disconnect the first link (which is already disconnected)
// It should not affect the current connection
link1?.disconnect(graph)
// The input should still have the second link
expect(targetNode.inputs[0].link).toBe(link2?.id)
})
})
})

View File

@@ -0,0 +1,310 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import { MovingOutputLink } from '@/lib/litegraph/src/canvas/MovingOutputLink'
import { ToOutputRenderLink } from '@/lib/litegraph/src/canvas/ToOutputRenderLink'
import { LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
import { createTestSubgraph } from '../subgraph/fixtures/subgraphHelpers'
describe('LinkConnector SubgraphInput connection validation', () => {
let connector: LinkConnector
const mockSetConnectingLinks = vi.fn()
beforeEach(() => {
connector = new LinkConnector(mockSetConnectingLinks)
vi.clearAllMocks()
})
describe('MovingOutputLink validation', () => {
it('should implement canConnectToSubgraphInput method', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Verify the method exists
expect(typeof movingLink.canConnectToSubgraphInput).toBe('function')
})
it('should validate type compatibility correctly', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create valid link (number -> number)
const validLink = new LLink(
1,
'number',
sourceNode.id,
0,
targetNode.id,
0
)
subgraph._links.set(validLink.id, validLink)
const validMovingLink = new MovingOutputLink(subgraph, validLink)
// Create invalid link (string -> number)
const invalidLink = new LLink(
2,
'string',
sourceNode.id,
1,
targetNode.id,
1
)
subgraph._links.set(invalidLink.id, invalidLink)
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
const numberInput = subgraph.inputs[0]
// Test validation
expect(validMovingLink.canConnectToSubgraphInput(numberInput)).toBe(true)
expect(invalidMovingLink.canConnectToSubgraphInput(numberInput)).toBe(
false
)
})
it('should handle wildcard types', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'wildcard_input', type: '*' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
const wildcardInput = subgraph.inputs[0]
// Wildcard should accept any type
expect(movingLink.canConnectToSubgraphInput(wildcardInput)).toBe(true)
})
})
describe('ToOutputRenderLink validation', () => {
it('should implement canConnectToSubgraphInput method', () => {
// Create a minimal valid setup
const subgraph = createTestSubgraph()
const node = new LGraphNode('TestNode')
node.id = 1
node.addInput('test_in', 'number')
subgraph.add(node)
const slot = node.inputs[0] as NodeInputSlot
const renderLink = new ToOutputRenderLink(subgraph, node, slot)
// Verify the method exists
expect(typeof renderLink.canConnectToSubgraphInput).toBe('function')
})
})
describe('dropOnIoNode validation', () => {
it('should prevent invalid connections when dropping on SubgraphInputNode', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create an invalid link (string output -> string input, but subgraph expects number)
const link = new LLink(1, 'string', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Mock console.warn to verify it's called
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
// Add the link to the connector
connector.renderLinks.push(movingLink)
connector.state.connectingTo = 'output'
// Create mock event
const mockEvent = {
canvasX: 100,
canvasY: 100
} as any
// Mock the getSlotInPosition to return the subgraph input
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
// Spy on connectToSubgraphInput to ensure it's NOT called
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
// Drop on the SubgraphInputNode
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
// Verify that the invalid connection was skipped
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Invalid connection type',
'string',
'->',
'number'
)
expect(connectSpy).not.toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it('should allow valid connections when dropping on SubgraphInputNode', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
// Create a valid link (number -> number)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Add the link to the connector
connector.renderLinks.push(movingLink)
connector.state.connectingTo = 'output'
// Create mock event
const mockEvent = {
canvasX: 100,
canvasY: 100
} as any
// Mock the getSlotInPosition to return the subgraph input
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
// Spy on connectToSubgraphInput to ensure it IS called
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
// Drop on the SubgraphInputNode
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
// Verify that the valid connection was made
expect(connectSpy).toHaveBeenCalledWith(
subgraph.inputs[0],
connector.events
)
})
})
describe('isSubgraphInputValidDrop', () => {
it('should check if render links can connect to SubgraphInput', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create valid and invalid links
const validLink = new LLink(
1,
'number',
sourceNode.id,
0,
targetNode.id,
0
)
const invalidLink = new LLink(
2,
'string',
sourceNode.id,
1,
targetNode.id,
1
)
subgraph._links.set(validLink.id, validLink)
subgraph._links.set(invalidLink.id, invalidLink)
const validMovingLink = new MovingOutputLink(subgraph, validLink)
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
const subgraphInput = subgraph.inputs[0]
// Test with only invalid link
connector.renderLinks.length = 0
connector.renderLinks.push(invalidMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
// Test with valid link
connector.renderLinks.length = 0
connector.renderLinks.push(validMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
// Test with mixed links
connector.renderLinks.length = 0
connector.renderLinks.push(invalidMovingLink, validMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
})
it('should handle render links without canConnectToSubgraphInput method', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
// Create a mock render link without the method
const mockLink = {
fromSlot: { type: 'number' }
// No canConnectToSubgraphInput method
} as any
connector.renderLinks.push(mockLink)
const subgraphInput = subgraph.inputs[0]
// Should return false as the link doesn't have the method
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
})
})
})

View File

@@ -0,0 +1,200 @@
import { assert, describe, expect, it } from 'vitest'
import {
ISlotType,
LGraph,
LGraphGroup,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from './fixtures/subgraphHelpers'
function createNode(
graph: LGraph,
inputs: ISlotType[] = [],
outputs: ISlotType[] = [],
title?: string
) {
const type = JSON.stringify({ inputs, outputs })
if (!LiteGraph.registered_node_types[type]) {
class testnode extends LGraphNode {
constructor(title: string) {
super(title)
let i_count = 0
for (const input of inputs) this.addInput('input_' + i_count++, input)
let o_count = 0
for (const output of outputs)
this.addOutput('output_' + o_count++, output)
}
}
LiteGraph.registered_node_types[type] = testnode
}
const node = LiteGraph.createNode(type, title)
if (!node) {
throw new Error('Failed to create node')
}
graph.add(node)
return node
}
describe('SubgraphConversion', () => {
describe('Subgraph Unpacking Functionality', () => {
it('Should keep interior nodes and links', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const node1 = createNode(subgraph, [], ['number'])
const node2 = createNode(subgraph, ['number'])
node1.connect(0, node2, 0)
graph.unpackSubgraph(subgraphNode)
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(1)
})
it('Should merge boundry links', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }],
outputs: [{ name: 'value', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const innerNode1 = createNode(subgraph, [], ['number'])
const innerNode2 = createNode(subgraph, ['number'], [])
subgraph.inputNode.slots[0].connect(innerNode2.inputs[0], innerNode2)
subgraph.outputNode.slots[0].connect(innerNode1.outputs[0], innerNode1)
const outerNode1 = createNode(graph, [], ['number'])
const outerNode2 = createNode(graph, ['number'])
outerNode1.connect(0, subgraphNode, 0)
subgraphNode.connect(0, outerNode2, 0)
graph.unpackSubgraph(subgraphNode)
expect(graph.nodes.length).toBe(4)
expect(graph.links.size).toBe(2)
})
it('Should keep reroutes and groups', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const inner = createNode(subgraph, [], ['number'])
const innerLink = subgraph.outputNode.slots[0].connect(
inner.outputs[0],
inner
)
assert(innerLink)
const outer = createNode(graph, ['number'])
const outerLink = subgraphNode.connect(0, outer, 0)
assert(outerLink)
subgraph.add(new LGraphGroup())
subgraph.createReroute([10, 10], innerLink)
graph.createReroute([10, 10], outerLink)
graph.unpackSubgraph(subgraphNode)
expect(graph.reroutes.size).toBe(2)
expect(graph.groups.length).toBe(1)
})
it('Should map reroutes onto split outputs', () => {
const subgraph = createTestSubgraph({
outputs: [
{ name: 'value1', type: 'number' },
{ name: 'value2', type: 'number' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const inner = createNode(subgraph, [], ['number', 'number'])
const innerLink1 = subgraph.outputNode.slots[0].connect(
inner.outputs[0],
inner
)
const innerLink2 = subgraph.outputNode.slots[1].connect(
inner.outputs[1],
inner
)
const outer1 = createNode(graph, ['number'])
const outer2 = createNode(graph, ['number'])
const outer3 = createNode(graph, ['number'])
const outerLink1 = subgraphNode.connect(0, outer1, 0)
assert(innerLink1 && innerLink2 && outerLink1)
subgraphNode.connect(0, outer2, 0)
subgraphNode.connect(1, outer3, 0)
subgraph.createReroute([10, 10], innerLink1)
subgraph.createReroute([10, 20], innerLink2)
graph.createReroute([10, 10], outerLink1)
graph.unpackSubgraph(subgraphNode)
expect(graph.reroutes.size).toBe(3)
expect(graph.links.size).toBe(3)
let linkRefCount = 0
for (const reroute of graph.reroutes.values()) {
linkRefCount += reroute.linkIds.size
}
expect(linkRefCount).toBe(4)
})
it('Should map reroutes onto split inputs', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'value1', type: 'number' },
{ name: 'value2', type: 'number' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
const inner1 = createNode(subgraph, ['number', 'number'])
const inner2 = createNode(subgraph, ['number'])
const innerLink1 = subgraph.inputNode.slots[0].connect(
inner1.inputs[0],
inner1
)
const innerLink2 = subgraph.inputNode.slots[1].connect(
inner1.inputs[1],
inner1
)
const innerLink3 = subgraph.inputNode.slots[1].connect(
inner2.inputs[0],
inner2
)
assert(innerLink1 && innerLink2 && innerLink3)
const outer = createNode(graph, [], ['number'])
const outerLink1 = outer.connect(0, subgraphNode, 0)
const outerLink2 = outer.connect(0, subgraphNode, 1)
assert(outerLink1 && outerLink2)
graph.createReroute([10, 10], outerLink1)
graph.createReroute([10, 20], outerLink2)
subgraph.createReroute([10, 10], innerLink1)
graph.unpackSubgraph(subgraphNode)
expect(graph.reroutes.size).toBe(3)
expect(graph.links.size).toBe(3)
let linkRefCount = 0
for (const reroute of graph.reroutes.values()) {
linkRefCount += reroute.linkIds.size
}
expect(linkRefCount).toBe(4)
})
})
})

View File

@@ -0,0 +1,270 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "التحقق من التحديثات"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "فتح مجلد العقد المخصصة"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "فتح مجلد المدخلات"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "فتح مجلد السجلات"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "فتح extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "فتح مجلد النماذج"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "فتح مجلد المخرجات"
},
"Comfy-Desktop_OpenDevTools": {
"label": "فتح أدوات المطور"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "دليل المستخدم لسطح المكتب"
},
"Comfy-Desktop_Quit": {
"label": "خروج"
},
"Comfy-Desktop_Reinstall": {
"label": "إعادة التثبيت"
},
"Comfy-Desktop_Restart": {
"label": "إعادة التشغيل"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "فتح عارض ثلاثي الأبعاد (بيتا) للعقدة المحددة"
},
"Comfy_BrowseTemplates": {
"label": "تصفح القوالب"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "إضافة خطوة تحرير النموذج"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "حذف العناصر المحددة"
},
"Comfy_Canvas_FitView": {
"label": "تعديل العرض ليناسب العقد المحددة"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "تحريك العقد المحددة للأسفل"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "تحريك العقد المحددة لليسار"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "تحريك العقد المحددة لليمين"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "تحريك العقد المحددة للأعلى"
},
"Comfy_Canvas_ResetView": {
"label": "إعادة تعيين العرض"
},
"Comfy_Canvas_Resize": {
"label": "تغيير حجم العقد المحددة"
},
"Comfy_Canvas_ToggleLinkVisibility": {
"label": "تبديل رؤية الروابط في اللوحة"
},
"Comfy_Canvas_ToggleLock": {
"label": "تبديل القفل في اللوحة"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "تبديل الخريطة المصغرة في اللوحة"
},
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "تثبيت/إلغاء تثبيت العناصر المحددة"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "تجاوز/إلغاء تجاوز العقد المحددة"
},
"Comfy_Canvas_ToggleSelectedNodes_Collapse": {
"label": "طي/توسيع العقد المحددة"
},
"Comfy_Canvas_ToggleSelectedNodes_Mute": {
"label": "كتم/إلغاء كتم العقد المحددة"
},
"Comfy_Canvas_ToggleSelectedNodes_Pin": {
"label": "تثبيت/إلغاء تثبيت العقد المحددة"
},
"Comfy_Canvas_ZoomIn": {
"label": "تكبير"
},
"Comfy_Canvas_ZoomOut": {
"label": "تصغير"
},
"Comfy_ClearPendingTasks": {
"label": "مسح المهام المعلقة"
},
"Comfy_ClearWorkflow": {
"label": "مسح سير العمل"
},
"Comfy_ContactSupport": {
"label": "الاتصال بالدعم"
},
"Comfy_DuplicateWorkflow": {
"label": "تكرار سير العمل الحالي"
},
"Comfy_ExportWorkflow": {
"label": "تصدير سير العمل"
},
"Comfy_ExportWorkflowAPI": {
"label": "تصدير سير العمل (تنسيق API)"
},
"Comfy_Feedback": {
"label": "إرسال ملاحظات"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "تحويل التحديد إلى رسم فرعي"
},
"Comfy_Graph_FitGroupToContents": {
"label": "ضبط المجموعة على المحتويات"
},
"Comfy_Graph_GroupSelectedNodes": {
"label": "تجميع العقد المحددة"
},
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
"label": "تحويل العقد المحددة إلى عقدة مجموعة"
},
"Comfy_GroupNode_ManageGroupNodes": {
"label": "إدارة عقد المجموعات"
},
"Comfy_GroupNode_UngroupSelectedGroupNodes": {
"label": "إلغاء تجميع عقد المجموعات المحددة"
},
"Comfy_Help_AboutComfyUI": {
"label": "حول ComfyUI"
},
"Comfy_Help_OpenComfyOrgDiscord": {
"label": "فتح خادم Comfy-Org على Discord"
},
"Comfy_Help_OpenComfyUIDocs": {
"label": "فتح مستندات ComfyUI"
},
"Comfy_Help_OpenComfyUIForum": {
"label": "فتح منتدى ComfyUI"
},
"Comfy_Help_OpenComfyUIIssues": {
"label": "فتح مشكلات ComfyUI"
},
"Comfy_Interrupt": {
"label": "إيقاف مؤقت"
},
"Comfy_LoadDefaultWorkflow": {
"label": "تحميل سير العمل الافتراضي"
},
"Comfy_Manager_CustomNodesManager": {
"label": "تبديل مدير العقد المخصصة"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "تبديل شريط تقدم مدير العقد المخصصة"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "تقليل حجم الفرشاة في محرر القناع"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "زيادة حجم الفرشاة في محرر القناع"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "فتح محرر القناع للعقدة المحددة"
},
"Comfy_NewBlankWorkflow": {
"label": "سير عمل جديد فارغ"
},
"Comfy_OpenClipspace": {
"label": "Clipspace"
},
"Comfy_OpenWorkflow": {
"label": "فتح سير عمل"
},
"Comfy_QueuePrompt": {
"label": "إضافة الأمر إلى قائمة الانتظار"
},
"Comfy_QueuePromptFront": {
"label": "إضافة الأمر إلى مقدمة قائمة الانتظار"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "إدراج عقد الإخراج المحددة في قائمة الانتظار"
},
"Comfy_Redo": {
"label": "إعادة"
},
"Comfy_RefreshNodeDefinitions": {
"label": "تحديث تعريفات العقد"
},
"Comfy_SaveWorkflow": {
"label": "حفظ سير العمل"
},
"Comfy_SaveWorkflowAs": {
"label": "حفظ سير العمل باسم"
},
"Comfy_ShowSettingsDialog": {
"label": "عرض نافذة الإعدادات"
},
"Comfy_ToggleTheme": {
"label": "تبديل النمط (فاتح/داكن)"
},
"Comfy_Undo": {
"label": "تراجع"
},
"Comfy_User_OpenSignInDialog": {
"label": "فتح نافذة تسجيل الدخول"
},
"Comfy_User_SignOut": {
"label": "تسجيل الخروج"
},
"Workspace_CloseWorkflow": {
"label": "إغلاق سير العمل الحالي"
},
"Workspace_NextOpenedWorkflow": {
"label": "سير العمل التالي المفتوح"
},
"Workspace_PreviousOpenedWorkflow": {
"label": "سير العمل السابق المفتوح"
},
"Workspace_SearchBox_Toggle": {
"label": "تبديل مربع البحث"
},
"Workspace_ToggleBottomPanel": {
"label": "تبديل اللوحة السفلية"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "عرض مربع حوار اختصارات لوحة المفاتيح"
},
"Workspace_ToggleBottomPanelTab_command-terminal": {
"label": "تبديل لوحة الطرفية السفلية"
},
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "تبديل لوحة السجلات السفلية"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "تبديل اللوحة السفلية الأساسية"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "تبديل لوحة تحكم العرض السفلية"
},
"Workspace_ToggleFocusMode": {
"label": "تبديل وضع التركيز"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "تبديل الشريط الجانبي لمكتبة النماذج",
"tooltip": "مكتبة النماذج"
},
"Workspace_ToggleSidebarTab_node-library": {
"label": "تبديل الشريط الجانبي لمكتبة العقد",
"tooltip": "مكتبة العقد"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "تبديل الشريط الجانبي لقائمة الانتظار",
"tooltip": "قائمة الانتظار"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "تبديل الشريط الجانبي لسير العمل",
"tooltip": "سير العمل"
}
}

1673
src/locales/ar/main.json Normal file

File diff suppressed because it is too large Load Diff

8653
src/locales/ar/nodeDefs.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,416 @@
{
"Comfy-Desktop_AutoUpdate": {
"name": "التحقق تلقائيًا من التحديثات"
},
"Comfy-Desktop_SendStatistics": {
"name": "إرسال إحصائيات الاستخدام المجهولة"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "مرآة تثبيت Pypi",
"tooltip": "مرآة التثبيت الافتراضية لـ pip"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "مرآة تثبيت بايثون",
"tooltip": "يتم تحميل تثبيتات بايثون المدارة من مشروع Astral python-build-standalone. يمكن تعيين هذا المتغير إلى عنوان مرآة لاستخدام مصدر مختلف لتثبيتات بايثون. سيحل العنوان المقدم محل https://github.com/astral-sh/python-build-standalone/releases/download في، مثلاً، https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. يمكن قراءة التوزيعات من دليل محلي باستخدام نظام ملفات file://."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "مرآة تثبيت Torch",
"tooltip": "مرآة تثبيت pip لـ pytorch"
},
"Comfy-Desktop_WindowStyle": {
"name": "نمط النافذة",
"tooltip": "مخصص: استبدال شريط عنوان النظام بالقائمة العلوية لـ ComfyUI",
"options": {
"default": "افتراضي",
"custom": "مخصص"
}
},
"Comfy_Canvas_BackgroundImage": {
"name": "صورة خلفية اللوحة",
"tooltip": "رابط صورة لخلفية اللوحة. يمكنك النقر بزر الفأرة الأيمن على صورة في لوحة النتائج واختيار \"تعيين كخلفية\" لاستخدامها، أو رفع صورتك الخاصة باستخدام زر الرفع."
},
"Comfy_Canvas_NavigationMode": {
"name": "وضع تنقل اللوحة",
"options": {
"Standard (New)": "قياسي (جديد)",
"Left-Click Pan (Legacy)": "سحب بالنقر الأيسر (قديم)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "عرض صندوق أدوات التحديد"
},
"Comfy_ConfirmClear": {
"name": "طلب التأكيد عند مسح سير العمل"
},
"Comfy_DevMode": {
"name": "تمكين خيارات وضع المطور (حفظ API، إلخ)"
},
"Comfy_DisableFloatRounding": {
"name": "تعطيل تقريب عناصر التحكم العائمة الافتراضية",
"tooltip": "(يتطلب إعادة تحميل الصفحة) لا يمكن تعطيل التقريب عندما يتم تعيينه من العقدة في الخلفية."
},
"Comfy_DisableSliders": {
"name": "تعطيل منزلقات أدوات العقد"
},
"Comfy_DOMClippingEnabled": {
"name": "تمكين قص عناصر DOM (قد يقلل التمكين من الأداء)"
},
"Comfy_EditAttention_Delta": {
"name": "دقة تحكم +Ctrl فوق/تحت"
},
"Comfy_EnableTooltips": {
"name": "تمكين التلميحات"
},
"Comfy_EnableWorkflowViewRestore": {
"name": "حفظ واستعادة موقع اللوحة ومستوى التكبير في سير العمل"
},
"Comfy_FloatRoundingPrecision": {
"name": "عدد أرقام التقريب العشرية لأدوات التحكم العائمة [0 = تلقائي]",
"tooltip": "(يتطلب إعادة تحميل الصفحة)"
},
"Comfy_Graph_CanvasInfo": {
"name": "عرض معلومات اللوحة في الزاوية السفلى اليسرى (الإطارات في الثانية، إلخ)"
},
"Comfy_Graph_CanvasMenu": {
"name": "عرض قائمة لوحة الرسم البياني"
},
"Comfy_Graph_CtrlShiftZoom": {
"name": "تمكين اختصار التكبير السريع (Ctrl + Shift + سحب)"
},
"Comfy_Graph_LinkMarkers": {
"name": "علامات منتصف الروابط",
"options": {
"None": "لا شيء",
"Circle": "دائرة",
"Arrow": "سهم"
}
},
"Comfy_Graph_ZoomSpeed": {
"name": "سرعة تكبير اللوحة"
},
"Comfy_Group_DoubleClickTitleToEdit": {
"name": "انقر مزدوج على عنوان المجموعة للتحرير"
},
"Comfy_GroupSelectedNodes_Padding": {
"name": "تباعد حول العقد المحددة في المجموعة"
},
"Comfy_LinkRelease_Action": {
"name": "الإجراء عند تحرير الرابط (بدون مفتاح تعديل)",
"options": {
"context menu": "قائمة السياق",
"search box": "صندوق البحث",
"no action": "لا إجراء"
}
},
"Comfy_LinkRelease_ActionShift": {
"name": "الإجراء عند تحرير الرابط (Shift)",
"options": {
"context menu": "قائمة السياق",
"search box": "صندوق البحث",
"no action": "لا إجراء"
}
},
"Comfy_LinkRenderMode": {
"name": "وضع عرض الروابط",
"options": {
"Straight": "مستقيم",
"Linear": "خطي",
"Spline": "منحنى",
"Hidden": "مخفي"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "تمكين عارض ثلاثي الأبعاد (تجريبي)",
"tooltip": "تمكين عارض ثلاثي الأبعاد (تجريبي) للعقد المحددة. تتيح هذه الميزة عرض النماذج ثلاثية الأبعاد والتفاعل معها مباشرة داخل العارض ثلاثي الأبعاد بحجمه الكامل."
},
"Comfy_Load3D_BackgroundColor": {
"name": "لون الخلفية الابتدائي",
"tooltip": "يحدد لون الخلفية الافتراضي للمشهد ثلاثي الأبعاد. يمكن تعديل هذا اللون لكل عنصر ثلاثي الأبعاد بعد الإنشاء."
},
"Comfy_Load3D_CameraType": {
"name": "نوع الكاميرا الابتدائي",
"tooltip": "يحدد ما إذا كانت الكاميرا منظور أو متعامدة بشكل افتراضي عند إنشاء عنصر ثلاثي الأبعاد جديد. يمكن تعديل هذا الإعداد لكل عنصر بعد الإنشاء.",
"options": {
"perspective": "منظور",
"orthographic": "متعامد"
}
},
"Comfy_Load3D_LightAdjustmentIncrement": {
"name": "زيادة تعديل الضوء",
"tooltip": "يتحكم في حجم الخطوة عند تعديل شدة الإضاءة في المشاهد ثلاثية الأبعاد. قيمة أصغر تسمح بتحكم أدق، وأكبر قيمة تعطي تغييرات أكثر وضوحًا."
},
"Comfy_Load3D_LightIntensity": {
"name": "شدة الإضاءة الابتدائية",
"tooltip": "يحدد مستوى سطوع الإضاءة الافتراضي في المشهد ثلاثي الأبعاد. يمكن تعديله لكل عنصر بعد الإنشاء."
},
"Comfy_Load3D_LightIntensityMaximum": {
"name": "أقصى شدة إضاءة",
"tooltip": "يحدد الحد الأقصى المسموح به لشدة الإضاءة في المشاهد ثلاثية الأبعاد."
},
"Comfy_Load3D_LightIntensityMinimum": {
"name": "أدنى شدة إضاءة",
"tooltip": "يحدد الحد الأدنى المسموح به لشدة الإضاءة في المشاهد ثلاثية الأبعاد."
},
"Comfy_Load3D_ShowGrid": {
"name": "رؤية الشبكة الابتدائية",
"tooltip": "يتحكم في ظهور الشبكة بشكل افتراضي عند إنشاء عنصر ثلاثي الأبعاد جديد."
},
"Comfy_Load3D_ShowPreview": {
"name": "رؤية المعاينة الابتدائية",
"tooltip": "يتحكم في ظهور شاشة المعاينة بشكل افتراضي عند إنشاء عنصر ثلاثي الأبعاد جديد."
},
"Comfy_Locale": {
"name": "اللغة"
},
"Comfy_MaskEditor_BrushAdjustmentSpeed": {
"name": "مضاعف سرعة تعديل الفرشاة",
"tooltip": "يتحكم في سرعة تغير حجم الفرشاة وصلابتها أثناء التعديل. القيم الأعلى تعني تغييرات أسرع."
},
"Comfy_MaskEditor_UseDominantAxis": {
"name": "تقييد تعديل الفرشاة إلى المحور السائد",
"tooltip": "عند التمكين، تؤثر التعديلات على الحجم أو الصلابة فقط بناءً على الاتجاه الذي تتحرك فيه أكثر."
},
"Comfy_MaskEditor_UseNewEditor": {
"name": "استخدام محرر القناع الجديد",
"tooltip": "التحويل إلى واجهة محرر القناع الجديدة"
},
"Comfy_ModelLibrary_AutoLoadAll": {
"name": "تحميل جميع مجلدات النماذج تلقائيًا",
"tooltip": "إذا كانت صحيحة، سيتم تحميل جميع المجلدات عند فتح مكتبة النماذج (قد يسبب تأخيرًا أثناء التحميل). إذا كانت خاطئة، يتم تحميل مجلدات النماذج على مستوى الجذر فقط عند النقر عليها."
},
"Comfy_ModelLibrary_NameFormat": {
"name": "اسم العرض في شجرة مكتبة النماذج",
"tooltip": "اختر \"اسم الملف\" لعرض اسم الملف المبسط بدون المجلد أو الامتداد \".safetensors\" في قائمة النماذج. اختر \"العنوان\" لعرض عنوان بيانات النموذج القابل للتكوين.",
"options": {
"filename": "اسم الملف",
"title": "العنوان"
}
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "عرض العرض × الارتفاع تحت معاينة الصورة"
},
"Comfy_Node_AutoSnapLinkToSlot": {
"name": "التثبيت التلقائي للرابط إلى فتحة العقدة",
"tooltip": "عند سحب رابط فوق عقدة، يتم تثبيت الرابط تلقائيًا على فتحة إدخال صالحة في العقدة"
},
"Comfy_Node_BypassAllLinksOnDelete": {
"name": "الحفاظ على جميع الروابط عند حذف العقد",
"tooltip": "عند حذف عقدة، حاول إعادة توصيل جميع روابط الإدخال والإخراج (تجاوز العقدة المحذوفة)"
},
"Comfy_Node_DoubleClickTitleToEdit": {
"name": "النقر المزدوج على عنوان العقدة للتحرير"
},
"Comfy_Node_MiddleClickRerouteNode": {
"name": "النقر الأوسط ينشئ عقدة إعادة توجيه جديدة"
},
"Comfy_Node_Opacity": {
"name": "شفافية العقدة"
},
"Comfy_Node_ShowDeprecated": {
"name": "عرض العقدة المهجورة في البحث",
"tooltip": "العقد المهجورة مخفية افتراضيًا في واجهة المستخدم، لكنها تظل فعالة في سير العمل الحالي الذي يستخدمها."
},
"Comfy_Node_ShowExperimental": {
"name": "عرض العقدة التجريبية في البحث",
"tooltip": "يتم تمييز العقد التجريبية في واجهة المستخدم وقد تخضع لتغييرات كبيرة أو إزالتها في الإصدارات المستقبلية. استخدمها بحذر في سير العمل الإنتاجي."
},
"Comfy_Node_SnapHighlightsNode": {
"name": "تثبيت يبرز العقدة",
"tooltip": "عند سحب رابط فوق عقدة تحتوي على فتحة إدخال صالحة، يتم تمييز العقدة"
},
"Comfy_NodeBadge_NodeIdBadgeMode": {
"name": "وضع شارة معرف العقدة",
"options": {
"None": "لا شيء",
"Show all": "عرض الكل"
}
},
"Comfy_NodeBadge_NodeLifeCycleBadgeMode": {
"name": "وضع شارة دورة حياة العقدة",
"options": {
"None": "لا شيء",
"Show all": "عرض الكل"
}
},
"Comfy_NodeBadge_NodeSourceBadgeMode": {
"name": "وضع شارة مصدر العقدة",
"options": {
"None": "لا شيء",
"Show all": "عرض الكل",
"Hide built-in": "إخفاء المدمج"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "عرض شارة تسعير عقدة API"
},
"Comfy_NodeSearchBoxImpl": {
"name": "تنفيذ مربع بحث العقدة",
"options": {
"default": "افتراضي",
"litegraph (legacy)": "لايت جراف (قديم)"
}
},
"Comfy_NodeSearchBoxImpl_NodePreview": {
"name": "معاينة العقدة",
"tooltip": "ينطبق فقط على التنفيذ الافتراضي"
},
"Comfy_NodeSearchBoxImpl_ShowCategory": {
"name": "عرض فئة العقدة في نتائج البحث",
"tooltip": "ينطبق فقط على التنفيذ الافتراضي"
},
"Comfy_NodeSearchBoxImpl_ShowIdName": {
"name": "عرض اسم معرف العقدة في نتائج البحث",
"tooltip": "ينطبق فقط على التنفيذ الافتراضي"
},
"Comfy_NodeSearchBoxImpl_ShowNodeFrequency": {
"name": "عرض تكرار العقدة في نتائج البحث",
"tooltip": "ينطبق فقط على التنفيذ الافتراضي"
},
"Comfy_NodeSuggestions_number": {
"name": "عدد اقتراحات العقد",
"tooltip": "خاص بمربع بحث / قائمة السياق في لايت جراف فقط"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "عرض تحديثات الإصدار",
"tooltip": "عرض التحديثات للنماذج الجديدة والميزات الرئيسية."
},
"Comfy_Pointer_ClickBufferTime": {
"name": "تأخير انحراف نقرة المؤشر",
"tooltip": "بعد الضغط على زر المؤشر، هذا هو الوقت الأقصى (بالملي ثانية) الذي يمكن تجاهل حركة المؤشر خلاله.\n\nيساعد على منع دفع الكائنات عن طريق الخطأ إذا تم تحريك المؤشر أثناء النقر."
},
"Comfy_Pointer_ClickDrift": {
"name": "انحراف نقرة المؤشر (أقصى مسافة)",
"tooltip": "إذا تحرك المؤشر أكثر من هذه المسافة أثناء الضغط على زر، يعتبر سحبًا بدلاً من نقرة.\n\nيساعد على منع دفع الكائنات عن طريق الخطأ إذا تم تحريك المؤشر أثناء النقر."
},
"Comfy_Pointer_DoubleClickTime": {
"name": "فترة النقر المزدوج (قصوى)",
"tooltip": "الوقت الأقصى بالملي ثانية بين النقرتين في النقر المزدوج. زيادة هذه القيمة قد تساعد إذا لم يتم تسجيل النقرات المزدوجة أحيانًا."
},
"Comfy_PreviewFormat": {
"name": "تنسيق صورة المعاينة",
"tooltip": "عند عرض معاينة في ويدجت الصورة، يتم تحويلها إلى صورة خفيفة الوزن، مثل webp، jpeg، webp;50، إلخ."
},
"Comfy_PromptFilename": {
"name": "طلب اسم الملف عند حفظ سير العمل"
},
"Comfy_Queue_MaxHistoryItems": {
"name": "حجم تاريخ قائمة الانتظار",
"tooltip": "العدد الأقصى للمهام المعروضة في تاريخ قائمة الانتظار."
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "حد عدد الدُفعات",
"tooltip": "العدد الأقصى للمهام التي تضاف إلى القائمة بنقرة زر واحدة"
},
"Comfy_Sidebar_Location": {
"name": "موقع الشريط الجانبي",
"options": {
"left": "يسار",
"right": "يمين"
}
},
"Comfy_Sidebar_Size": {
"name": "حجم الشريط الجانبي",
"options": {
"normal": "عادي",
"small": "صغير"
}
},
"Comfy_Sidebar_UnifiedWidth": {
"name": "عرض موحد للشريط الجانبي"
},
"Comfy_SnapToGrid_GridSize": {
"name": "حجم الالتصاق بالشبكة",
"tooltip": "عند سحب وتغيير حجم العقد مع الضغط على shift، يتم محاذاتها إلى الشبكة، هذا يتحكم في حجم تلك الشبكة."
},
"Comfy_TextareaWidget_FontSize": {
"name": "حجم خط ويدجت منطقة النص"
},
"Comfy_TextareaWidget_Spellcheck": {
"name": "التحقق من الإملاء في ويدجت منطقة النص"
},
"Comfy_TreeExplorer_ItemPadding": {
"name": "حشو عناصر مستعرض الشجرة"
},
"Comfy_UseNewMenu": {
"name": "استخدام القائمة الجديدة",
"tooltip": "موقع شريط القائمة. على الأجهزة المحمولة، تُعرض القائمة دائمًا في الأعلى.",
"options": {
"Disabled": "معطل",
"Top": "أعلى",
"Bottom": "أسفل"
}
},
"Comfy_Validation_Workflows": {
"name": "التحقق من صحة سير العمل"
},
"Comfy_WidgetControlMode": {
"name": "وضع التحكم في الودجت",
"tooltip": "يتحكم في متى يتم تحديث قيم الودجت (توليد عشوائي/زيادة/نقصان)، إما قبل إدراج الطلب في الطابور أو بعده.",
"options": {
"before": "قبل",
"after": "بعد"
}
},
"Comfy_Window_UnloadConfirmation": {
"name": "عرض تأكيد عند إغلاق النافذة"
},
"Comfy_Workflow_AutoSave": {
"name": "الحفظ التلقائي",
"options": {
"off": "إيقاف",
"after delay": "بعد تأخير"
}
},
"Comfy_Workflow_AutoSaveDelay": {
"name": "تأخير الحفظ التلقائي (بالملي ثانية)",
"tooltip": "ينطبق فقط إذا تم تعيين الحفظ التلقائي إلى \"بعد تأخير\"."
},
"Comfy_Workflow_ConfirmDelete": {
"name": "عرض تأكيد عند حذف سير العمل"
},
"Comfy_Workflow_Persist": {
"name": "الاحتفاظ بحالة سير العمل واستعادتها عند (إعادة) تحميل الصفحة"
},
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "عرض تحذير النماذج المفقودة"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "عرض تحذير العقد المفقودة"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "ترتيب معرفات العقد عند حفظ سير العمل"
},
"Comfy_Workflow_WorkflowTabsPosition": {
"name": "موضع تبويبات سير العمل المفتوحة",
"options": {
"Sidebar": "الشريط الجانبي",
"Topbar": "شريط الأعلى",
"Topbar (2nd-row)": "شريط الأعلى (الصف الثاني)"
}
},
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
"name": "عتبة التكبير للرسم بجودة منخفضة",
"tooltip": "عرض أشكال بجودة منخفضة عند التكبير للخارج"
},
"LiteGraph_Canvas_MaximumFps": {
"name": "الحد الأقصى للإطارات في الثانية",
"tooltip": "الحد الأقصى لعدد الإطارات في الثانية التي يسمح للرسم أن يعرضها. يحد من استخدام GPU على حساب السلاسة. إذا كانت 0، يتم استخدام معدل تحديث الشاشة. الافتراضي: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "تغيير مقياس قوائم ودجت كومبو العقدة عند التكبير"
},
"LiteGraph_Node_DefaultPadding": {
"name": "تصغير العقد الجديدة دائمًا",
"tooltip": "تغيير حجم العقد إلى أصغر حجم ممكن عند الإنشاء. عند التعطيل، يتم توسيع العقدة المضافة حديثًا قليلاً لإظهار قيم الودجت."
},
"LiteGraph_Node_TooltipDelay": {
"name": "تأخير التلميح"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "إزاحة منحنى إعادة التوجيه",
"tooltip": "إزاحة نقطة تحكم بيزير من نقطة مركز إعادة التوجيه"
},
"pysssss_SnapToGrid": {
"name": "الالتصاق بالشبكة دائمًا"
}
}

View File

@@ -119,6 +119,12 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Exit Subgraph"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Unpack the selected Subgraph"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Fit Group To Contents"
},

View File

@@ -974,6 +974,7 @@
"Export (API)": "Export (API)",
"Give Feedback": "Give Feedback",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Exit Subgraph": "Exit Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Convert selected nodes to group node": "Convert selected nodes to group node",
@@ -1630,5 +1631,26 @@
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"enterNewName": "Enter new name"
},
"shortcuts": {
"essentials": "Essential",
"viewControls": "View Controls",
"manageShortcuts": "Manage Shortcuts",
"noKeybinding": "No keybinding",
"keyboardShortcuts": "Keyboard Shortcuts",
"subcategories": {
"workflow": "Workflow",
"node": "Node",
"queue": "Queue",
"view": "View",
"panelControls": "Panel Controls"
}
},
"minimap": {
"nodeColors": "Node Colors",
"showLinks": "Show Links",
"showGroups": "Show Frames/Groups",
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
}
}

View File

@@ -119,6 +119,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Salir de subgrafo"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajustar grupo al contenido"
},

View File

@@ -765,6 +765,7 @@
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
"Edit": "Editar",
"Exit Subgraph": "Salir de subgrafo",
"Export": "Exportar",
"Export (API)": "Exportar (API)",
"Fit Group To Contents": "Ajustar grupo a contenidos",
@@ -828,6 +829,13 @@
"Zoom In": "Acercar",
"Zoom Out": "Alejar"
},
"minimap": {
"nodeColors": "Colores de nodos",
"renderBypassState": "Mostrar estado de omisión",
"renderErrorState": "Mostrar estado de error",
"showGroups": "Mostrar marcos/grupos",
"showLinks": "Mostrar enlaces"
},
"missingModelsDialog": {
"doNotAskAgain": "No mostrar esto de nuevo",
"missingModels": "Modelos faltantes",

View File

@@ -119,6 +119,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Quitter le sous-graphe"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajuster le groupe au contenu"
},

View File

@@ -765,6 +765,7 @@
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
"Edit": "Éditer",
"Exit Subgraph": "Quitter le sous-graphe",
"Export": "Exporter",
"Export (API)": "Exporter (API)",
"Fit Group To Contents": "Ajuster le groupe au contenu",
@@ -828,6 +829,13 @@
"Zoom In": "Zoom avant",
"Zoom Out": "Zoom arrière"
},
"minimap": {
"nodeColors": "Couleurs des nœuds",
"renderBypassState": "Afficher létat de contournement",
"renderErrorState": "Afficher létat derreur",
"showGroups": "Afficher les cadres/groupes",
"showLinks": "Afficher les liens"
},
"missingModelsDialog": {
"doNotAskAgain": "Ne plus afficher ce message",
"missingModels": "Modèles manquants",

View File

@@ -119,6 +119,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "選択範囲をサブグラフに変換"
},
"Comfy_Graph_ExitSubgraph": {
"label": "サブグラフを終了"
},
"Comfy_Graph_FitGroupToContents": {
"label": "グループを内容に合わせて調整"
},

View File

@@ -765,6 +765,7 @@
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
"Edit": "編集",
"Exit Subgraph": "サブグラフを終了",
"Export": "エクスポート",
"Export (API)": "エクスポート (API)",
"Fit Group To Contents": "グループを内容に合わせる",
@@ -828,6 +829,13 @@
"Zoom In": "ズームイン",
"Zoom Out": "ズームアウト"
},
"minimap": {
"nodeColors": "ノードの色",
"renderBypassState": "バイパス状態を表示",
"renderErrorState": "エラー状態を表示",
"showGroups": "フレーム/グループを表示",
"showLinks": "リンクを表示"
},
"missingModelsDialog": {
"doNotAskAgain": "再度表示しない",
"missingModels": "モデルが見つかりません",

View File

@@ -119,6 +119,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_ExitSubgraph": {
"label": "서브그래프 종료"
},
"Comfy_Graph_FitGroupToContents": {
"label": "그룹을 내용에 맞게 맞추기"
},

View File

@@ -765,6 +765,7 @@
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
"Edit": "편집",
"Exit Subgraph": "서브그래프 종료",
"Export": "내보내기",
"Export (API)": "내보내기 (API)",
"Fit Group To Contents": "그룹을 내용에 맞게 조정",
@@ -828,6 +829,13 @@
"Zoom In": "확대",
"Zoom Out": "축소"
},
"minimap": {
"nodeColors": "노드 색상",
"renderBypassState": "바이패스 상태 렌더링",
"renderErrorState": "에러 상태 렌더링",
"showGroups": "프레임/그룹 표시",
"showLinks": "링크 표시"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
"missingModels": "모델이 없습니다",

View File

@@ -119,6 +119,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Преобразовать выделенное в подграф"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Выйти из подграфа"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Подогнать группу к содержимому"
},

View File

@@ -765,6 +765,7 @@
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
"Edit": "Редактировать",
"Exit Subgraph": "Выйти из подграфа",
"Export": "Экспортировать",
"Export (API)": "Экспорт (API)",
"Fit Group To Contents": "Подогнать группу под содержимое",
@@ -828,6 +829,13 @@
"Zoom In": "Увеличить",
"Zoom Out": "Уменьшить"
},
"minimap": {
"nodeColors": "Цвета узлов",
"renderBypassState": "Отображать состояние обхода",
"renderErrorState": "Отображать состояние ошибки",
"showGroups": "Показать фреймы/группы",
"showLinks": "Показать связи"
},
"missingModelsDialog": {
"doNotAskAgain": "Больше не показывать это",
"missingModels": "Отсутствующие модели",

View File

@@ -119,6 +119,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "將選取內容轉換為子圖"
},
"Comfy_Graph_ExitSubgraph": {
"label": "離開子圖"
},
"Comfy_Graph_FitGroupToContents": {
"label": "調整群組以符合內容"
},

View File

@@ -765,6 +765,7 @@
"Desktop User Guide": "桌面應用程式使用指南",
"Duplicate Current Workflow": "複製目前工作流程",
"Edit": "編輯",
"Exit Subgraph": "離開子圖",
"Export": "匯出",
"Export (API)": "匯出API",
"Fit Group To Contents": "群組貼合內容",
@@ -828,6 +829,13 @@
"Zoom In": "放大",
"Zoom Out": "縮小"
},
"minimap": {
"nodeColors": "節點顏色",
"renderBypassState": "顯示繞過狀態",
"renderErrorState": "顯示錯誤狀態",
"showGroups": "顯示框架/群組",
"showLinks": "顯示連結"
},
"missingModelsDialog": {
"doNotAskAgain": "不要再顯示此訊息",
"missingModels": "缺少模型",

View File

@@ -72,7 +72,7 @@
"label": "锁定视图"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "布切小地"
"label": "布切小地"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "忽略/取消忽略选中节点"
@@ -119,6 +119,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "将选区转换为子图"
},
"Comfy_Graph_ExitSubgraph": {
"label": "退出子图"
},
"Comfy_Graph_FitGroupToContents": {
"label": "适应节点框到内容"
},
@@ -162,10 +165,10 @@
"label": "切换进度对话框"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "小 MaskEditor 中的刷大小"
"label": "小 MaskEditor 中的刷大小"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "增加 MaskEditor 畫筆大小"
"label": "增加 MaskEditor 画笔大小"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "打开选中节点的遮罩编辑器"

View File

@@ -83,10 +83,10 @@
}
},
"breadcrumbsMenu": {
"clearWorkflow": "清工作流",
"deleteWorkflow": "除工作流",
"duplicate": "複製",
"enterNewName": "入新名"
"clearWorkflow": "清工作流",
"deleteWorkflow": "除工作流",
"duplicate": "复制",
"enterNewName": "入新名"
},
"chatHistory": {
"cancelEdit": "取消",
@@ -218,7 +218,7 @@
"WEBCAM": "摄像头"
},
"desktopMenu": {
"confirmQuit": "未保存的工作流程开启;任何未保存的更改都将丢失。忽略此警告并退出?",
"confirmQuit": "存在未保存的工作流;任何未保存的更改都将丢失。忽略此警告并退出?",
"confirmReinstall": "这将清除您的 extra_models_config.yaml 文件,并重新开始安装。您确定吗?",
"quit": "退出",
"reinstall": "重新安装"
@@ -272,7 +272,7 @@
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"clearFilters": "清除篩選",
"clearFilters": "清除筛选",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
@@ -297,7 +297,7 @@
"devices": "设备",
"disableAll": "禁用全部",
"disabling": "禁用中",
"dismiss": "關閉",
"dismiss": "关闭",
"download": "下载",
"edit": "编辑",
"empty": "空",
@@ -312,8 +312,8 @@
"filter": "过滤",
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。",
"goToNode": "转到节点",
"help": "帮助",
"icon": "图标",
@@ -399,8 +399,8 @@
"upload": "上传",
"usageHint": "使用提示",
"user": "用户",
"versionMismatchWarning": "版本容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新明。",
"versionMismatchWarning": "版本容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 请参考 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新明。",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
},
@@ -410,7 +410,7 @@
"resetView": "重置视图",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"toggleMinimap": "切小地",
"toggleMinimap": "切小地",
"zoomIn": "放大",
"zoomOut": "缩小"
},
@@ -720,7 +720,7 @@
"disabled": "禁用",
"disabledTooltip": "工作流将不会自动执行",
"execute": "执行",
"help": "明",
"help": "明",
"hideMenu": "隐藏菜单",
"instant": "实时",
"instantTooltip": "工作流将会在生成完成后立即执行",
@@ -734,9 +734,9 @@
"run": "运行",
"runWorkflow": "运行工作流程Shift排在前面",
"runWorkflowFront": "运行工作流程(排在前面)",
"settings": "定",
"settings": "定",
"showMenu": "显示菜单",
"theme": "主",
"theme": "主",
"toggleBottomPanel": "底部面板"
},
"menuLabels": {
@@ -746,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": "清除工作流",
@@ -765,6 +765,7 @@
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
"Edit": "编辑",
"Exit Subgraph": "退出子圖",
"Export": "导出",
"Export (API)": "导出 (API)",
"Fit Group To Contents": "适应组内容",
@@ -813,13 +814,13 @@
"Toggle 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 Workflows Sidebar": "切工作流程側邊欄",
"Toggle Workflows Sidebar": "切工作流侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
@@ -828,6 +829,13 @@
"Zoom In": "放大画面",
"Zoom Out": "缩小画面"
},
"minimap": {
"nodeColors": "節點顏色",
"renderBypassState": "顯示繞過狀態",
"renderErrorState": "顯示錯誤狀態",
"showGroups": "顯示框架/群組",
"showLinks": "顯示連結"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
"missingModels": "缺少模型",
@@ -1612,10 +1620,10 @@
"required": "必填"
},
"versionMismatchWarning": {
"dismiss": "關閉",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 版或更高版本。",
"title": "版本容性警告",
"dismiss": "关闭",
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 版或更高版本。",
"title": "版本容性警告",
"updateFrontend": "更新前端"
},
"welcome": {

View File

@@ -30,10 +30,10 @@
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片并选择“设为背景”来使用它。"
},
"Comfy_Canvas_NavigationMode": {
"name": "畫布導航模式",
"name": "画布导航模式",
"options": {
"Left-Click Pan (Legacy)": "左拖曳(版)",
"Standard (New)": "標準(新)"
"Left-Click Pan (Legacy)": "左拖曳(版)",
"Standard (New)": "标准(新)"
}
},
"Comfy_Canvas_SelectionToolbox": {
@@ -334,7 +334,7 @@
"Disabled": "禁用",
"Top": "顶部"
},
"tooltip": "選單列位置。在行動裝置上,選單始終顯示於頂端。"
"tooltip": "菜单栏位置。在移动设备上,菜单始终显示于顶端。"
},
"Comfy_Validation_Workflows": {
"name": "校验工作流"

View File

@@ -476,6 +476,11 @@ const zSettings = z.object({
'Comfy.InstalledVersion': z.string().nullable(),
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
'Comfy.Minimap.Visible': z.boolean(),
'Comfy.Minimap.NodeColors': z.boolean(),
'Comfy.Minimap.ShowLinks': z.boolean(),
'Comfy.Minimap.ShowGroups': z.boolean(),
'Comfy.Minimap.RenderBypassState': z.boolean(),
'Comfy.Minimap.RenderErrorState': z.boolean(),
'Comfy.Canvas.NavigationMode': z.string(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),

View File

@@ -385,8 +385,15 @@ export class ComfyApp {
static pasteFromClipspace(node: LGraphNode) {
if (ComfyApp.clipspace) {
// image paste
const combinedImgSrc =
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src
let combinedImgSrc: string | undefined
if (
ComfyApp.clipspace.combinedIndex !== undefined &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length
) {
combinedImgSrc =
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
}
if (ComfyApp.clipspace.imgs && node.imgs) {
if (node.images && ComfyApp.clipspace.images) {
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {

View File

@@ -1,5 +1,6 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
KeyComboImpl,
KeybindingImpl,
@@ -11,6 +12,7 @@ export const useKeybindingService = () => {
const keybindingStore = useKeybindingStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
const keybindHandler = async function (event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
@@ -32,6 +34,19 @@ export const useKeybindingService = () => {
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
// Special handling for Escape key - let dialogs handle it first
if (
event.key === 'Escape' &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey
) {
// If dialogs are open, don't execute the keybinding - let the dialog handle it
if (dialogStore.dialogStack.length > 0) {
return
}
}
// Prevent default browser behavior first, then execute the command
event.preventDefault()
await commandStore.execute(keybinding.commandId)

View File

@@ -1,5 +1,6 @@
import _ from 'lodash'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
@@ -63,6 +64,7 @@ export const useLitegraphService = () => {
const toastStore = useToastStore()
const widgetStore = useWidgetStore()
const canvasStore = useCanvasStore()
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
function registerSubgraphNodeDef(
@@ -363,6 +365,7 @@ export const useLitegraphService = () => {
// Note: Do not following assignments before `LiteGraph.registerNodeType`
// because `registerNodeType` will overwrite the assignments.
node.category = nodeDef.category
node.skip_list = true
node.title = nodeDef.display_name || nodeDef.name
}
@@ -761,15 +764,8 @@ export const useLitegraphService = () => {
options.push({
content: 'Bypass',
callback: () => {
const mode =
this.mode === LGraphEventMode.BYPASS
? LGraphEventMode.ALWAYS
: LGraphEventMode.BYPASS
for (const item of app.canvas.selectedItems) {
if (item instanceof LGraphNode) item.mode = mode
}
// @ts-expect-error fixme ts strict error
this.graph.change()
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true)
}
})
@@ -804,6 +800,15 @@ export const useLitegraphService = () => {
})
}
}
if (this instanceof SubgraphNode) {
options.unshift({
content: 'Unpack Subgraph',
callback: () => {
useNodeOutputStore().revokeSubgraphPreviews(this)
this.graph.unpackSubgraph(this)
}
})
}
return []
}

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

@@ -17,6 +17,7 @@ export interface ComfyCommand {
versionAdded?: string
confirmation?: string // If non-nullish, this command will prompt for confirmation
source?: string
category?: 'essentials' | 'view-controls' // For shortcuts panel organization
}
export class ComfyCommandImpl implements ComfyCommand {
@@ -29,6 +30,7 @@ export class ComfyCommandImpl implements ComfyCommand {
versionAdded?: string
confirmation?: string
source?: string
category?: 'essentials' | 'view-controls'
constructor(command: ComfyCommand) {
this.id = command.id
@@ -40,6 +42,7 @@ export class ComfyCommandImpl implements ComfyCommand {
this.versionAdded = command.versionAdded
this.confirmation = command.confirmation
this.source = command.source
this.category = command.category
}
get label() {

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
ExecutedWsMessage,
ResultItem,
@@ -268,6 +268,20 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
app.nodePreviewImages = {}
}
/**
* Revoke all preview of a subgraph node and the graph it contains.
* Does not recurse to contents of nested subgraphs.
*/
function revokeSubgraphPreviews(subgraphNode: SubgraphNode) {
const graphId = subgraphNode.graph.isRootGraph
? ''
: subgraphNode.graph.id + ':'
revokePreviewsByLocatorId(graphId + subgraphNode.id)
for (const node of subgraphNode.subgraph.nodes) {
revokePreviewsByLocatorId(subgraphNode.subgraph.id + node.id)
}
}
return {
getNodeOutputs,
getNodeImageUrls,
@@ -279,6 +293,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setNodePreviewsByNodeId,
revokePreviewsByExecutionId,
revokeAllPreviews,
revokeSubgraphPreviews,
getPreviewParam
}
})

View File

@@ -226,6 +226,14 @@ export const useReleaseStore = defineStore('release', () => {
return
}
// Skip fetching if API nodes are disabled via argv
if (
systemStatsStore.systemStats?.system?.argv?.includes(
'--disable-api-nodes'
)
) {
return
}
isLoading.value = true
error.value = null

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

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useShortcutsTab } from '@/composables/bottomPanelTabs/useShortcutsTab'
import {
useCommandTerminalTab,
useLogsTerminalTab
@@ -10,45 +11,110 @@ import { ComfyExtension } from '@/types/comfy'
import type { BottomPanelExtension } from '@/types/extensionTypes'
import { isElectron } from '@/utils/envUtil'
type PanelType = 'terminal' | 'shortcuts'
interface PanelState {
tabs: BottomPanelExtension[]
activeTabId: string
visible: boolean
}
export const useBottomPanelStore = defineStore('bottomPanel', () => {
const bottomPanelVisible = ref(false)
const toggleBottomPanel = () => {
// If there are no tabs, don't show the bottom panel
if (bottomPanelTabs.value.length === 0) {
return
// Multi-panel state
const panels = ref<Record<PanelType, PanelState>>({
terminal: { tabs: [], activeTabId: '', visible: false },
shortcuts: { tabs: [], activeTabId: '', visible: false }
})
const activePanel = ref<PanelType | null>(null)
// Computed properties for active panel
const activePanelState = computed(() =>
activePanel.value ? panels.value[activePanel.value] : null
)
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
const state = activePanelState.value
if (!state) return null
return state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
})
const bottomPanelVisible = computed({
get: () => !!activePanel.value,
set: (visible: boolean) => {
if (!visible) {
activePanel.value = null
}
}
})
const bottomPanelTabs = computed(() => activePanelState.value?.tabs ?? [])
const activeBottomPanelTabId = computed({
get: () => activePanelState.value?.activeTabId ?? '',
set: (tabId: string) => {
const state = activePanelState.value
if (state) {
state.activeTabId = tabId
}
}
})
const togglePanel = (panelType: PanelType) => {
const panel = panels.value[panelType]
if (panel.tabs.length === 0) return
if (activePanel.value === panelType) {
// Hide current panel
activePanel.value = null
} else {
// Show target panel
activePanel.value = panelType
if (!panel.activeTabId && panel.tabs.length > 0) {
panel.activeTabId = panel.tabs[0].id
}
}
bottomPanelVisible.value = !bottomPanelVisible.value
}
const bottomPanelTabs = ref<BottomPanelExtension[]>([])
const activeBottomPanelTabId = ref<string>('')
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
return (
bottomPanelTabs.value.find(
(tab) => tab.id === activeBottomPanelTabId.value
) ?? null
)
})
const setActiveTab = (tabId: string) => {
activeBottomPanelTabId.value = tabId
const toggleBottomPanel = () => {
// Legacy method - toggles terminal panel
togglePanel('terminal')
}
const setActiveTab = (tabId: string) => {
const state = activePanelState.value
if (state) {
state.activeTabId = tabId
}
}
const toggleBottomPanelTab = (tabId: string) => {
if (activeBottomPanelTabId.value === tabId && bottomPanelVisible.value) {
bottomPanelVisible.value = false
} else {
activeBottomPanelTabId.value = tabId
bottomPanelVisible.value = true
// Find which panel contains this tab
for (const [panelType, panel] of Object.entries(panels.value)) {
const tab = panel.tabs.find((t) => t.id === tabId)
if (tab) {
if (activePanel.value === panelType && panel.activeTabId === tabId) {
activePanel.value = null
} else {
activePanel.value = panelType as PanelType
panel.activeTabId = tabId
}
return
}
}
}
const registerBottomPanelTab = (tab: BottomPanelExtension) => {
bottomPanelTabs.value = [...bottomPanelTabs.value, tab]
if (bottomPanelTabs.value.length === 1) {
activeBottomPanelTabId.value = tab.id
const targetPanel = tab.targetPanel ?? 'terminal'
const panel = panels.value[targetPanel]
panel.tabs = [...panel.tabs, tab]
if (panel.tabs.length === 1) {
panel.activeTabId = tab.id
}
useCommandStore().registerCommand({
id: `Workspace.ToggleBottomPanelTab.${tab.id}`,
icon: 'pi pi-list',
label: `Toggle ${tab.title} Bottom Panel`,
category: 'view-controls' as const,
function: () => toggleBottomPanelTab(tab.id),
source: 'System'
})
@@ -59,6 +125,7 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
if (isElectron()) {
registerBottomPanelTab(useCommandTerminalTab())
}
useShortcutsTab().forEach(registerBottomPanelTab)
}
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
@@ -68,6 +135,11 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
}
return {
// Multi-panel API
panels,
activePanel,
togglePanel,
bottomPanelVisible,
toggleBottomPanel,
bottomPanelTabs,

View File

@@ -44,6 +44,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
label: labelFunction,
tooltip: tooltipFunction,
versionAdded: '1.3.9',
category: 'view-controls' as const,
function: () => {
toggleSidebarTab(tab.id)
},

View File

@@ -14,6 +14,7 @@ export interface BaseSidebarTabExtension {
export interface BaseBottomPanelExtension {
id: string
title: string
targetPanel?: 'terminal' | 'shortcuts'
}
export interface VueExtension {

View File

@@ -0,0 +1,87 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock ShortcutsList component
vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
default: {
name: 'ShortcutsList',
props: ['commands', 'subcategories', 'columns'],
template:
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
}
}))
// Mock command store
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',
label: 'New Workflow',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Node.Add',
label: 'Add Node',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Queue.Clear',
label: 'Clear Queue',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Other.Command',
label: 'Other Command',
category: 'view-controls',
function: vi.fn(),
icon: 'pi pi-test',
tooltip: 'Test tooltip',
menubarLabel: 'Other Command',
keybinding: null
} as ComfyCommandImpl
]
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
commands: mockCommands
})
}))
describe('EssentialsPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should render ShortcutsList with essentials commands', () => {
const wrapper = mount(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
expect(shortcutsList.exists()).toBe(true)
// Should pass only essentials commands
const commands = shortcutsList.props('commands')
expect(commands).toHaveLength(3)
commands.forEach((cmd: ComfyCommandImpl) => {
expect(cmd.category).toBe('essentials')
})
})
it('should categorize commands into subcategories', () => {
const wrapper = mount(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
const subcategories = shortcutsList.props('subcategories')
expect(subcategories).toHaveProperty('workflow')
expect(subcategories).toHaveProperty('node')
expect(subcategories).toHaveProperty('queue')
expect(subcategories.workflow).toContain(mockCommands[0])
expect(subcategories.node).toContain(mockCommands[1])
expect(subcategories.queue).toContain(mockCommands[2])
})
})

View File

@@ -0,0 +1,164 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock vue-i18n
const mockT = vi.fn((key: string) => {
const translations: Record<string, string> = {
'shortcuts.subcategories.workflow': 'Workflow',
'shortcuts.subcategories.node': 'Node',
'shortcuts.subcategories.queue': 'Queue',
'shortcuts.subcategories.view': 'View',
'shortcuts.subcategories.panelControls': 'Panel Controls',
'commands.Workflow_New.label': 'New Blank Workflow'
}
return translations[key] || key
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT
})
}))
describe('ShortcutsList', () => {
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',
label: 'New Workflow',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Control', 'n']
}
}
} as ComfyCommandImpl,
{
id: 'Node.Add',
label: 'Add Node',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Shift', 'a']
}
}
} as ComfyCommandImpl,
{
id: 'Queue.Clear',
label: 'Clear Queue',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Control', 'Shift', 'c']
}
}
} as ComfyCommandImpl
]
const mockSubcategories = {
workflow: [mockCommands[0]],
node: [mockCommands[1]],
queue: [mockCommands[2]]
}
it('should render shortcuts organized by subcategories', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check that subcategories are rendered
expect(wrapper.text()).toContain('Workflow')
expect(wrapper.text()).toContain('Node')
expect(wrapper.text()).toContain('Queue')
// Check that commands are rendered
expect(wrapper.text()).toContain('New Blank Workflow')
})
it('should format keyboard shortcuts correctly', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check for formatted keys
expect(wrapper.text()).toContain('Ctrl')
expect(wrapper.text()).toContain('n')
expect(wrapper.text()).toContain('Shift')
expect(wrapper.text()).toContain('a')
expect(wrapper.text()).toContain('c')
})
it('should filter out commands without keybindings', () => {
const commandsWithoutKeybinding: ComfyCommandImpl[] = [
...mockCommands,
{
id: 'No.Keybinding',
label: 'No Keybinding',
category: 'essentials',
keybinding: null
} as ComfyCommandImpl
]
const wrapper = mount(ShortcutsList, {
props: {
commands: commandsWithoutKeybinding,
subcategories: {
...mockSubcategories,
other: [commandsWithoutKeybinding[3]]
}
}
})
expect(wrapper.text()).not.toContain('No Keybinding')
})
it('should handle special key formatting', () => {
const specialKeyCommand: ComfyCommandImpl = {
id: 'Special.Keys',
label: 'Special Keys',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Meta', 'ArrowUp', 'Enter', 'Escape', ' ']
}
}
} as ComfyCommandImpl
const wrapper = mount(ShortcutsList, {
props: {
commands: [specialKeyCommand],
subcategories: {
special: [specialKeyCommand]
}
}
})
const text = wrapper.text()
expect(text).toContain('Cmd') // Meta -> Cmd
expect(text).toContain('↑') // ArrowUp -> ↑
expect(text).toContain('↵') // Enter -> ↵
expect(text).toContain('Esc') // Escape -> Esc
expect(text).toContain('Space') // ' ' -> Space
})
it('should use fallback subcategory titles', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: {
unknown: [mockCommands[0]]
}
}
})
expect(wrapper.text()).toContain('unknown')
})
})

View File

@@ -64,6 +64,16 @@ describe('useNodePricing', () => {
})
describe('dynamic pricing - KlingTextToVideoNode', () => {
it('should return high price for kling-v2-1-master model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingTextToVideoNode', [
{ name: 'mode', value: 'standard / 5s / v2-1-master' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.40/Run')
})
it('should return high price for kling-v2-master model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingTextToVideoNode', [
@@ -104,6 +114,16 @@ describe('useNodePricing', () => {
expect(price).toBe('$1.40/Run')
})
it('should return high price for kling-v2-1-master model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingImage2VideoNode', [
{ name: 'model_name', value: 'v2-1-master' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.40/Run')
})
it('should return standard price for kling-v1-6 model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingImage2VideoNode', [
@@ -219,6 +239,49 @@ describe('useNodePricing', () => {
})
})
describe('dynamic pricing - MinimaxHailuoVideoNode', () => {
it('should return $0.28 for 6s duration and 768P resolution', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('MinimaxHailuoVideoNode', [
{ name: 'duration', value: '6' },
{ name: 'resolution', value: '768P' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.28/Run')
})
it('should return $0.60 for 10s duration and 768P resolution', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('MinimaxHailuoVideoNode', [
{ name: 'duration', value: '10' },
{ name: 'resolution', value: '768P' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.56/Run')
})
it('should return $0.49 for 6s duration and 1080P resolution', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('MinimaxHailuoVideoNode', [
{ name: 'duration', value: '6' },
{ name: 'resolution', value: '1080P' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.49/Run')
})
it('should return range when duration widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('MinimaxHailuoVideoNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.28-0.56/Run (varies with resolution & duration)')
})
})
describe('dynamic pricing - OpenAIDalle2', () => {
it('should return $0.02 for 1024x1024 size', () => {
const { getNodeDisplayPrice } = useNodePricing()
@@ -1384,11 +1447,19 @@ describe('useNodePricing', () => {
const testCases = [
{
model: 'gemini-2.5-pro-preview-05-06',
expected: '$0.00016/$0.0006 per 1K tokens'
expected: '$0.00125/$0.01 per 1K tokens'
},
{
model: 'gemini-2.5-pro',
expected: '$0.00125/$0.01 per 1K tokens'
},
{
model: 'gemini-2.5-flash-preview-04-17',
expected: '$0.00125/$0.01 per 1K tokens'
expected: '$0.0003/$0.0025 per 1K tokens'
},
{
model: 'gemini-2.5-flash',
expected: '$0.0003/$0.0025 per 1K tokens'
},
{ model: 'unknown-gemini-model', expected: 'Token-based' }
]
@@ -1441,7 +1512,10 @@ describe('useNodePricing', () => {
{ model: 'gpt-4o', expected: '$0.0025/$0.01 per 1K tokens' },
{ model: 'gpt-4.1-nano', expected: '$0.0001/$0.0004 per 1K tokens' },
{ model: 'gpt-4.1-mini', expected: '$0.0004/$0.0016 per 1K tokens' },
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' }
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' },
{ model: 'gpt-5-nano', expected: '$0.00005/$0.0004 per 1K tokens' },
{ model: 'gpt-5-mini', expected: '$0.00025/$0.002 per 1K tokens' },
{ model: 'gpt-5', expected: '$0.00125/$0.01 per 1K tokens' }
]
testCases.forEach(({ model, expected }) => {

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

Some files were not shown because too many files have changed in this diff Show More