Compare commits
41 Commits
fix/qwenvl
...
v1.25.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee5541b6a | ||
|
|
5918dde255 | ||
|
|
a7cbf1b90c | ||
|
|
30009e2786 | ||
|
|
3818b7ce57 | ||
|
|
aaaa8c820f | ||
|
|
f98443dbb6 | ||
|
|
1094b57eb5 | ||
|
|
f5409ea20c | ||
|
|
bfed966ae7 | ||
|
|
d903891cea | ||
|
|
1068d1bc9a | ||
|
|
6904029fad | ||
|
|
263f52f539 | ||
|
|
be7d239087 | ||
|
|
6265dfac38 | ||
|
|
97d95e5574 | ||
|
|
be4e5b0ade | ||
|
|
f6967d889e | ||
|
|
a9c80e91d3 | ||
|
|
ccee1fa7c0 | ||
|
|
3897a75621 | ||
|
|
e8c70545e3 | ||
|
|
b0223187fe | ||
|
|
ab766694e9 | ||
|
|
08f834b93c | ||
|
|
ad3eede075 | ||
|
|
fc294112e7 | ||
|
|
bbf7b4801c | ||
|
|
d1434d1c80 | ||
|
|
980e3ebfab | ||
|
|
694ff47269 | ||
|
|
b35525578c | ||
|
|
8872caaf4d | ||
|
|
3def157b96 | ||
|
|
1abf9a5e86 | ||
|
|
2d4dba3f19 | ||
|
|
aa9b70656e | ||
|
|
80d54eca2f | ||
|
|
a9f05bd604 | ||
|
|
53f5927d4b |
@@ -9,7 +9,7 @@ module.exports = defineConfig({
|
|||||||
entry: 'src/locales/en',
|
entry: 'src/locales/en',
|
||||||
entryLocale: 'en',
|
entryLocale: 'en',
|
||||||
output: 'src/locales',
|
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.
|
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'.
|
'latent' is the short form of 'latent space'.
|
||||||
'mask' is in the context of image processing.
|
'mask' is in the context of image processing.
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ The development of successive minor versions overlaps. For example, while versio
|
|||||||
<summary>v1.5: Native translation (i18n)</summary>
|
<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
|
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>
|
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
|
More details available here: https://blog.comfy.org/p/native-localization-support-i18n
|
||||||
|
|||||||
@@ -767,8 +767,8 @@ export class ComfyPage {
|
|||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
async rightClickCanvas() {
|
async rightClickCanvas(x: number = 10, y: number = 10) {
|
||||||
await this.page.mouse.click(10, 10, { button: 'right' })
|
await this.page.mouse.click(x, y, { button: 'right' })
|
||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
280
browser_tests/tests/bottomPanelShortcuts.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
|
|||||||
await libraryTab.open()
|
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)
|
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
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
const initialNodeCount = await comfyPage.getGraphNodesCount()
|
const initialNodeCount = await comfyPage.getGraphNodesCount()
|
||||||
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
|
|||||||
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
|
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.getFolder(groupNodeCategory).click()
|
||||||
await libraryTab
|
await libraryTab
|
||||||
.getNode(groupNodeName)
|
.getNode(groupNodeName)
|
||||||
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
|
|||||||
).toHaveLength(0)
|
).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.getFolder(groupNodeCategory).click()
|
||||||
await libraryTab
|
await libraryTab
|
||||||
.getNode(groupNodeName)
|
.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.setSetting('Comfy.EnableTooltips', true)
|
||||||
await comfyPage.convertAllNodesToGroupNode('Group Node')
|
await comfyPage.convertAllNodesToGroupNode('Group Node')
|
||||||
await comfyPage.page.mouse.move(47, 173)
|
await comfyPage.page.mouse.move(47, 173)
|
||||||
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
|
|||||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
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
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
const makeGroup = async (name, type1, type2) => {
|
const makeGroup = async (name, type1, type2) => {
|
||||||
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
|
|||||||
expect(visibleInputCount).toBe(2)
|
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
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
const expectSingleNode = async (type: string) => {
|
const expectSingleNode = async (type: string) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { Locator, expect } from '@playwright/test'
|
||||||
import { Position } from '@vueuse/core'
|
import { Position } from '@vueuse/core'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -767,6 +767,17 @@ test.describe('Viewport settings', () => {
|
|||||||
comfyPage,
|
comfyPage,
|
||||||
comfyMouse
|
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
|
// Screenshot the canvas element
|
||||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||||
@@ -794,15 +805,13 @@ test.describe('Viewport settings', () => {
|
|||||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||||
|
|
||||||
// Go back to Workflow A
|
// Go back to Workflow A
|
||||||
await tabA.click()
|
await changeTab(tabA)
|
||||||
await comfyPage.nextFrame()
|
|
||||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||||
screenshotA
|
screenshotA
|
||||||
)
|
)
|
||||||
|
|
||||||
// And back to Workflow B
|
// And back to Workflow B
|
||||||
await tabB.click()
|
await changeTab(tabB)
|
||||||
await comfyPage.nextFrame()
|
|
||||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||||
screenshotB
|
screenshotB
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ test.describe('Minimap', () => {
|
|||||||
const minimapViewport = minimapContainer.locator('.minimap-viewport')
|
const minimapViewport = minimapContainer.locator('.minimap-viewport')
|
||||||
await expect(minimapViewport).toBeVisible()
|
await expect(minimapViewport).toBeVisible()
|
||||||
|
|
||||||
await expect(minimapContainer).toHaveCSS('position', 'absolute')
|
await expect(minimapContainer).toHaveCSS('position', 'relative')
|
||||||
await expect(minimapContainer).toHaveCSS('z-index', '1000')
|
|
||||||
|
// 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 }) => {
|
test('Validate minimap toggle button state', async ({ comfyPage }) => {
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ test.describe('LiteGraph Native Reroute Node', () => {
|
|||||||
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
|
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 = (
|
const loadCheckpointNode = (
|
||||||
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
||||||
)[0]
|
)[0]
|
||||||
|
|||||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -24,11 +24,11 @@ test.describe('Canvas Right Click Menu', () => {
|
|||||||
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
|
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 comfyPage.select2Nodes()
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||||
await comfyPage.rightClickCanvas()
|
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.promptDialogInput.fill('GroupNode2CLIP')
|
||||||
await comfyPage.page.keyboard.press('Enter')
|
await comfyPage.page.keyboard.press('Enter')
|
||||||
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })
|
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 98 KiB |
@@ -196,6 +196,68 @@ test.describe('Subgraph Operations', () => {
|
|||||||
const deletedNode = await comfyPage.getNodeRefById('2')
|
const deletedNode = await comfyPage.getNodeRefById('2')
|
||||||
expect(await deletedNode.exists()).toBe(false)
|
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', () => {
|
test.describe('Operations Inside Subgraphs', () => {
|
||||||
@@ -466,4 +528,103 @@ test.describe('Subgraph Operations', () => {
|
|||||||
expect(finalCount).toBe(parentCount)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
155
browser_tests/tests/workflowTabThumbnail.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@comfyorg/comfyui-frontend",
|
"name": "@comfyorg/comfyui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.25.5",
|
"version": "1.25.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||||
"homepage": "https://comfy.org",
|
"homepage": "https://comfy.org",
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ export default defineConfig({
|
|||||||
grep: /@2x/ // Run all tests tagged with @2x
|
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',
|
// name: 'firefox',
|
||||||
// use: { ...devices['Desktop Firefox'] },
|
// use: { ...devices['Desktop Firefox'] },
|
||||||
|
|||||||
@@ -11,18 +11,33 @@
|
|||||||
class="p-3 border-none"
|
class="p-3 border-none"
|
||||||
>
|
>
|
||||||
<span class="font-bold">
|
<span class="font-bold">
|
||||||
{{ tab.title.toUpperCase() }}
|
{{
|
||||||
|
shouldCapitalizeTab(tab.id)
|
||||||
|
? tab.title.toUpperCase()
|
||||||
|
: tab.title
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</Tab>
|
</Tab>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div class="flex items-center gap-2">
|
||||||
class="justify-self-end"
|
<Button
|
||||||
icon="pi pi-times"
|
v-if="isShortcutsTabActive"
|
||||||
severity="secondary"
|
:label="$t('shortcuts.manageShortcuts')"
|
||||||
size="small"
|
icon="pi pi-cog"
|
||||||
text
|
severity="secondary"
|
||||||
@click="bottomPanelStore.bottomPanelVisible = false"
|
size="small"
|
||||||
/>
|
text
|
||||||
|
@click="openKeybindingSettings"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
class="justify-self-end"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
@click="closeBottomPanel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -44,9 +59,32 @@ import Button from 'primevue/button'
|
|||||||
import Tab from 'primevue/tab'
|
import Tab from 'primevue/tab'
|
||||||
import TabList from 'primevue/tablist'
|
import TabList from 'primevue/tablist'
|
||||||
import Tabs from 'primevue/tabs'
|
import Tabs from 'primevue/tabs'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||||
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||||
|
|
||||||
const bottomPanelStore = useBottomPanelStore()
|
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>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
120
src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEventListener } from '@vueuse/core'
|
|
||||||
import Breadcrumb from 'primevue/breadcrumb'
|
import Breadcrumb from 'primevue/breadcrumb'
|
||||||
import type { MenuItem } from 'primevue/menuitem'
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import { computed, onUpdated, ref, watch } from 'vue'
|
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
|
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
|
||||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
||||||
watch(breadcrumbElement, (el) => {
|
watch(breadcrumbElement, (el) => {
|
||||||
|
|||||||
@@ -1,31 +1,66 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="visible && initialized"
|
v-if="visible && initialized"
|
||||||
ref="containerRef"
|
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
|
||||||
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
|
|
||||||
:style="containerStyles"
|
|
||||||
@pointerdown="handlePointerDown"
|
|
||||||
@pointermove="handlePointerMove"
|
|
||||||
@pointerup="handlePointerUp"
|
|
||||||
@pointerleave="handlePointerUp"
|
|
||||||
@wheel="handleWheel"
|
|
||||||
>
|
>
|
||||||
<canvas
|
<MiniMapPanel
|
||||||
ref="canvasRef"
|
v-if="showOptionsPanel"
|
||||||
:width="width"
|
:panel-styles="panelStyles"
|
||||||
:height="height"
|
:node-colors="nodeColors"
|
||||||
class="minimap-canvas"
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useMinimap } from '@/composables/useMinimap'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
|
||||||
|
import MiniMapPanel from './MiniMapPanel.vue'
|
||||||
|
|
||||||
const minimap = useMinimap()
|
const minimap = useMinimap()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
|
||||||
@@ -38,6 +73,13 @@ const {
|
|||||||
viewportStyles,
|
viewportStyles,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
panelStyles,
|
||||||
|
nodeColors,
|
||||||
|
showLinks,
|
||||||
|
showGroups,
|
||||||
|
renderBypass,
|
||||||
|
renderError,
|
||||||
|
updateOption,
|
||||||
init,
|
init,
|
||||||
destroy,
|
destroy,
|
||||||
handlePointerDown,
|
handlePointerDown,
|
||||||
@@ -46,6 +88,12 @@ const {
|
|||||||
handleWheel
|
handleWheel
|
||||||
} = minimap
|
} = minimap
|
||||||
|
|
||||||
|
const showOptionsPanel = ref(false)
|
||||||
|
|
||||||
|
const toggleOptionsPanel = () => {
|
||||||
|
showOptionsPanel.value = !showOptionsPanel.value
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => canvasStore.canvas,
|
() => canvasStore.canvas,
|
||||||
async (canvas) => {
|
async (canvas) => {
|
||||||
|
|||||||
97
src/components/graph/MiniMapPanel.vue
Normal 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>
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button
|
<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="{
|
v-tooltip.top="{
|
||||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||||
showDelay: 1000
|
showDelay: 1000
|
||||||
@@ -20,6 +34,7 @@ import Button from 'primevue/button'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
|
||||||
@@ -27,7 +42,13 @@ const { t } = useI18n()
|
|||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
|
||||||
const isVisible = computed(() => {
|
const isUnpackVisible = computed(() => {
|
||||||
|
return (
|
||||||
|
canvasStore.selectedItems?.length === 1 &&
|
||||||
|
canvasStore.selectedItems[0] instanceof SubgraphNode
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const isConvertVisible = computed(() => {
|
||||||
return (
|
return (
|
||||||
canvasStore.groupSelected ||
|
canvasStore.groupSelected ||
|
||||||
canvasStore.rerouteSelected ||
|
canvasStore.rerouteSelected ||
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||||
<SidebarHelpCenterIcon />
|
<SidebarHelpCenterIcon />
|
||||||
<SidebarBottomPanelToggleButton />
|
<SidebarBottomPanelToggleButton />
|
||||||
|
<SidebarShortcutsToggleButton />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</teleport>
|
</teleport>
|
||||||
@@ -32,6 +33,7 @@ import { computed } from 'vue'
|
|||||||
|
|
||||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||||
|
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||||
import { useSettingStore } from '@/stores/settingStore'
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
import { useUserStore } from '@/stores/userStore'
|
import { useUserStore } from '@/stores/userStore'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<SidebarIcon
|
<SidebarIcon
|
||||||
:tooltip="$t('menu.toggleBottomPanel')"
|
:tooltip="$t('menu.toggleBottomPanel')"
|
||||||
:selected="bottomPanelStore.bottomPanelVisible"
|
:selected="bottomPanelStore.activePanel == 'terminal'"
|
||||||
@click="bottomPanelStore.toggleBottomPanel"
|
@click="bottomPanelStore.toggleBottomPanel"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
|
|||||||
44
src/components/sidebar/SidebarShortcutsToggleButton.vue
Normal 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>
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
|
<div
|
||||||
<span
|
ref="workflowTabRef"
|
||||||
v-tooltip.bottom="{
|
class="flex p-2 gap-2 workflow-tab"
|
||||||
value: workflowOption.workflow.key,
|
v-bind="$attrs"
|
||||||
class: 'workflow-tab-tooltip',
|
@mouseenter="handleMouseEnter"
|
||||||
showDelay: 512
|
@mouseleave="handleMouseLeave"
|
||||||
}"
|
@click="handleClick"
|
||||||
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
|
>
|
||||||
>
|
<span class="workflow-label text-sm max-w-[150px] truncate inline-block">
|
||||||
{{ workflowOption.workflow.filename }}
|
{{ workflowOption.workflow.filename }}
|
||||||
</span>
|
</span>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -22,23 +22,33 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WorkflowTabPopover
|
||||||
|
ref="popoverRef"
|
||||||
|
:workflow-filename="workflowOption.workflow.filename"
|
||||||
|
:thumbnail-url="thumbnailUrl"
|
||||||
|
:is-active-tab="isActiveTab"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
usePragmaticDraggable,
|
usePragmaticDraggable,
|
||||||
usePragmaticDroppable
|
usePragmaticDroppable
|
||||||
} from '@/composables/usePragmaticDragAndDrop'
|
} from '@/composables/usePragmaticDragAndDrop'
|
||||||
|
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||||
import { useWorkflowService } from '@/services/workflowService'
|
import { useWorkflowService } from '@/services/workflowService'
|
||||||
import { useSettingStore } from '@/stores/settingStore'
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
|
|
||||||
|
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||||
|
|
||||||
interface WorkflowOption {
|
interface WorkflowOption {
|
||||||
value: string
|
value: string
|
||||||
workflow: ComfyWorkflow
|
workflow: ComfyWorkflow
|
||||||
@@ -55,6 +65,8 @@ const workspaceStore = useWorkspaceStore()
|
|||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||||
|
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||||
|
const workflowThumbnail = useWorkflowThumbnail()
|
||||||
|
|
||||||
// Use computed refs to cache autosave settings
|
// Use computed refs to cache autosave settings
|
||||||
const autoSaveSetting = computed(() =>
|
const autoSaveSetting = computed(() =>
|
||||||
@@ -90,6 +102,27 @@ const shouldShowStatusIndicator = computed(() => {
|
|||||||
return false
|
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[]) => {
|
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
if (
|
if (
|
||||||
@@ -135,6 +168,10 @@ usePragmaticDroppable(tabGetter, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
popoverRef.value?.hidePopover()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
229
src/components/topbar/WorkflowTabPopover.vue
Normal 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>
|
||||||
78
src/composables/bottomPanelTabs/useCommandSubcategories.ts
Normal 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' }
|
||||||
|
]
|
||||||
|
}
|
||||||
27
src/composables/bottomPanelTabs/useShortcutsTab.ts
Normal 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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -261,7 +261,10 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
return '$0.14-2.80/Run (varies with model, mode & duration)'
|
return '$0.14-2.80/Run (varies with model, mode & duration)'
|
||||||
|
|
||||||
const modelValue = String(modelWidget.value)
|
const modelValue = String(modelWidget.value)
|
||||||
if (modelValue.includes('v2-master')) {
|
if (
|
||||||
|
modelValue.includes('v2-1-master') ||
|
||||||
|
modelValue.includes('v2-master')
|
||||||
|
) {
|
||||||
return '$1.40/Run'
|
return '$1.40/Run'
|
||||||
} else if (
|
} else if (
|
||||||
modelValue.includes('v1-6') ||
|
modelValue.includes('v1-6') ||
|
||||||
@@ -280,12 +283,19 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
console.log('durationValue', durationValue)
|
console.log('durationValue', durationValue)
|
||||||
|
|
||||||
// Same pricing matrix as KlingTextToVideoNode
|
// Same pricing matrix as KlingTextToVideoNode
|
||||||
if (modelValue.includes('v2-master')) {
|
if (
|
||||||
|
modelValue.includes('v2-1-master') ||
|
||||||
|
modelValue.includes('v2-master')
|
||||||
|
) {
|
||||||
if (durationValue.includes('10')) {
|
if (durationValue.includes('10')) {
|
||||||
return '$2.80/Run'
|
return '$2.80/Run'
|
||||||
}
|
}
|
||||||
return '$1.40/Run' // 5s default
|
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')) {
|
if (modeValue.includes('pro')) {
|
||||||
return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run'
|
return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run'
|
||||||
} else {
|
} else {
|
||||||
@@ -418,7 +428,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
const modeValue = String(modeWidget.value)
|
const modeValue = String(modeWidget.value)
|
||||||
|
|
||||||
// Pricing matrix from CSV data based on mode string content
|
// 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')) {
|
if (modeValue.includes('10s')) {
|
||||||
return '$2.80/Run'
|
return '$2.80/Run'
|
||||||
}
|
}
|
||||||
@@ -558,6 +573,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
MinimaxTextToVideoNode: {
|
MinimaxTextToVideoNode: {
|
||||||
displayPrice: '$0.43/Run'
|
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: {
|
OpenAIDalle2: {
|
||||||
displayPrice: (node: LGraphNode): string => {
|
displayPrice: (node: LGraphNode): string => {
|
||||||
const sizeWidget = node.widgets?.find(
|
const sizeWidget = node.widgets?.find(
|
||||||
@@ -1278,9 +1319,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
// Google Veo video generation
|
// Google Veo video generation
|
||||||
if (model.includes('veo-2.0')) {
|
if (model.includes('veo-2.0')) {
|
||||||
return '$0.5/second'
|
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')) {
|
} 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'
|
return '$0.00125/$0.01 per 1K tokens'
|
||||||
}
|
}
|
||||||
// For other Gemini models, show token-based pricing info
|
// 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'
|
return '$0.0004/$0.0016 per 1K tokens'
|
||||||
} else if (model.includes('gpt-4.1')) {
|
} else if (model.includes('gpt-4.1')) {
|
||||||
return '$0.002/$0.008 per 1K tokens'
|
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'
|
return 'Token-based'
|
||||||
}
|
}
|
||||||
@@ -1358,6 +1409,7 @@ export const useNodePricing = () => {
|
|||||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||||
|
MinimaxHailuoVideoNode: ['resolution', 'duration'],
|
||||||
OpenAIDalle3: ['size', 'quality'],
|
OpenAIDalle3: ['size', 'quality'],
|
||||||
OpenAIDalle2: ['size', 'n'],
|
OpenAIDalle2: ['size', 'n'],
|
||||||
OpenAIGPTImage1: ['quality', 'n'],
|
OpenAIGPTImage1: ['quality', 'n'],
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import { useWorkflowService } from '@/services/workflowService'
|
|||||||
import type { ComfyCommand } from '@/stores/commandStore'
|
import type { ComfyCommand } from '@/stores/commandStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||||
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||||
import { useSettingStore } from '@/stores/settingStore'
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
|
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||||
import { useToastStore } from '@/stores/toastStore'
|
import { useToastStore } from '@/stores/toastStore'
|
||||||
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||||
@@ -46,6 +48,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
|
|
||||||
|
const bottomPanelStore = useBottomPanelStore()
|
||||||
|
|
||||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||||
useSelectedLiteGraphItems()
|
useSelectedLiteGraphItems()
|
||||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||||
@@ -70,6 +75,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-plus',
|
icon: 'pi pi-plus',
|
||||||
label: 'New Blank Workflow',
|
label: 'New Blank Workflow',
|
||||||
menubarLabel: 'New',
|
menubarLabel: 'New',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: () => workflowService.loadBlankWorkflow()
|
function: () => workflowService.loadBlankWorkflow()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,6 +83,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-folder-open',
|
icon: 'pi pi-folder-open',
|
||||||
label: 'Open Workflow',
|
label: 'Open Workflow',
|
||||||
menubarLabel: 'Open',
|
menubarLabel: 'Open',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
app.ui.loadFile()
|
app.ui.loadFile()
|
||||||
}
|
}
|
||||||
@@ -92,6 +99,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-save',
|
icon: 'pi pi-save',
|
||||||
label: 'Save Workflow',
|
label: 'Save Workflow',
|
||||||
menubarLabel: 'Save',
|
menubarLabel: 'Save',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||||
if (!workflow) return
|
if (!workflow) return
|
||||||
@@ -104,6 +112,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-save',
|
icon: 'pi pi-save',
|
||||||
label: 'Save Workflow As',
|
label: 'Save Workflow As',
|
||||||
menubarLabel: 'Save As',
|
menubarLabel: 'Save As',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||||
if (!workflow) return
|
if (!workflow) return
|
||||||
@@ -116,6 +125,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-download',
|
icon: 'pi pi-download',
|
||||||
label: 'Export Workflow',
|
label: 'Export Workflow',
|
||||||
menubarLabel: 'Export',
|
menubarLabel: 'Export',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
await workflowService.exportWorkflow('workflow', 'workflow')
|
await workflowService.exportWorkflow('workflow', 'workflow')
|
||||||
}
|
}
|
||||||
@@ -133,6 +143,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.Undo',
|
id: 'Comfy.Undo',
|
||||||
icon: 'pi pi-undo',
|
icon: 'pi pi-undo',
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
await getTracker()?.undo?.()
|
await getTracker()?.undo?.()
|
||||||
}
|
}
|
||||||
@@ -141,6 +152,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.Redo',
|
id: 'Comfy.Redo',
|
||||||
icon: 'pi pi-refresh',
|
icon: 'pi pi-refresh',
|
||||||
label: 'Redo',
|
label: 'Redo',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
await getTracker()?.redo?.()
|
await getTracker()?.redo?.()
|
||||||
}
|
}
|
||||||
@@ -149,6 +161,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.ClearWorkflow',
|
id: 'Comfy.ClearWorkflow',
|
||||||
icon: 'pi pi-trash',
|
icon: 'pi pi-trash',
|
||||||
label: 'Clear Workflow',
|
label: 'Clear Workflow',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
if (
|
if (
|
||||||
@@ -190,6 +203,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.RefreshNodeDefinitions',
|
id: 'Comfy.RefreshNodeDefinitions',
|
||||||
icon: 'pi pi-refresh',
|
icon: 'pi pi-refresh',
|
||||||
label: 'Refresh Node Definitions',
|
label: 'Refresh Node Definitions',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
await app.refreshComboInNodes()
|
await app.refreshComboInNodes()
|
||||||
}
|
}
|
||||||
@@ -198,6 +212,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.Interrupt',
|
id: 'Comfy.Interrupt',
|
||||||
icon: 'pi pi-stop',
|
icon: 'pi pi-stop',
|
||||||
label: 'Interrupt',
|
label: 'Interrupt',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
await api.interrupt(executionStore.activePromptId)
|
await api.interrupt(executionStore.activePromptId)
|
||||||
toastStore.add({
|
toastStore.add({
|
||||||
@@ -212,6 +227,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.ClearPendingTasks',
|
id: 'Comfy.ClearPendingTasks',
|
||||||
icon: 'pi pi-stop',
|
icon: 'pi pi-stop',
|
||||||
label: 'Clear Pending Tasks',
|
label: 'Clear Pending Tasks',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
await useQueueStore().clear(['queue'])
|
await useQueueStore().clear(['queue'])
|
||||||
toastStore.add({
|
toastStore.add({
|
||||||
@@ -234,6 +250,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.Canvas.ZoomIn',
|
id: 'Comfy.Canvas.ZoomIn',
|
||||||
icon: 'pi pi-plus',
|
icon: 'pi pi-plus',
|
||||||
label: 'Zoom In',
|
label: 'Zoom In',
|
||||||
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
const ds = app.canvas.ds
|
const ds = app.canvas.ds
|
||||||
ds.changeScale(
|
ds.changeScale(
|
||||||
@@ -247,6 +264,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.Canvas.ZoomOut',
|
id: 'Comfy.Canvas.ZoomOut',
|
||||||
icon: 'pi pi-minus',
|
icon: 'pi pi-minus',
|
||||||
label: 'Zoom Out',
|
label: 'Zoom Out',
|
||||||
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
const ds = app.canvas.ds
|
const ds = app.canvas.ds
|
||||||
ds.changeScale(
|
ds.changeScale(
|
||||||
@@ -260,6 +278,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.Canvas.FitView',
|
id: 'Comfy.Canvas.FitView',
|
||||||
icon: 'pi pi-expand',
|
icon: 'pi pi-expand',
|
||||||
label: 'Fit view to selected nodes',
|
label: 'Fit view to selected nodes',
|
||||||
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
if (app.canvas.empty) {
|
if (app.canvas.empty) {
|
||||||
toastStore.add({
|
toastStore.add({
|
||||||
@@ -325,6 +344,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-play',
|
icon: 'pi pi-play',
|
||||||
label: 'Queue Prompt',
|
label: 'Queue Prompt',
|
||||||
versionAdded: '1.3.7',
|
versionAdded: '1.3.7',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
const batchCount = useQueueSettingsStore().batchCount
|
const batchCount = useQueueSettingsStore().batchCount
|
||||||
await app.queuePrompt(0, batchCount)
|
await app.queuePrompt(0, batchCount)
|
||||||
@@ -335,6 +355,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-play',
|
icon: 'pi pi-play',
|
||||||
label: 'Queue Prompt (Front)',
|
label: 'Queue Prompt (Front)',
|
||||||
versionAdded: '1.3.7',
|
versionAdded: '1.3.7',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
const batchCount = useQueueSettingsStore().batchCount
|
const batchCount = useQueueSettingsStore().batchCount
|
||||||
await app.queuePrompt(-1, batchCount)
|
await app.queuePrompt(-1, batchCount)
|
||||||
@@ -371,6 +392,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-cog',
|
icon: 'pi pi-cog',
|
||||||
label: 'Show Settings Dialog',
|
label: 'Show Settings Dialog',
|
||||||
versionAdded: '1.3.7',
|
versionAdded: '1.3.7',
|
||||||
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
dialogService.showSettingsDialog()
|
dialogService.showSettingsDialog()
|
||||||
}
|
}
|
||||||
@@ -380,6 +402,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-sitemap',
|
icon: 'pi pi-sitemap',
|
||||||
label: 'Group Selected Nodes',
|
label: 'Group Selected Nodes',
|
||||||
versionAdded: '1.3.7',
|
versionAdded: '1.3.7',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
const { canvas } = app
|
const { canvas } = app
|
||||||
if (!canvas.selectedItems?.size) {
|
if (!canvas.selectedItems?.size) {
|
||||||
@@ -423,6 +446,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-volume-off',
|
icon: 'pi pi-volume-off',
|
||||||
label: 'Mute/Unmute Selected Nodes',
|
label: 'Mute/Unmute Selected Nodes',
|
||||||
versionAdded: '1.3.11',
|
versionAdded: '1.3.11',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||||
app.canvas.setDirty(true, true)
|
app.canvas.setDirty(true, true)
|
||||||
@@ -433,6 +457,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-shield',
|
icon: 'pi pi-shield',
|
||||||
label: 'Bypass/Unbypass Selected Nodes',
|
label: 'Bypass/Unbypass Selected Nodes',
|
||||||
versionAdded: '1.3.11',
|
versionAdded: '1.3.11',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||||
app.canvas.setDirty(true, true)
|
app.canvas.setDirty(true, true)
|
||||||
@@ -443,6 +468,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-pin',
|
icon: 'pi pi-pin',
|
||||||
label: 'Pin/Unpin Selected Nodes',
|
label: 'Pin/Unpin Selected Nodes',
|
||||||
versionAdded: '1.3.11',
|
versionAdded: '1.3.11',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
getSelectedNodes().forEach((node) => {
|
getSelectedNodes().forEach((node) => {
|
||||||
node.pin(!node.pinned)
|
node.pin(!node.pinned)
|
||||||
@@ -516,8 +542,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-list',
|
icon: 'pi pi-list',
|
||||||
label: 'Toggle Bottom Panel',
|
label: 'Toggle Bottom Panel',
|
||||||
versionAdded: '1.3.22',
|
versionAdded: '1.3.22',
|
||||||
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
useBottomPanelStore().toggleBottomPanel()
|
bottomPanelStore.toggleBottomPanel()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -525,6 +552,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-eye',
|
icon: 'pi pi-eye',
|
||||||
label: 'Toggle Focus Mode',
|
label: 'Toggle Focus Mode',
|
||||||
versionAdded: '1.3.27',
|
versionAdded: '1.3.27',
|
||||||
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
useWorkspaceStore().toggleFocusMode()
|
useWorkspaceStore().toggleFocusMode()
|
||||||
}
|
}
|
||||||
@@ -750,6 +778,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-sitemap',
|
icon: 'pi pi-sitemap',
|
||||||
label: 'Convert Selection to Subgraph',
|
label: 'Convert Selection to Subgraph',
|
||||||
versionAdded: '1.20.1',
|
versionAdded: '1.20.1',
|
||||||
|
category: 'essentials' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
const canvas = canvasStore.getCanvas()
|
const canvas = canvasStore.getCanvas()
|
||||||
const graph = canvas.subgraph ?? canvas.graph
|
const graph = canvas.subgraph ?? canvas.graph
|
||||||
@@ -767,6 +796,48 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
}
|
}
|
||||||
const { node } = res
|
const { node } = res
|
||||||
canvas.select(node)
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { useRafFn, useThrottleFn } from '@vueuse/core'
|
|||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
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 type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app } from '@/scripts/app'
|
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
import { useSettingStore } from '@/stores/settingStore'
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
|
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||||
|
import { adjustColor } from '@/utils/colorUtil'
|
||||||
|
|
||||||
interface GraphCallbacks {
|
interface GraphCallbacks {
|
||||||
onNodeAdded?: (node: LGraphNode) => void
|
onNodeAdded?: (node: LGraphNode) => void
|
||||||
@@ -16,9 +17,17 @@ interface GraphCallbacks {
|
|||||||
onConnectionChange?: (node: LGraphNode) => void
|
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() {
|
export function useMinimap() {
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
const colorPaletteStore = useColorPaletteStore()
|
||||||
|
|
||||||
const containerRef = ref<HTMLDivElement>()
|
const containerRef = ref<HTMLDivElement>()
|
||||||
@@ -27,6 +36,27 @@ export function useMinimap() {
|
|||||||
|
|
||||||
const visible = ref(true)
|
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 initialized = ref(false)
|
||||||
const bounds = ref({
|
const bounds = ref({
|
||||||
minX: 0,
|
minX: 0,
|
||||||
@@ -63,10 +93,22 @@ export function useMinimap() {
|
|||||||
const nodeColor = computed(
|
const nodeColor = computed(
|
||||||
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
|
() => (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(
|
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 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({
|
const containerRect = ref({
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -106,7 +148,11 @@ export function useMinimap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canvas = computed(() => canvasStore.canvas)
|
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(() => ({
|
const containerStyles = computed(() => ({
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
@@ -116,6 +162,14 @@ export function useMinimap() {
|
|||||||
borderRadius: '8px'
|
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(() => ({
|
const viewportStyles = computed(() => ({
|
||||||
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
|
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
|
||||||
width: `${viewportTransform.value.width}px`,
|
width: `${viewportTransform.value.width}px`,
|
||||||
@@ -189,6 +243,35 @@ export function useMinimap() {
|
|||||||
return Math.min(scaleX, scaleY) * 0.9
|
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 = (
|
const renderNodes = (
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
offsetX: number,
|
offsetX: number,
|
||||||
@@ -203,9 +286,29 @@ export function useMinimap() {
|
|||||||
const w = node.size[0] * scale.value
|
const w = node.size[0] * scale.value
|
||||||
const h = node.size[1] * 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
|
// Render solid node blocks
|
||||||
ctx.fillStyle = nodeColor.value
|
ctx.fillStyle = color
|
||||||
ctx.fillRect(x, y, w, h)
|
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
|
if (!g) return
|
||||||
|
|
||||||
ctx.strokeStyle = linkColor.value
|
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<{
|
const connections: Array<{
|
||||||
x1: number
|
x1: number
|
||||||
y1: number
|
y1: number
|
||||||
@@ -304,8 +407,15 @@ export function useMinimap() {
|
|||||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||||
const offsetY = (height - bounds.value.height * 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)
|
renderNodes(ctx, offsetX, offsetY)
|
||||||
renderConnections(ctx, offsetX, offsetY)
|
|
||||||
|
|
||||||
needsFullRedraw.value = false
|
needsFullRedraw.value = false
|
||||||
updateFlags.value.nodes = false
|
updateFlags.value.nodes = false
|
||||||
@@ -522,7 +632,8 @@ export function useMinimap() {
|
|||||||
c.setDirty(true, true)
|
c.setDirty(true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let originalCallbacks: GraphCallbacks = {}
|
// Map to store original callbacks per graph ID
|
||||||
|
const originalCallbacksMap = new Map<string, GraphCallbacks>()
|
||||||
|
|
||||||
const handleGraphChanged = useThrottleFn(() => {
|
const handleGraphChanged = useThrottleFn(() => {
|
||||||
needsFullRedraw.value = true
|
needsFullRedraw.value = true
|
||||||
@@ -536,11 +647,18 @@ export function useMinimap() {
|
|||||||
const g = graph.value
|
const g = graph.value
|
||||||
if (!g) return
|
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,
|
onNodeAdded: g.onNodeAdded,
|
||||||
onNodeRemoved: g.onNodeRemoved,
|
onNodeRemoved: g.onNodeRemoved,
|
||||||
onConnectionChange: g.onConnectionChange
|
onConnectionChange: g.onConnectionChange
|
||||||
}
|
}
|
||||||
|
originalCallbacksMap.set(g.id, originalCallbacks)
|
||||||
|
|
||||||
g.onNodeAdded = function (node) {
|
g.onNodeAdded = function (node) {
|
||||||
originalCallbacks.onNodeAdded?.call(this, node)
|
originalCallbacks.onNodeAdded?.call(this, node)
|
||||||
@@ -565,15 +683,18 @@ export function useMinimap() {
|
|||||||
const g = graph.value
|
const g = graph.value
|
||||||
if (!g) return
|
if (!g) return
|
||||||
|
|
||||||
if (originalCallbacks.onNodeAdded !== undefined) {
|
const originalCallbacks = originalCallbacksMap.get(g.id)
|
||||||
g.onNodeAdded = originalCallbacks.onNodeAdded
|
if (!originalCallbacks) {
|
||||||
}
|
throw new Error(
|
||||||
if (originalCallbacks.onNodeRemoved !== undefined) {
|
'Attempted to cleanup event listeners for graph that was never set up'
|
||||||
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
)
|
||||||
}
|
|
||||||
if (originalCallbacks.onConnectionChange !== undefined) {
|
|
||||||
g.onConnectionChange = originalCallbacks.onConnectionChange
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.onNodeAdded = originalCallbacks.onNodeAdded
|
||||||
|
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||||
|
g.onConnectionChange = originalCallbacks.onConnectionChange
|
||||||
|
|
||||||
|
originalCallbacksMap.delete(g.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -646,6 +767,19 @@ export function useMinimap() {
|
|||||||
{ immediate: true, flush: 'post' }
|
{ 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) => {
|
watch(visible, async (isVisible) => {
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
@@ -690,16 +824,25 @@ export function useMinimap() {
|
|||||||
canvasRef,
|
canvasRef,
|
||||||
containerStyles,
|
containerStyles,
|
||||||
viewportStyles,
|
viewportStyles,
|
||||||
|
panelStyles,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|
||||||
|
nodeColors,
|
||||||
|
showLinks,
|
||||||
|
showGroups,
|
||||||
|
renderBypass,
|
||||||
|
renderError,
|
||||||
|
|
||||||
init,
|
init,
|
||||||
destroy,
|
destroy,
|
||||||
toggle,
|
toggle,
|
||||||
|
renderMinimap,
|
||||||
handlePointerDown,
|
handlePointerDown,
|
||||||
handlePointerMove,
|
handlePointerMove,
|
||||||
handlePointerUp,
|
handlePointerUp,
|
||||||
handleWheel,
|
handleWheel,
|
||||||
setMinimapRef
|
setMinimapRef,
|
||||||
|
updateOption
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/composables/useWorkflowThumbnail.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,5 +182,19 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
|||||||
alt: true
|
alt: true
|
||||||
},
|
},
|
||||||
commandId: 'Comfy.Canvas.ToggleMinimap'
|
commandId: 'Comfy.Canvas.ToggleMinimap'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combo: {
|
||||||
|
ctrl: true,
|
||||||
|
shift: true,
|
||||||
|
key: 'k'
|
||||||
|
},
|
||||||
|
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combo: {
|
||||||
|
key: 'Escape'
|
||||||
|
},
|
||||||
|
commandId: 'Comfy.Graph.ExitSubgraph'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -300,7 +300,8 @@ export const CORE_SETTINGS: SettingParams[] = [
|
|||||||
{ value: 'ja', text: '日本語' },
|
{ value: 'ja', text: '日本語' },
|
||||||
{ value: 'ko', text: '한국어' },
|
{ value: 'ko', text: '한국어' },
|
||||||
{ value: 'fr', text: 'Français' },
|
{ value: 'fr', text: 'Français' },
|
||||||
{ value: 'es', text: 'Español' }
|
{ value: 'es', text: 'Español' },
|
||||||
|
{ value: 'ar', text: 'عربي' }
|
||||||
],
|
],
|
||||||
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
||||||
},
|
},
|
||||||
@@ -830,6 +831,41 @@ export const CORE_SETTINGS: SettingParams[] = [
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
versionAdded: '1.25.0'
|
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',
|
id: 'Comfy.Workflow.AutoSaveDelay',
|
||||||
name: 'Auto Save Delay (ms)',
|
name: 'Auto Save Delay (ms)',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { type NodeId } from '@/lib/litegraph/src/LGraphNode'
|
|||||||
import {
|
import {
|
||||||
type ExecutableLGraphNode,
|
type ExecutableLGraphNode,
|
||||||
type ExecutionId,
|
type ExecutionId,
|
||||||
|
LGraphCanvas,
|
||||||
LGraphNode,
|
LGraphNode,
|
||||||
LiteGraph,
|
LiteGraph,
|
||||||
SubgraphNode
|
SubgraphNode
|
||||||
@@ -1172,8 +1173,7 @@ export class GroupNodeHandler {
|
|||||||
// @ts-expect-error fixme ts strict error
|
// @ts-expect-error fixme ts strict error
|
||||||
getExtraMenuOptions?.apply(this, arguments)
|
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
|
if (optionIndex === -1) optionIndex = options.length
|
||||||
else optionIndex++
|
else optionIndex++
|
||||||
options.splice(
|
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 => {
|
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
|
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
|
||||||
@@ -1729,6 +1780,9 @@ const ext: ComfyExtension = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
setup() {
|
||||||
|
addConvertToGroupOptions()
|
||||||
|
},
|
||||||
async beforeConfigureGraph(
|
async beforeConfigureGraph(
|
||||||
graphData: ComfyWorkflowJSON,
|
graphData: ComfyWorkflowJSON,
|
||||||
missingNodeTypes: string[]
|
missingNodeTypes: string[]
|
||||||
|
|||||||
@@ -3976,13 +3976,19 @@ class UIManager {
|
|||||||
const mainImageFilename =
|
const mainImageFilename =
|
||||||
new URL(mainImageUrl).searchParams.get('filename') ?? undefined
|
new URL(mainImageUrl).searchParams.get('filename') ?? undefined
|
||||||
|
|
||||||
const combinedImageFilename =
|
let combinedImageFilename: string | null | undefined
|
||||||
|
if (
|
||||||
ComfyApp.clipspace?.combinedIndex !== undefined &&
|
ComfyApp.clipspace?.combinedIndex !== undefined &&
|
||||||
ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace.combinedIndex]?.src
|
ComfyApp.clipspace?.imgs &&
|
||||||
? new URL(
|
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length &&
|
||||||
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
|
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src
|
||||||
).searchParams.get('filename')
|
) {
|
||||||
: undefined
|
combinedImageFilename = new URL(
|
||||||
|
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
|
||||||
|
).searchParams.get('filename')
|
||||||
|
} else {
|
||||||
|
combinedImageFilename = undefined
|
||||||
|
}
|
||||||
|
|
||||||
const imageLayerFilenames =
|
const imageLayerFilenames =
|
||||||
mainImageFilename !== undefined
|
mainImageFilename !== undefined
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { createI18n } from 'vue-i18n'
|
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 enCommands from './locales/en/commands.json'
|
||||||
import en from './locales/en/main.json'
|
import en from './locales/en/main.json'
|
||||||
import enNodes from './locales/en/nodeDefs.json'
|
import enNodes from './locales/en/nodeDefs.json'
|
||||||
@@ -50,7 +54,8 @@ const messages = {
|
|||||||
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
|
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
|
||||||
ko: buildLocale(ko, koNodes, koCommands, koSettings),
|
ko: buildLocale(ko, koNodes, koCommands, koSettings),
|
||||||
fr: buildLocale(fr, frNodes, frCommands, frSettings),
|
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({
|
export const i18n = createI18n({
|
||||||
|
|||||||
@@ -495,6 +495,16 @@
|
|||||||
padding-left: 12px;
|
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 {
|
.graphmenu-entry.disabled {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ export class CanvasPointer {
|
|||||||
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
|
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
|
||||||
static #maxClickDrift2 = this.#maxClickDrift ** 2
|
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. */
|
/** The element this PointerState should capture input against when dragging. */
|
||||||
element: Element
|
element: Element
|
||||||
/** Pointer ID used by drag capture. */
|
/** Pointer ID used by drag capture. */
|
||||||
@@ -77,6 +90,9 @@ export class CanvasPointer {
|
|||||||
/** The last pointerup event for the primary button */
|
/** The last pointerup event for the primary button */
|
||||||
eUp?: CanvasPointerEvent
|
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.
|
* 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.
|
* @param pointer [DEPRECATED] This parameter will be removed in a future release.
|
||||||
@@ -257,6 +273,35 @@ export class CanvasPointer {
|
|||||||
delete this.onDragStart
|
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.
|
* Resets the state of this {@link CanvasPointer} instance.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
|
|||||||
import type {
|
import type {
|
||||||
DefaultConnectionColors,
|
DefaultConnectionColors,
|
||||||
Dictionary,
|
Dictionary,
|
||||||
|
HasBoundingRect,
|
||||||
IContextMenuValue,
|
IContextMenuValue,
|
||||||
INodeInputSlot,
|
INodeInputSlot,
|
||||||
INodeOutputSlot,
|
INodeOutputSlot,
|
||||||
@@ -26,7 +27,8 @@ import type {
|
|||||||
MethodNames,
|
MethodNames,
|
||||||
OptionalProps,
|
OptionalProps,
|
||||||
Point,
|
Point,
|
||||||
Positionable
|
Positionable,
|
||||||
|
Size
|
||||||
} from './interfaces'
|
} from './interfaces'
|
||||||
import { LiteGraph, SubgraphNode } from './litegraph'
|
import { LiteGraph, SubgraphNode } from './litegraph'
|
||||||
import {
|
import {
|
||||||
@@ -1568,6 +1570,9 @@ export class LGraph
|
|||||||
boundingRect
|
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
|
// Add the subgraph node to the graph
|
||||||
this.add(subgraphNode)
|
this.add(subgraphNode)
|
||||||
|
|
||||||
@@ -1663,6 +1668,271 @@ export class LGraph
|
|||||||
return { subgraph, node: subgraphNode as SubgraphNode }
|
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.
|
* Resolve a path of subgraph node IDs into a list of subgraph nodes.
|
||||||
* Not intended to be run from subgraphs.
|
* Not intended to be run from subgraphs.
|
||||||
@@ -2331,6 +2601,9 @@ export class Subgraph
|
|||||||
nodes: this.nodes.map((node) => node.serialize()),
|
nodes: this.nodes.map((node) => node.serialize()),
|
||||||
groups: this.groups.map((group) => group.serialize()),
|
groups: this.groups.map((group) => group.serialize()),
|
||||||
links: [...this.links.values()].map((x) => x.asSerialisable()),
|
links: [...this.links.values()].map((x) => x.asSerialisable()),
|
||||||
|
reroutes: this.reroutes.size
|
||||||
|
? [...this.reroutes.values()].map((x) => x.asSerialisable())
|
||||||
|
: undefined,
|
||||||
extra: this.extra
|
extra: this.extra
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2299,6 +2299,8 @@ export class LGraphCanvas
|
|||||||
|
|
||||||
const node_data = node.clone()?.serialize()
|
const node_data = node.clone()?.serialize()
|
||||||
if (node_data?.type != null) {
|
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)
|
const cloned = LiteGraph.createNode(newType)
|
||||||
if (cloned) {
|
if (cloned) {
|
||||||
cloned.configure(node_data)
|
cloned.configure(node_data)
|
||||||
@@ -2384,7 +2386,7 @@ export class LGraphCanvas
|
|||||||
// Set the width of the line for isPointInStroke checks
|
// Set the width of the line for isPointInStroke checks
|
||||||
const { lineWidth } = this.ctx
|
const { lineWidth } = this.ctx
|
||||||
this.ctx.lineWidth = this.connections_width + 7
|
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) {
|
for (const linkSegment of this.renderedPaths) {
|
||||||
const centre = linkSegment._pos
|
const centre = linkSegment._pos
|
||||||
@@ -3453,10 +3455,6 @@ export class LGraphCanvas
|
|||||||
processMouseWheel(e: WheelEvent): void {
|
processMouseWheel(e: WheelEvent): void {
|
||||||
if (!this.graph || !this.allow_dragcanvas) return
|
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)
|
this.adjustMouseEvent(e)
|
||||||
|
|
||||||
const pos: Point = [e.clientX, e.clientY]
|
const pos: Point = [e.clientX, e.clientY]
|
||||||
@@ -3464,35 +3462,34 @@ export class LGraphCanvas
|
|||||||
|
|
||||||
let { scale } = this.ds
|
let { scale } = this.ds
|
||||||
|
|
||||||
if (
|
// Detect if this is a trackpad gesture or mouse wheel
|
||||||
LiteGraph.canvasNavigationMode === 'legacy' ||
|
const isTrackpad = this.pointer.isTrackpadGesture(e)
|
||||||
(LiteGraph.canvasNavigationMode === 'standard' && e.ctrlKey)
|
|
||||||
) {
|
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
|
||||||
if (delta > 0) {
|
// Legacy mode or standard mode with ctrl - use wheel for zoom
|
||||||
scale *= this.zoom_speed
|
if (isTrackpad) {
|
||||||
} else if (delta < 0) {
|
// Trackpad gesture - use smooth scaling
|
||||||
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) {
|
|
||||||
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
|
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
|
||||||
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
|
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
|
||||||
} else if (e.shiftKey) {
|
|
||||||
this.ds.offset[0] -= e.deltaY * 1.18 * (1 / scale)
|
|
||||||
} else {
|
} else {
|
||||||
this.ds.offset[0] -= e.deltaX * 1.18 * (1 / scale)
|
// Mouse wheel - use stepped scaling
|
||||||
this.ds.offset[1] -= e.deltaY * 1.18 * (1 / scale)
|
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: []
|
subgraphs: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: logic for traversing nested subgraphs depends on this being a set.
|
||||||
const subgraphs = new Set<Subgraph>()
|
const subgraphs = new Set<Subgraph>()
|
||||||
|
|
||||||
// Create serialisable objects
|
// Create serialisable objects
|
||||||
@@ -3648,8 +3646,13 @@ export class LGraphCanvas
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add unique subgraph entries
|
// Add unique subgraph entries
|
||||||
// TODO: Must find all nested subgraphs
|
// NOTE: subgraphs is appended to mid iteration.
|
||||||
for (const subgraph of subgraphs) {
|
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()
|
const cloned = subgraph.clone(true).asSerialisable()
|
||||||
serialisable.subgraphs.push(cloned)
|
serialisable.subgraphs.push(cloned)
|
||||||
}
|
}
|
||||||
@@ -3766,12 +3769,19 @@ export class LGraphCanvas
|
|||||||
created.push(group)
|
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
|
// Nodes
|
||||||
for (const info of parsed.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)
|
const node = info.type == null ? null : LiteGraph.createNode(info.type)
|
||||||
if (!node) {
|
if (!node) {
|
||||||
// failedNodes.push(info)
|
// failedNodes.push(info)
|
||||||
@@ -8063,18 +8073,6 @@ export class LGraphCanvas
|
|||||||
options = node.getMenuOptions(this)
|
options = node.getMenuOptions(this)
|
||||||
} else {
|
} else {
|
||||||
options = [
|
options = [
|
||||||
{
|
|
||||||
content: 'Inputs',
|
|
||||||
has_submenu: true,
|
|
||||||
disabled: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: 'Outputs',
|
|
||||||
has_submenu: true,
|
|
||||||
disabled: true,
|
|
||||||
callback: LGraphCanvas.showMenuNodeOptionalOutputs
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
{
|
{
|
||||||
content: 'Convert to Subgraph 🆕',
|
content: 'Convert to Subgraph 🆕',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
@@ -8242,15 +8240,19 @@ export class LGraphCanvas
|
|||||||
'Both in put and output slots were null when processing context menu.'
|
'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)) {
|
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
|
||||||
menu_info.push({ content: 'Rename Slot', slot })
|
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) {
|
if (node.getExtraSlotMenuOptions) {
|
||||||
menu_info.push(...node.getExtraSlotMenuOptions(slot))
|
menu_info.push(...node.getExtraSlotMenuOptions(slot))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* 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 {
|
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 reroutes = LLink.getReroutes(network, this)
|
||||||
|
|
||||||
const lastReroute = reroutes.at(-1)
|
const lastReroute = reroutes.at(-1)
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ export class LiteGraphGlobal {
|
|||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @deprecated Removed; has no effect.
|
||||||
* If `true`, mouse wheel events will be interpreted as trackpad gestures.
|
* If `true`, mouse wheel events will be interpreted as trackpad gestures.
|
||||||
* Tested on MacBook M4 Pro.
|
* Tested on MacBook M4 Pro.
|
||||||
* @default false
|
* @default false
|
||||||
@@ -292,6 +293,7 @@ export class LiteGraphGlobal {
|
|||||||
macTrackpadGestures: boolean = false
|
macTrackpadGestures: boolean = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @deprecated Removed; has no effect.
|
||||||
* If both this setting and {@link macTrackpadGestures} are `true`, trackpad gestures will
|
* If both this setting and {@link macTrackpadGestures} are `true`, trackpad gestures will
|
||||||
* only be enabled when the browser user agent includes "Mac".
|
* only be enabled when the browser user agent includes "Mac".
|
||||||
* @default true
|
* @default true
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ export class FloatingRenderLink implements RenderLink {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||||
|
return this.toType === 'output' && input.isValidTarget(this.fromSlot)
|
||||||
|
}
|
||||||
|
|
||||||
connectToInput(
|
connectToInput(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
input: INodeInputSlot,
|
input: INodeInputSlot,
|
||||||
|
|||||||
@@ -651,6 +651,20 @@ export class LinkConnector {
|
|||||||
if (!input) throw new Error('No input slot found for link.')
|
if (!input) throw new Error('No input slot found for link.')
|
||||||
|
|
||||||
for (const link of renderLinks) {
|
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)
|
link.connectToSubgraphInput(input, this.events)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -795,7 +809,10 @@ export class LinkConnector {
|
|||||||
*/
|
*/
|
||||||
disconnectLinks(): void {
|
disconnectLinks(): void {
|
||||||
for (const link of this.renderLinks) {
|
for (const link of this.renderLinks) {
|
||||||
if (link instanceof MovingLinkBase) {
|
if (
|
||||||
|
link instanceof MovingLinkBase ||
|
||||||
|
link instanceof ToInputFromIoNodeLink
|
||||||
|
) {
|
||||||
link.disconnect()
|
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.
|
* 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.
|
* @param reroute The reroute that would be dropped on.
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export class MovingOutputLink extends MovingLinkBase {
|
|||||||
return reroute.origin_id !== this.outputNode.id
|
return reroute.origin_id !== this.outputNode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||||
|
return input.isValidTarget(this.fromSlot)
|
||||||
|
}
|
||||||
|
|
||||||
connectToInput(): never {
|
connectToInput(): never {
|
||||||
throw new Error('MovingOutputLink cannot connect to an input.')
|
throw new Error('MovingOutputLink cannot connect to an input.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,4 +135,9 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
|||||||
connectToRerouteOutput() {
|
connectToRerouteOutput() {
|
||||||
throw new Error('ToInputRenderLink cannot connect to an output.')
|
throw new Error('ToInputRenderLink cannot connect to an output.')
|
||||||
}
|
}
|
||||||
|
disconnect(): boolean {
|
||||||
|
if (!this.existingLink) return false
|
||||||
|
this.existingLink.disconnect(this.network, 'input')
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export class ToOutputRenderLink implements RenderLink {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||||
|
return input.isValidTarget(this.fromSlot)
|
||||||
|
}
|
||||||
|
|
||||||
connectToOutput(
|
connectToOutput(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
output: INodeOutputSlot,
|
output: INodeOutputSlot,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
CallbackReturn,
|
CallbackReturn,
|
||||||
ISlotType
|
ISlotType
|
||||||
} from '@/lib/litegraph/src/interfaces'
|
} 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 { Subgraph } from './Subgraph'
|
||||||
import type { SubgraphNode } from './SubgraphNode'
|
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
|
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
|
||||||
if (this.mode === LGraphEventMode.BYPASS) {
|
if (this.mode === LGraphEventMode.BYPASS) {
|
||||||
const { inputs } = this
|
|
||||||
|
|
||||||
// Bypass nodes by finding first input with matching type
|
// Bypass nodes by finding first input with matching type
|
||||||
const parentInputIndexes = Object.keys(inputs).map(Number)
|
const matchingIndex = this.#getBypassSlotIndex(slot, type)
|
||||||
// Prioritise exact slot index
|
|
||||||
const indexes = [slot, ...parentInputIndexes]
|
|
||||||
const matchingIndex = indexes.find((i) => inputs[i]?.type === type)
|
|
||||||
|
|
||||||
// No input types match
|
// No input types match
|
||||||
if (matchingIndex === undefined) {
|
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.
|
* 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.
|
* @param slot The slot index of the output on the subgraph node.
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export abstract class SubgraphIONodeBase<
|
|||||||
* @param event The event that triggered the context menu.
|
* @param event The event that triggered the context menu.
|
||||||
*/
|
*/
|
||||||
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
|
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
|
if (!(options.length > 0)) return
|
||||||
|
|
||||||
new LiteGraph.ContextMenu(options, {
|
new LiteGraph.ContextMenu(options, {
|
||||||
@@ -193,20 +193,26 @@ export abstract class SubgraphIONodeBase<
|
|||||||
* @param slot The slot to get the context menu options for.
|
* @param slot The slot to get the context menu options for.
|
||||||
* @returns The context menu options.
|
* @returns The context menu options.
|
||||||
*/
|
*/
|
||||||
#getSlotMenuOptions(slot: TSlot): IContextMenuValue[] {
|
#getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
|
||||||
const options: IContextMenuValue[] = []
|
const options: (IContextMenuValue | null)[] = []
|
||||||
|
|
||||||
// Disconnect option if slot has connections
|
// Disconnect option if slot has connections
|
||||||
if (slot !== this.emptySlot && slot.linkIds.length > 0) {
|
if (slot !== this.emptySlot && slot.linkIds.length > 0) {
|
||||||
options.push({ content: 'Disconnect Links', value: 'disconnect' })
|
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) {
|
if (slot !== this.emptySlot) {
|
||||||
options.push(
|
options.push({ content: 'Rename Slot', value: 'rename' })
|
||||||
{ content: 'Remove Slot', value: 'remove' },
|
}
|
||||||
{ content: 'Rename Slot', value: 'rename' }
|
|
||||||
)
|
if (slot !== this.emptySlot) {
|
||||||
|
options.push(null) // separator
|
||||||
|
options.push({
|
||||||
|
content: 'Remove Slot',
|
||||||
|
value: 'remove',
|
||||||
|
className: 'danger'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import type {
|
|||||||
GraphOrSubgraph,
|
GraphOrSubgraph,
|
||||||
Subgraph
|
Subgraph
|
||||||
} from '@/lib/litegraph/src/subgraph/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 { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
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')
|
if (!input) throw new Error('Subgraph input not found')
|
||||||
|
|
||||||
input.label = newName
|
input.label = newName
|
||||||
|
if (input._widget) {
|
||||||
|
input._widget.label = newName
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ signal }
|
{ signal }
|
||||||
)
|
)
|
||||||
@@ -168,7 +174,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
|||||||
subgraphInput: SubgraphInput,
|
subgraphInput: SubgraphInput,
|
||||||
input: INodeInputSlot & Partial<ISubgraphInput>
|
input: INodeInputSlot & Partial<ISubgraphInput>
|
||||||
) {
|
) {
|
||||||
input._listenerController?.abort()
|
if (
|
||||||
|
input._listenerController &&
|
||||||
|
typeof input._listenerController.abort === 'function'
|
||||||
|
) {
|
||||||
|
input._listenerController.abort()
|
||||||
|
}
|
||||||
input._listenerController = new AbortController()
|
input._listenerController = new AbortController()
|
||||||
const { signal } = input._listenerController
|
const { signal } = input._listenerController
|
||||||
|
|
||||||
@@ -204,7 +215,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
|||||||
|
|
||||||
override configure(info: ExportedSubgraphInstance): void {
|
override configure(info: ExportedSubgraphInstance): void {
|
||||||
for (const input of this.inputs) {
|
for (const input of this.inputs) {
|
||||||
input._listenerController?.abort()
|
if (
|
||||||
|
input._listenerController &&
|
||||||
|
typeof input._listenerController.abort === 'function'
|
||||||
|
) {
|
||||||
|
input._listenerController.abort()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inputs.length = 0
|
this.inputs.length = 0
|
||||||
@@ -253,10 +269,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
|||||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||||
(slot) => slot.name === input.name
|
(slot) => slot.name === input.name
|
||||||
)
|
)
|
||||||
if (!subgraphInput)
|
if (!subgraphInput) {
|
||||||
throw new Error(
|
// Skip inputs that don't exist in the subgraph definition
|
||||||
`[SubgraphNode.configure] No subgraph input found for input ${input.name}`
|
// 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)
|
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||||
|
|
||||||
@@ -515,7 +535,44 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const input of this.inputs) {
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
import { test } from './testExtensions'
|
||||||
|
|
||||||
@@ -14,4 +14,84 @@ describe('LLink', () => {
|
|||||||
const link = new LLink(1, 'float', 4, 2, 5, 3)
|
const link = new LLink(1, 'float', 4, 2, 5, 3)
|
||||||
expect(link.serialize()).toMatchSnapshot('Basic')
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
200
src/lib/litegraph/test/subgraph/SubgraphConversion.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
270
src/locales/ar/commands.json
Normal 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
8653
src/locales/ar/nodeDefs.json
Normal file
416
src/locales/ar/settings.json
Normal 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": "الالتصاق بالشبكة دائمًا"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,12 @@
|
|||||||
"Comfy_Graph_ConvertToSubgraph": {
|
"Comfy_Graph_ConvertToSubgraph": {
|
||||||
"label": "Convert Selection to Subgraph"
|
"label": "Convert Selection to Subgraph"
|
||||||
},
|
},
|
||||||
|
"Comfy_Graph_ExitSubgraph": {
|
||||||
|
"label": "Exit Subgraph"
|
||||||
|
},
|
||||||
|
"Comfy_Graph_UnpackSubgraph": {
|
||||||
|
"label": "Unpack the selected Subgraph"
|
||||||
|
},
|
||||||
"Comfy_Graph_FitGroupToContents": {
|
"Comfy_Graph_FitGroupToContents": {
|
||||||
"label": "Fit Group To Contents"
|
"label": "Fit Group To Contents"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -974,6 +974,7 @@
|
|||||||
"Export (API)": "Export (API)",
|
"Export (API)": "Export (API)",
|
||||||
"Give Feedback": "Give Feedback",
|
"Give Feedback": "Give Feedback",
|
||||||
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
|
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
|
||||||
|
"Exit Subgraph": "Exit Subgraph",
|
||||||
"Fit Group To Contents": "Fit Group To Contents",
|
"Fit Group To Contents": "Fit Group To Contents",
|
||||||
"Group Selected Nodes": "Group Selected Nodes",
|
"Group Selected Nodes": "Group Selected Nodes",
|
||||||
"Convert selected nodes to group node": "Convert selected nodes to group node",
|
"Convert selected nodes to group node": "Convert selected nodes to group node",
|
||||||
@@ -1630,5 +1631,26 @@
|
|||||||
"clearWorkflow": "Clear Workflow",
|
"clearWorkflow": "Clear Workflow",
|
||||||
"deleteWorkflow": "Delete Workflow",
|
"deleteWorkflow": "Delete Workflow",
|
||||||
"enterNewName": "Enter new name"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +119,9 @@
|
|||||||
"Comfy_Graph_ConvertToSubgraph": {
|
"Comfy_Graph_ConvertToSubgraph": {
|
||||||
"label": "Convertir selección en subgrafo"
|
"label": "Convertir selección en subgrafo"
|
||||||
},
|
},
|
||||||
|
"Comfy_Graph_ExitSubgraph": {
|
||||||
|
"label": "Salir de subgrafo"
|
||||||
|
},
|
||||||
"Comfy_Graph_FitGroupToContents": {
|
"Comfy_Graph_FitGroupToContents": {
|
||||||
"label": "Ajustar grupo al contenido"
|
"label": "Ajustar grupo al contenido"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -765,6 +765,7 @@
|
|||||||
"Desktop User Guide": "Guía de usuario de escritorio",
|
"Desktop User Guide": "Guía de usuario de escritorio",
|
||||||
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
|
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
|
||||||
"Edit": "Editar",
|
"Edit": "Editar",
|
||||||
|
"Exit Subgraph": "Salir de subgrafo",
|
||||||
"Export": "Exportar",
|
"Export": "Exportar",
|
||||||
"Export (API)": "Exportar (API)",
|
"Export (API)": "Exportar (API)",
|
||||||
"Fit Group To Contents": "Ajustar grupo a contenidos",
|
"Fit Group To Contents": "Ajustar grupo a contenidos",
|
||||||
@@ -828,6 +829,13 @@
|
|||||||
"Zoom In": "Acercar",
|
"Zoom In": "Acercar",
|
||||||
"Zoom Out": "Alejar"
|
"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": {
|
"missingModelsDialog": {
|
||||||
"doNotAskAgain": "No mostrar esto de nuevo",
|
"doNotAskAgain": "No mostrar esto de nuevo",
|
||||||
"missingModels": "Modelos faltantes",
|
"missingModels": "Modelos faltantes",
|
||||||
|
|||||||
@@ -119,6 +119,9 @@
|
|||||||
"Comfy_Graph_ConvertToSubgraph": {
|
"Comfy_Graph_ConvertToSubgraph": {
|
||||||
"label": "Convertir la sélection en sous-graphe"
|
"label": "Convertir la sélection en sous-graphe"
|
||||||
},
|
},
|
||||||
|
"Comfy_Graph_ExitSubgraph": {
|
||||||
|
"label": "Quitter le sous-graphe"
|
||||||
|
},
|
||||||
"Comfy_Graph_FitGroupToContents": {
|
"Comfy_Graph_FitGroupToContents": {
|
||||||
"label": "Ajuster le groupe au contenu"
|
"label": "Ajuster le groupe au contenu"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -765,6 +765,7 @@
|
|||||||
"Desktop User Guide": "Guide de l'utilisateur de bureau",
|
"Desktop User Guide": "Guide de l'utilisateur de bureau",
|
||||||
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
|
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
|
||||||
"Edit": "Éditer",
|
"Edit": "Éditer",
|
||||||
|
"Exit Subgraph": "Quitter le sous-graphe",
|
||||||
"Export": "Exporter",
|
"Export": "Exporter",
|
||||||
"Export (API)": "Exporter (API)",
|
"Export (API)": "Exporter (API)",
|
||||||
"Fit Group To Contents": "Ajuster le groupe au contenu",
|
"Fit Group To Contents": "Ajuster le groupe au contenu",
|
||||||
@@ -828,6 +829,13 @@
|
|||||||
"Zoom In": "Zoom avant",
|
"Zoom In": "Zoom avant",
|
||||||
"Zoom Out": "Zoom arrière"
|
"Zoom Out": "Zoom arrière"
|
||||||
},
|
},
|
||||||
|
"minimap": {
|
||||||
|
"nodeColors": "Couleurs des nœuds",
|
||||||
|
"renderBypassState": "Afficher l’état de contournement",
|
||||||
|
"renderErrorState": "Afficher l’état d’erreur",
|
||||||
|
"showGroups": "Afficher les cadres/groupes",
|
||||||
|
"showLinks": "Afficher les liens"
|
||||||
|
},
|
||||||
"missingModelsDialog": {
|
"missingModelsDialog": {
|
||||||
"doNotAskAgain": "Ne plus afficher ce message",
|
"doNotAskAgain": "Ne plus afficher ce message",
|
||||||
"missingModels": "Modèles manquants",
|
"missingModels": "Modèles manquants",
|
||||||
|
|||||||
@@ -119,6 +119,9 @@
|
|||||||
"Comfy_Graph_ConvertToSubgraph": {
|
"Comfy_Graph_ConvertToSubgraph": {
|
||||||
"label": "選択範囲をサブグラフに変換"
|
"label": "選択範囲をサブグラフに変換"
|
||||||
},
|
},
|
||||||
|
"Comfy_Graph_ExitSubgraph": {
|
||||||
|
"label": "サブグラフを終了"
|
||||||
|
},
|
||||||
"Comfy_Graph_FitGroupToContents": {
|
"Comfy_Graph_FitGroupToContents": {
|
||||||
"label": "グループを内容に合わせて調整"
|
"label": "グループを内容に合わせて調整"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -765,6 +765,7 @@
|
|||||||
"Desktop User Guide": "デスクトップユーザーガイド",
|
"Desktop User Guide": "デスクトップユーザーガイド",
|
||||||
"Duplicate Current Workflow": "現在のワークフローを複製",
|
"Duplicate Current Workflow": "現在のワークフローを複製",
|
||||||
"Edit": "編集",
|
"Edit": "編集",
|
||||||
|
"Exit Subgraph": "サブグラフを終了",
|
||||||
"Export": "エクスポート",
|
"Export": "エクスポート",
|
||||||
"Export (API)": "エクスポート (API)",
|
"Export (API)": "エクスポート (API)",
|
||||||
"Fit Group To Contents": "グループを内容に合わせる",
|
"Fit Group To Contents": "グループを内容に合わせる",
|
||||||
@@ -828,6 +829,13 @@
|
|||||||
"Zoom In": "ズームイン",
|
"Zoom In": "ズームイン",
|
||||||
"Zoom Out": "ズームアウト"
|
"Zoom Out": "ズームアウト"
|
||||||
},
|
},
|
||||||
|
"minimap": {
|
||||||
|
"nodeColors": "ノードの色",
|
||||||
|
"renderBypassState": "バイパス状態を表示",
|
||||||
|
"renderErrorState": "エラー状態を表示",
|
||||||
|
"showGroups": "フレーム/グループを表示",
|
||||||
|
"showLinks": "リンクを表示"
|
||||||
|
},
|
||||||
"missingModelsDialog": {
|
"missingModelsDialog": {
|
||||||
"doNotAskAgain": "再度表示しない",
|
"doNotAskAgain": "再度表示しない",
|
||||||
"missingModels": "モデルが見つかりません",
|
"missingModels": "モデルが見つかりません",
|
||||||
|
|||||||
@@ -119,6 +119,9 @@
|
|||||||
"Comfy_Graph_ConvertToSubgraph": {
|
"Comfy_Graph_ConvertToSubgraph": {
|
||||||
"label": "선택 영역을 서브그래프로 변환"
|
"label": "선택 영역을 서브그래프로 변환"
|
||||||
},
|
},
|
||||||
|
"Comfy_Graph_ExitSubgraph": {
|
||||||
|
"label": "서브그래프 종료"
|
||||||
|
},
|
||||||
"Comfy_Graph_FitGroupToContents": {
|
"Comfy_Graph_FitGroupToContents": {
|
||||||
"label": "그룹을 내용에 맞게 맞추기"
|
"label": "그룹을 내용에 맞게 맞추기"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -765,6 +765,7 @@
|
|||||||
"Desktop User Guide": "데스크톱 사용자 가이드",
|
"Desktop User Guide": "데스크톱 사용자 가이드",
|
||||||
"Duplicate Current Workflow": "현재 워크플로 복제",
|
"Duplicate Current Workflow": "현재 워크플로 복제",
|
||||||
"Edit": "편집",
|
"Edit": "편집",
|
||||||
|
"Exit Subgraph": "서브그래프 종료",
|
||||||
"Export": "내보내기",
|
"Export": "내보내기",
|
||||||
"Export (API)": "내보내기 (API)",
|
"Export (API)": "내보내기 (API)",
|
||||||
"Fit Group To Contents": "그룹을 내용에 맞게 조정",
|
"Fit Group To Contents": "그룹을 내용에 맞게 조정",
|
||||||
@@ -828,6 +829,13 @@
|
|||||||
"Zoom In": "확대",
|
"Zoom In": "확대",
|
||||||
"Zoom Out": "축소"
|
"Zoom Out": "축소"
|
||||||
},
|
},
|
||||||
|
"minimap": {
|
||||||
|
"nodeColors": "노드 색상",
|
||||||
|
"renderBypassState": "바이패스 상태 렌더링",
|
||||||
|
"renderErrorState": "에러 상태 렌더링",
|
||||||
|
"showGroups": "프레임/그룹 표시",
|
||||||
|
"showLinks": "링크 표시"
|
||||||
|
},
|
||||||
"missingModelsDialog": {
|
"missingModelsDialog": {
|
||||||
"doNotAskAgain": "다시 보지 않기",
|
"doNotAskAgain": "다시 보지 않기",
|
||||||
"missingModels": "모델이 없습니다",
|
"missingModels": "모델이 없습니다",
|
||||||
|
|||||||
@@ -119,6 +119,9 @@
|
|||||||
"Comfy_Graph_ConvertToSubgraph": {
|
"Comfy_Graph_ConvertToSubgraph": {
|
||||||
"label": "Преобразовать выделенное в подграф"
|
"label": "Преобразовать выделенное в подграф"
|
||||||
},
|
},
|
||||||
|
"Comfy_Graph_ExitSubgraph": {
|
||||||
|
"label": "Выйти из подграфа"
|
||||||
|
},
|
||||||
"Comfy_Graph_FitGroupToContents": {
|
"Comfy_Graph_FitGroupToContents": {
|
||||||
"label": "Подогнать группу к содержимому"
|
"label": "Подогнать группу к содержимому"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -765,6 +765,7 @@
|
|||||||
"Desktop User Guide": "Руководство пользователя для настольных ПК",
|
"Desktop User Guide": "Руководство пользователя для настольных ПК",
|
||||||
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
|
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
|
||||||
"Edit": "Редактировать",
|
"Edit": "Редактировать",
|
||||||
|
"Exit Subgraph": "Выйти из подграфа",
|
||||||
"Export": "Экспортировать",
|
"Export": "Экспортировать",
|
||||||
"Export (API)": "Экспорт (API)",
|
"Export (API)": "Экспорт (API)",
|
||||||
"Fit Group To Contents": "Подогнать группу под содержимое",
|
"Fit Group To Contents": "Подогнать группу под содержимое",
|
||||||
@@ -828,6 +829,13 @@
|
|||||||
"Zoom In": "Увеличить",
|
"Zoom In": "Увеличить",
|
||||||
"Zoom Out": "Уменьшить"
|
"Zoom Out": "Уменьшить"
|
||||||
},
|
},
|
||||||
|
"minimap": {
|
||||||
|
"nodeColors": "Цвета узлов",
|
||||||
|
"renderBypassState": "Отображать состояние обхода",
|
||||||
|
"renderErrorState": "Отображать состояние ошибки",
|
||||||
|
"showGroups": "Показать фреймы/группы",
|
||||||
|
"showLinks": "Показать связи"
|
||||||
|
},
|
||||||
"missingModelsDialog": {
|
"missingModelsDialog": {
|
||||||
"doNotAskAgain": "Больше не показывать это",
|
"doNotAskAgain": "Больше не показывать это",
|
||||||
"missingModels": "Отсутствующие модели",
|
"missingModels": "Отсутствующие модели",
|
||||||
|
|||||||
@@ -119,6 +119,9 @@
|
|||||||
"Comfy_Graph_ConvertToSubgraph": {
|
"Comfy_Graph_ConvertToSubgraph": {
|
||||||
"label": "將選取內容轉換為子圖"
|
"label": "將選取內容轉換為子圖"
|
||||||
},
|
},
|
||||||
|
"Comfy_Graph_ExitSubgraph": {
|
||||||
|
"label": "離開子圖"
|
||||||
|
},
|
||||||
"Comfy_Graph_FitGroupToContents": {
|
"Comfy_Graph_FitGroupToContents": {
|
||||||
"label": "調整群組以符合內容"
|
"label": "調整群組以符合內容"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -765,6 +765,7 @@
|
|||||||
"Desktop User Guide": "桌面應用程式使用指南",
|
"Desktop User Guide": "桌面應用程式使用指南",
|
||||||
"Duplicate Current Workflow": "複製目前工作流程",
|
"Duplicate Current Workflow": "複製目前工作流程",
|
||||||
"Edit": "編輯",
|
"Edit": "編輯",
|
||||||
|
"Exit Subgraph": "離開子圖",
|
||||||
"Export": "匯出",
|
"Export": "匯出",
|
||||||
"Export (API)": "匯出(API)",
|
"Export (API)": "匯出(API)",
|
||||||
"Fit Group To Contents": "群組貼合內容",
|
"Fit Group To Contents": "群組貼合內容",
|
||||||
@@ -828,6 +829,13 @@
|
|||||||
"Zoom In": "放大",
|
"Zoom In": "放大",
|
||||||
"Zoom Out": "縮小"
|
"Zoom Out": "縮小"
|
||||||
},
|
},
|
||||||
|
"minimap": {
|
||||||
|
"nodeColors": "節點顏色",
|
||||||
|
"renderBypassState": "顯示繞過狀態",
|
||||||
|
"renderErrorState": "顯示錯誤狀態",
|
||||||
|
"showGroups": "顯示框架/群組",
|
||||||
|
"showLinks": "顯示連結"
|
||||||
|
},
|
||||||
"missingModelsDialog": {
|
"missingModelsDialog": {
|
||||||
"doNotAskAgain": "不要再顯示此訊息",
|
"doNotAskAgain": "不要再顯示此訊息",
|
||||||
"missingModels": "缺少模型",
|
"missingModels": "缺少模型",
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
"label": "锁定视图"
|
"label": "锁定视图"
|
||||||
},
|
},
|
||||||
"Comfy_Canvas_ToggleMinimap": {
|
"Comfy_Canvas_ToggleMinimap": {
|
||||||
"label": "畫布切換小地圖"
|
"label": "画布切换小地图"
|
||||||
},
|
},
|
||||||
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
|
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
|
||||||
"label": "忽略/取消忽略选中节点"
|
"label": "忽略/取消忽略选中节点"
|
||||||
@@ -119,6 +119,9 @@
|
|||||||
"Comfy_Graph_ConvertToSubgraph": {
|
"Comfy_Graph_ConvertToSubgraph": {
|
||||||
"label": "将选区转换为子图"
|
"label": "将选区转换为子图"
|
||||||
},
|
},
|
||||||
|
"Comfy_Graph_ExitSubgraph": {
|
||||||
|
"label": "退出子图"
|
||||||
|
},
|
||||||
"Comfy_Graph_FitGroupToContents": {
|
"Comfy_Graph_FitGroupToContents": {
|
||||||
"label": "适应节点框到内容"
|
"label": "适应节点框到内容"
|
||||||
},
|
},
|
||||||
@@ -162,10 +165,10 @@
|
|||||||
"label": "切换进度对话框"
|
"label": "切换进度对话框"
|
||||||
},
|
},
|
||||||
"Comfy_MaskEditor_BrushSize_Decrease": {
|
"Comfy_MaskEditor_BrushSize_Decrease": {
|
||||||
"label": "減小 MaskEditor 中的筆刷大小"
|
"label": "减小 MaskEditor 中的笔刷大小"
|
||||||
},
|
},
|
||||||
"Comfy_MaskEditor_BrushSize_Increase": {
|
"Comfy_MaskEditor_BrushSize_Increase": {
|
||||||
"label": "增加 MaskEditor 畫筆大小"
|
"label": "增加 MaskEditor 画笔大小"
|
||||||
},
|
},
|
||||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||||
"label": "打开选中节点的遮罩编辑器"
|
"label": "打开选中节点的遮罩编辑器"
|
||||||
|
|||||||
@@ -83,10 +83,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"breadcrumbsMenu": {
|
"breadcrumbsMenu": {
|
||||||
"clearWorkflow": "清除工作流程",
|
"clearWorkflow": "清空工作流",
|
||||||
"deleteWorkflow": "刪除工作流程",
|
"deleteWorkflow": "删除工作流",
|
||||||
"duplicate": "複製",
|
"duplicate": "复制",
|
||||||
"enterNewName": "輸入新名稱"
|
"enterNewName": "输入新名称"
|
||||||
},
|
},
|
||||||
"chatHistory": {
|
"chatHistory": {
|
||||||
"cancelEdit": "取消",
|
"cancelEdit": "取消",
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
"WEBCAM": "摄像头"
|
"WEBCAM": "摄像头"
|
||||||
},
|
},
|
||||||
"desktopMenu": {
|
"desktopMenu": {
|
||||||
"confirmQuit": "有未保存的工作流程开启;任何未保存的更改都将丢失。忽略此警告并退出?",
|
"confirmQuit": "存在未保存的工作流;任何未保存的更改都将丢失。忽略此警告并退出?",
|
||||||
"confirmReinstall": "这将清除您的 extra_models_config.yaml 文件,并重新开始安装。您确定吗?",
|
"confirmReinstall": "这将清除您的 extra_models_config.yaml 文件,并重新开始安装。您确定吗?",
|
||||||
"quit": "退出",
|
"quit": "退出",
|
||||||
"reinstall": "重新安装"
|
"reinstall": "重新安装"
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
"category": "类别",
|
"category": "类别",
|
||||||
"choose_file_to_upload": "选择要上传的文件",
|
"choose_file_to_upload": "选择要上传的文件",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"clearFilters": "清除篩選",
|
"clearFilters": "清除筛选",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"color": "颜色",
|
"color": "颜色",
|
||||||
"comingSoon": "即将推出",
|
"comingSoon": "即将推出",
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
"devices": "设备",
|
"devices": "设备",
|
||||||
"disableAll": "禁用全部",
|
"disableAll": "禁用全部",
|
||||||
"disabling": "禁用中",
|
"disabling": "禁用中",
|
||||||
"dismiss": "關閉",
|
"dismiss": "关闭",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"empty": "空",
|
"empty": "空",
|
||||||
@@ -312,8 +312,8 @@
|
|||||||
"filter": "过滤",
|
"filter": "过滤",
|
||||||
"findIssues": "查找问题",
|
"findIssues": "查找问题",
|
||||||
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
|
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
|
||||||
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
|
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不兼容。",
|
||||||
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
|
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。",
|
||||||
"goToNode": "转到节点",
|
"goToNode": "转到节点",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"icon": "图标",
|
"icon": "图标",
|
||||||
@@ -399,8 +399,8 @@
|
|||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"usageHint": "使用提示",
|
"usageHint": "使用提示",
|
||||||
"user": "用户",
|
"user": "用户",
|
||||||
"versionMismatchWarning": "版本相容性警告",
|
"versionMismatchWarning": "版本兼容性警告",
|
||||||
"versionMismatchWarningMessage": "{warning}:{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
|
"versionMismatchWarningMessage": "{warning}:{detail} 请参考 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新说明。",
|
||||||
"videoFailedToLoad": "视频加载失败",
|
"videoFailedToLoad": "视频加载失败",
|
||||||
"workflow": "工作流"
|
"workflow": "工作流"
|
||||||
},
|
},
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
"resetView": "重置视图",
|
"resetView": "重置视图",
|
||||||
"selectMode": "选择模式",
|
"selectMode": "选择模式",
|
||||||
"toggleLinkVisibility": "切换连线可见性",
|
"toggleLinkVisibility": "切换连线可见性",
|
||||||
"toggleMinimap": "切換小地圖",
|
"toggleMinimap": "切换小地图",
|
||||||
"zoomIn": "放大",
|
"zoomIn": "放大",
|
||||||
"zoomOut": "缩小"
|
"zoomOut": "缩小"
|
||||||
},
|
},
|
||||||
@@ -720,7 +720,7 @@
|
|||||||
"disabled": "禁用",
|
"disabled": "禁用",
|
||||||
"disabledTooltip": "工作流将不会自动执行",
|
"disabledTooltip": "工作流将不会自动执行",
|
||||||
"execute": "执行",
|
"execute": "执行",
|
||||||
"help": "說明",
|
"help": "说明",
|
||||||
"hideMenu": "隐藏菜单",
|
"hideMenu": "隐藏菜单",
|
||||||
"instant": "实时",
|
"instant": "实时",
|
||||||
"instantTooltip": "工作流将会在生成完成后立即执行",
|
"instantTooltip": "工作流将会在生成完成后立即执行",
|
||||||
@@ -734,9 +734,9 @@
|
|||||||
"run": "运行",
|
"run": "运行",
|
||||||
"runWorkflow": "运行工作流程(Shift排在前面)",
|
"runWorkflow": "运行工作流程(Shift排在前面)",
|
||||||
"runWorkflowFront": "运行工作流程(排在前面)",
|
"runWorkflowFront": "运行工作流程(排在前面)",
|
||||||
"settings": "設定",
|
"settings": "设定",
|
||||||
"showMenu": "显示菜单",
|
"showMenu": "显示菜单",
|
||||||
"theme": "主題",
|
"theme": "主题",
|
||||||
"toggleBottomPanel": "底部面板"
|
"toggleBottomPanel": "底部面板"
|
||||||
},
|
},
|
||||||
"menuLabels": {
|
"menuLabels": {
|
||||||
@@ -746,7 +746,7 @@
|
|||||||
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
|
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
|
||||||
"Canvas Toggle Link Visibility": "切换连线可见性",
|
"Canvas Toggle Link Visibility": "切换连线可见性",
|
||||||
"Canvas Toggle Lock": "切换视图锁定",
|
"Canvas Toggle Lock": "切换视图锁定",
|
||||||
"Canvas Toggle Minimap": "畫布切換小地圖",
|
"Canvas Toggle Minimap": "画布切换小地图",
|
||||||
"Check for Updates": "检查更新",
|
"Check for Updates": "检查更新",
|
||||||
"Clear Pending Tasks": "清除待处理任务",
|
"Clear Pending Tasks": "清除待处理任务",
|
||||||
"Clear Workflow": "清除工作流",
|
"Clear Workflow": "清除工作流",
|
||||||
@@ -765,6 +765,7 @@
|
|||||||
"Desktop User Guide": "桌面端用户指南",
|
"Desktop User Guide": "桌面端用户指南",
|
||||||
"Duplicate Current Workflow": "复制当前工作流",
|
"Duplicate Current Workflow": "复制当前工作流",
|
||||||
"Edit": "编辑",
|
"Edit": "编辑",
|
||||||
|
"Exit Subgraph": "退出子圖",
|
||||||
"Export": "导出",
|
"Export": "导出",
|
||||||
"Export (API)": "导出 (API)",
|
"Export (API)": "导出 (API)",
|
||||||
"Fit Group To Contents": "适应组内容",
|
"Fit Group To Contents": "适应组内容",
|
||||||
@@ -813,13 +814,13 @@
|
|||||||
"Toggle Bottom Panel": "切换底部面板",
|
"Toggle Bottom Panel": "切换底部面板",
|
||||||
"Toggle Focus Mode": "切换专注模式",
|
"Toggle Focus Mode": "切换专注模式",
|
||||||
"Toggle Logs Bottom Panel": "切换日志底部面板",
|
"Toggle Logs Bottom Panel": "切换日志底部面板",
|
||||||
"Toggle Model Library Sidebar": "切換模型庫側邊欄",
|
"Toggle Model Library Sidebar": "切换模型库侧边栏",
|
||||||
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
|
"Toggle Node Library Sidebar": "切换节点库侧边栏",
|
||||||
"Toggle Queue Sidebar": "切換佇列側邊欄",
|
"Toggle Queue Sidebar": "切换队列侧边栏",
|
||||||
"Toggle Search Box": "切换搜索框",
|
"Toggle Search Box": "切换搜索框",
|
||||||
"Toggle Terminal Bottom Panel": "切换终端底部面板",
|
"Toggle Terminal Bottom Panel": "切换终端底部面板",
|
||||||
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
|
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
|
||||||
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
|
"Toggle Workflows Sidebar": "切换工作流侧边栏",
|
||||||
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
|
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
|
||||||
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
|
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
|
||||||
"Undo": "撤销",
|
"Undo": "撤销",
|
||||||
@@ -828,6 +829,13 @@
|
|||||||
"Zoom In": "放大画面",
|
"Zoom In": "放大画面",
|
||||||
"Zoom Out": "缩小画面"
|
"Zoom Out": "缩小画面"
|
||||||
},
|
},
|
||||||
|
"minimap": {
|
||||||
|
"nodeColors": "節點顏色",
|
||||||
|
"renderBypassState": "顯示繞過狀態",
|
||||||
|
"renderErrorState": "顯示錯誤狀態",
|
||||||
|
"showGroups": "顯示框架/群組",
|
||||||
|
"showLinks": "顯示連結"
|
||||||
|
},
|
||||||
"missingModelsDialog": {
|
"missingModelsDialog": {
|
||||||
"doNotAskAgain": "不再显示此消息",
|
"doNotAskAgain": "不再显示此消息",
|
||||||
"missingModels": "缺少模型",
|
"missingModels": "缺少模型",
|
||||||
@@ -1612,10 +1620,10 @@
|
|||||||
"required": "必填"
|
"required": "必填"
|
||||||
},
|
},
|
||||||
"versionMismatchWarning": {
|
"versionMismatchWarning": {
|
||||||
"dismiss": "關閉",
|
"dismiss": "关闭",
|
||||||
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
|
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不兼容。",
|
||||||
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 版或更高版本。",
|
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 版或更高版本。",
|
||||||
"title": "版本相容性警告",
|
"title": "版本兼容性警告",
|
||||||
"updateFrontend": "更新前端"
|
"updateFrontend": "更新前端"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片,并选择“设为背景”来使用它。"
|
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片,并选择“设为背景”来使用它。"
|
||||||
},
|
},
|
||||||
"Comfy_Canvas_NavigationMode": {
|
"Comfy_Canvas_NavigationMode": {
|
||||||
"name": "畫布導航模式",
|
"name": "画布导航模式",
|
||||||
"options": {
|
"options": {
|
||||||
"Left-Click Pan (Legacy)": "左鍵拖曳(舊版)",
|
"Left-Click Pan (Legacy)": "左键拖曳(旧版)",
|
||||||
"Standard (New)": "標準(新)"
|
"Standard (New)": "标准(新)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Comfy_Canvas_SelectionToolbox": {
|
"Comfy_Canvas_SelectionToolbox": {
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
"Disabled": "禁用",
|
"Disabled": "禁用",
|
||||||
"Top": "顶部"
|
"Top": "顶部"
|
||||||
},
|
},
|
||||||
"tooltip": "選單列位置。在行動裝置上,選單始終顯示於頂端。"
|
"tooltip": "菜单栏位置。在移动设备上,菜单始终显示于顶端。"
|
||||||
},
|
},
|
||||||
"Comfy_Validation_Workflows": {
|
"Comfy_Validation_Workflows": {
|
||||||
"name": "校验工作流"
|
"name": "校验工作流"
|
||||||
|
|||||||
@@ -476,6 +476,11 @@ const zSettings = z.object({
|
|||||||
'Comfy.InstalledVersion': z.string().nullable(),
|
'Comfy.InstalledVersion': z.string().nullable(),
|
||||||
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
|
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
|
||||||
'Comfy.Minimap.Visible': 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.Canvas.NavigationMode': z.string(),
|
||||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||||
'Comfy-Desktop.SendStatistics': z.boolean(),
|
'Comfy-Desktop.SendStatistics': z.boolean(),
|
||||||
|
|||||||
@@ -385,8 +385,15 @@ export class ComfyApp {
|
|||||||
static pasteFromClipspace(node: LGraphNode) {
|
static pasteFromClipspace(node: LGraphNode) {
|
||||||
if (ComfyApp.clipspace) {
|
if (ComfyApp.clipspace) {
|
||||||
// image paste
|
// image paste
|
||||||
const combinedImgSrc =
|
let combinedImgSrc: string | undefined
|
||||||
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src
|
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 (ComfyApp.clipspace.imgs && node.imgs) {
|
||||||
if (node.images && ComfyApp.clipspace.images) {
|
if (node.images && ComfyApp.clipspace.images) {
|
||||||
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
|
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import {
|
import {
|
||||||
KeyComboImpl,
|
KeyComboImpl,
|
||||||
KeybindingImpl,
|
KeybindingImpl,
|
||||||
@@ -11,6 +12,7 @@ export const useKeybindingService = () => {
|
|||||||
const keybindingStore = useKeybindingStore()
|
const keybindingStore = useKeybindingStore()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
const dialogStore = useDialogStore()
|
||||||
|
|
||||||
const keybindHandler = async function (event: KeyboardEvent) {
|
const keybindHandler = async function (event: KeyboardEvent) {
|
||||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||||
@@ -32,6 +34,19 @@ export const useKeybindingService = () => {
|
|||||||
|
|
||||||
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
||||||
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
|
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
|
// Prevent default browser behavior first, then execute the command
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
await commandStore.execute(keybinding.commandId)
|
await commandStore.execute(keybinding.commandId)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||||
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
||||||
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
|
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
|
||||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||||
@@ -63,6 +64,7 @@ export const useLitegraphService = () => {
|
|||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const widgetStore = useWidgetStore()
|
const widgetStore = useWidgetStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||||
|
|
||||||
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
||||||
function registerSubgraphNodeDef(
|
function registerSubgraphNodeDef(
|
||||||
@@ -363,6 +365,7 @@ export const useLitegraphService = () => {
|
|||||||
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
||||||
// because `registerNodeType` will overwrite the assignments.
|
// because `registerNodeType` will overwrite the assignments.
|
||||||
node.category = nodeDef.category
|
node.category = nodeDef.category
|
||||||
|
node.skip_list = true
|
||||||
node.title = nodeDef.display_name || nodeDef.name
|
node.title = nodeDef.display_name || nodeDef.name
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,15 +764,8 @@ export const useLitegraphService = () => {
|
|||||||
options.push({
|
options.push({
|
||||||
content: 'Bypass',
|
content: 'Bypass',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
const mode =
|
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||||
this.mode === LGraphEventMode.BYPASS
|
app.canvas.setDirty(true, true)
|
||||||
? 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()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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 []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { toRaw } from 'vue'
|
import { toRaw } from 'vue'
|
||||||
|
|
||||||
|
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { SerialisableGraph, Vector2 } 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 workflowStore = useWorkflowStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
|
const workflowThumbnail = useWorkflowThumbnail()
|
||||||
const domWidgetStore = useDomWidgetStore()
|
const domWidgetStore = useDomWidgetStore()
|
||||||
|
|
||||||
async function getFilename(defaultName: string): Promise<string | null> {
|
async function getFilename(defaultName: string): Promise<string | null> {
|
||||||
@@ -287,8 +289,14 @@ export const useWorkflowService = () => {
|
|||||||
*/
|
*/
|
||||||
const beforeLoadNewGraph = () => {
|
const beforeLoadNewGraph = () => {
|
||||||
// Use workspaceStore here as it is patched in unit tests.
|
// Use workspaceStore here as it is patched in unit tests.
|
||||||
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
|
const workflowStore = useWorkspaceStore().workflow
|
||||||
domWidgetStore.clear()
|
const activeWorkflow = workflowStore.activeWorkflow
|
||||||
|
if (activeWorkflow) {
|
||||||
|
activeWorkflow.changeTracker.store()
|
||||||
|
// Capture thumbnail before loading new graph
|
||||||
|
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
||||||
|
domWidgetStore.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface ComfyCommand {
|
|||||||
versionAdded?: string
|
versionAdded?: string
|
||||||
confirmation?: string // If non-nullish, this command will prompt for confirmation
|
confirmation?: string // If non-nullish, this command will prompt for confirmation
|
||||||
source?: string
|
source?: string
|
||||||
|
category?: 'essentials' | 'view-controls' // For shortcuts panel organization
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ComfyCommandImpl implements ComfyCommand {
|
export class ComfyCommandImpl implements ComfyCommand {
|
||||||
@@ -29,6 +30,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
|||||||
versionAdded?: string
|
versionAdded?: string
|
||||||
confirmation?: string
|
confirmation?: string
|
||||||
source?: string
|
source?: string
|
||||||
|
category?: 'essentials' | 'view-controls'
|
||||||
|
|
||||||
constructor(command: ComfyCommand) {
|
constructor(command: ComfyCommand) {
|
||||||
this.id = command.id
|
this.id = command.id
|
||||||
@@ -40,6 +42,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
|||||||
this.versionAdded = command.versionAdded
|
this.versionAdded = command.versionAdded
|
||||||
this.confirmation = command.confirmation
|
this.confirmation = command.confirmation
|
||||||
this.source = command.source
|
this.source = command.source
|
||||||
|
this.category = command.category
|
||||||
}
|
}
|
||||||
|
|
||||||
get label() {
|
get label() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import {
|
import {
|
||||||
ExecutedWsMessage,
|
ExecutedWsMessage,
|
||||||
ResultItem,
|
ResultItem,
|
||||||
@@ -268,6 +268,20 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
|||||||
app.nodePreviewImages = {}
|
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 {
|
return {
|
||||||
getNodeOutputs,
|
getNodeOutputs,
|
||||||
getNodeImageUrls,
|
getNodeImageUrls,
|
||||||
@@ -279,6 +293,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
|||||||
setNodePreviewsByNodeId,
|
setNodePreviewsByNodeId,
|
||||||
revokePreviewsByExecutionId,
|
revokePreviewsByExecutionId,
|
||||||
revokeAllPreviews,
|
revokeAllPreviews,
|
||||||
|
revokeSubgraphPreviews,
|
||||||
getPreviewParam
|
getPreviewParam
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -226,6 +226,14 @@ export const useReleaseStore = defineStore('release', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip fetching if API nodes are disabled via argv
|
||||||
|
if (
|
||||||
|
systemStatsStore.systemStats?.system?.argv?.includes(
|
||||||
|
'--disable-api-nodes'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import _ from 'lodash'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
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 type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||||
@@ -327,6 +328,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
(path) => path !== workflow.path
|
(path) => path !== workflow.path
|
||||||
)
|
)
|
||||||
if (workflow.isTemporary) {
|
if (workflow.isTemporary) {
|
||||||
|
// Clear thumbnail when temporary workflow is closed
|
||||||
|
clearThumbnail(workflow.key)
|
||||||
delete workflowLookup.value[workflow.path]
|
delete workflowLookup.value[workflow.path]
|
||||||
} else {
|
} else {
|
||||||
workflow.unload()
|
workflow.unload()
|
||||||
@@ -387,12 +390,14 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
|
|
||||||
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
|
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
|
||||||
const isBusy = ref<boolean>(false)
|
const isBusy = ref<boolean>(false)
|
||||||
|
const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail()
|
||||||
|
|
||||||
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
||||||
isBusy.value = true
|
isBusy.value = true
|
||||||
try {
|
try {
|
||||||
// Capture all needed values upfront
|
// Capture all needed values upfront
|
||||||
const oldPath = workflow.path
|
const oldPath = workflow.path
|
||||||
|
const oldKey = workflow.key
|
||||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||||
|
|
||||||
const openIndex = detachWorkflow(workflow)
|
const openIndex = detachWorkflow(workflow)
|
||||||
@@ -403,6 +408,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
attachWorkflow(workflow, openIndex)
|
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
|
// Update bookmarks
|
||||||
if (wasBookmarked) {
|
if (wasBookmarked) {
|
||||||
await bookmarkStore.setBookmarked(oldPath, false)
|
await bookmarkStore.setBookmarked(oldPath, false)
|
||||||
@@ -420,6 +428,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||||
await bookmarkStore.setBookmarked(workflow.path, false)
|
await bookmarkStore.setBookmarked(workflow.path, false)
|
||||||
}
|
}
|
||||||
|
// Clear thumbnail when workflow is deleted
|
||||||
|
clearThumbnail(workflow.key)
|
||||||
delete workflowLookup.value[workflow.path]
|
delete workflowLookup.value[workflow.path]
|
||||||
} finally {
|
} finally {
|
||||||
isBusy.value = false
|
isBusy.value = false
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useShortcutsTab } from '@/composables/bottomPanelTabs/useShortcutsTab'
|
||||||
import {
|
import {
|
||||||
useCommandTerminalTab,
|
useCommandTerminalTab,
|
||||||
useLogsTerminalTab
|
useLogsTerminalTab
|
||||||
@@ -10,45 +11,110 @@ import { ComfyExtension } from '@/types/comfy'
|
|||||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||||
import { isElectron } from '@/utils/envUtil'
|
import { isElectron } from '@/utils/envUtil'
|
||||||
|
|
||||||
|
type PanelType = 'terminal' | 'shortcuts'
|
||||||
|
|
||||||
|
interface PanelState {
|
||||||
|
tabs: BottomPanelExtension[]
|
||||||
|
activeTabId: string
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||||
const bottomPanelVisible = ref(false)
|
// Multi-panel state
|
||||||
const toggleBottomPanel = () => {
|
const panels = ref<Record<PanelType, PanelState>>({
|
||||||
// If there are no tabs, don't show the bottom panel
|
terminal: { tabs: [], activeTabId: '', visible: false },
|
||||||
if (bottomPanelTabs.value.length === 0) {
|
shortcuts: { tabs: [], activeTabId: '', visible: false }
|
||||||
return
|
})
|
||||||
|
|
||||||
|
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 toggleBottomPanel = () => {
|
||||||
const activeBottomPanelTabId = ref<string>('')
|
// Legacy method - toggles terminal panel
|
||||||
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
|
togglePanel('terminal')
|
||||||
return (
|
|
||||||
bottomPanelTabs.value.find(
|
|
||||||
(tab) => tab.id === activeBottomPanelTabId.value
|
|
||||||
) ?? null
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const setActiveTab = (tabId: string) => {
|
|
||||||
activeBottomPanelTabId.value = tabId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setActiveTab = (tabId: string) => {
|
||||||
|
const state = activePanelState.value
|
||||||
|
if (state) {
|
||||||
|
state.activeTabId = tabId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleBottomPanelTab = (tabId: string) => {
|
const toggleBottomPanelTab = (tabId: string) => {
|
||||||
if (activeBottomPanelTabId.value === tabId && bottomPanelVisible.value) {
|
// Find which panel contains this tab
|
||||||
bottomPanelVisible.value = false
|
for (const [panelType, panel] of Object.entries(panels.value)) {
|
||||||
} else {
|
const tab = panel.tabs.find((t) => t.id === tabId)
|
||||||
activeBottomPanelTabId.value = tabId
|
if (tab) {
|
||||||
bottomPanelVisible.value = true
|
if (activePanel.value === panelType && panel.activeTabId === tabId) {
|
||||||
|
activePanel.value = null
|
||||||
|
} else {
|
||||||
|
activePanel.value = panelType as PanelType
|
||||||
|
panel.activeTabId = tabId
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const registerBottomPanelTab = (tab: BottomPanelExtension) => {
|
const registerBottomPanelTab = (tab: BottomPanelExtension) => {
|
||||||
bottomPanelTabs.value = [...bottomPanelTabs.value, tab]
|
const targetPanel = tab.targetPanel ?? 'terminal'
|
||||||
if (bottomPanelTabs.value.length === 1) {
|
const panel = panels.value[targetPanel]
|
||||||
activeBottomPanelTabId.value = tab.id
|
|
||||||
|
panel.tabs = [...panel.tabs, tab]
|
||||||
|
if (panel.tabs.length === 1) {
|
||||||
|
panel.activeTabId = tab.id
|
||||||
}
|
}
|
||||||
|
|
||||||
useCommandStore().registerCommand({
|
useCommandStore().registerCommand({
|
||||||
id: `Workspace.ToggleBottomPanelTab.${tab.id}`,
|
id: `Workspace.ToggleBottomPanelTab.${tab.id}`,
|
||||||
icon: 'pi pi-list',
|
icon: 'pi pi-list',
|
||||||
label: `Toggle ${tab.title} Bottom Panel`,
|
label: `Toggle ${tab.title} Bottom Panel`,
|
||||||
|
category: 'view-controls' as const,
|
||||||
function: () => toggleBottomPanelTab(tab.id),
|
function: () => toggleBottomPanelTab(tab.id),
|
||||||
source: 'System'
|
source: 'System'
|
||||||
})
|
})
|
||||||
@@ -59,6 +125,7 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
|||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
registerBottomPanelTab(useCommandTerminalTab())
|
registerBottomPanelTab(useCommandTerminalTab())
|
||||||
}
|
}
|
||||||
|
useShortcutsTab().forEach(registerBottomPanelTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
|
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
|
||||||
@@ -68,6 +135,11 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Multi-panel API
|
||||||
|
panels,
|
||||||
|
activePanel,
|
||||||
|
togglePanel,
|
||||||
|
|
||||||
bottomPanelVisible,
|
bottomPanelVisible,
|
||||||
toggleBottomPanel,
|
toggleBottomPanel,
|
||||||
bottomPanelTabs,
|
bottomPanelTabs,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
|||||||
label: labelFunction,
|
label: labelFunction,
|
||||||
tooltip: tooltipFunction,
|
tooltip: tooltipFunction,
|
||||||
versionAdded: '1.3.9',
|
versionAdded: '1.3.9',
|
||||||
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: () => {
|
||||||
toggleSidebarTab(tab.id)
|
toggleSidebarTab(tab.id)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface BaseSidebarTabExtension {
|
|||||||
export interface BaseBottomPanelExtension {
|
export interface BaseBottomPanelExtension {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
targetPanel?: 'terminal' | 'shortcuts'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VueExtension {
|
export interface VueExtension {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
164
tests-ui/tests/components/bottomPanel/ShortcutsList.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -64,6 +64,16 @@ describe('useNodePricing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('dynamic pricing - KlingTextToVideoNode', () => {
|
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', () => {
|
it('should return high price for kling-v2-master model', () => {
|
||||||
const { getNodeDisplayPrice } = useNodePricing()
|
const { getNodeDisplayPrice } = useNodePricing()
|
||||||
const node = createMockNode('KlingTextToVideoNode', [
|
const node = createMockNode('KlingTextToVideoNode', [
|
||||||
@@ -104,6 +114,16 @@ describe('useNodePricing', () => {
|
|||||||
expect(price).toBe('$1.40/Run')
|
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', () => {
|
it('should return standard price for kling-v1-6 model', () => {
|
||||||
const { getNodeDisplayPrice } = useNodePricing()
|
const { getNodeDisplayPrice } = useNodePricing()
|
||||||
const node = createMockNode('KlingImage2VideoNode', [
|
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', () => {
|
describe('dynamic pricing - OpenAIDalle2', () => {
|
||||||
it('should return $0.02 for 1024x1024 size', () => {
|
it('should return $0.02 for 1024x1024 size', () => {
|
||||||
const { getNodeDisplayPrice } = useNodePricing()
|
const { getNodeDisplayPrice } = useNodePricing()
|
||||||
@@ -1384,11 +1447,19 @@ describe('useNodePricing', () => {
|
|||||||
const testCases = [
|
const testCases = [
|
||||||
{
|
{
|
||||||
model: 'gemini-2.5-pro-preview-05-06',
|
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',
|
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' }
|
{ 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-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-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-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 }) => {
|
testCases.forEach(({ model, expected }) => {
|
||||||
|
|||||||
282
tests-ui/tests/composables/useWorkflowThumbnail.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||