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',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
|
||||
@@ -75,7 +75,7 @@ The development of successive minor versions overlaps. For example, while versio
|
||||
<summary>v1.5: Native translation (i18n)</summary>
|
||||
|
||||
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
|
||||
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
|
||||
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, Korean, or Arabic. This native
|
||||
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
|
||||
|
||||
More details available here: https://blog.comfy.org/p/native-localization-support-i18n
|
||||
|
||||
@@ -767,8 +767,8 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async rightClickCanvas() {
|
||||
await this.page.mouse.click(10, 10, { button: 'right' })
|
||||
async rightClickCanvas(x: number = 10, y: number = 10) {
|
||||
await this.page.mouse.click(x, y, { button: 'right' })
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
|
||||
test('Is added to node library sidebar', async ({ comfyPage }) => {
|
||||
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
|
||||
})
|
||||
|
||||
test.skip('Can be added to canvas using node library sidebar', async ({
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.getGraphNodesCount()
|
||||
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('Manage group opens with the correct group selected', async ({
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const makeGroup = async (name, type1, type2) => {
|
||||
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
|
||||
expect(visibleInputCount).toBe(2)
|
||||
})
|
||||
|
||||
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectSingleNode = async (type: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { Locator, expect } from '@playwright/test'
|
||||
import { Position } from '@vueuse/core'
|
||||
|
||||
import {
|
||||
@@ -767,6 +767,17 @@ test.describe('Viewport settings', () => {
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const changeTab = async (tab: Locator) => {
|
||||
await tab.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyMouse.move(comfyPage.emptySpace)
|
||||
|
||||
// If tooltip is visible, wait for it to hide
|
||||
await expect(
|
||||
comfyPage.page.locator('.workflow-popover-fade')
|
||||
).toHaveCount(0)
|
||||
}
|
||||
|
||||
// Screenshot the canvas element
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
@@ -794,15 +805,13 @@ test.describe('Viewport settings', () => {
|
||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||
|
||||
// Go back to Workflow A
|
||||
await tabA.click()
|
||||
await comfyPage.nextFrame()
|
||||
await changeTab(tabA)
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotA
|
||||
)
|
||||
|
||||
// And back to Workflow B
|
||||
await tabB.click()
|
||||
await comfyPage.nextFrame()
|
||||
await changeTab(tabB)
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotB
|
||||
)
|
||||
|
||||
@@ -24,8 +24,14 @@ test.describe('Minimap', () => {
|
||||
const minimapViewport = minimapContainer.locator('.minimap-viewport')
|
||||
await expect(minimapViewport).toBeVisible()
|
||||
|
||||
await expect(minimapContainer).toHaveCSS('position', 'absolute')
|
||||
await expect(minimapContainer).toHaveCSS('z-index', '1000')
|
||||
await expect(minimapContainer).toHaveCSS('position', 'relative')
|
||||
|
||||
// position and z-index validation moved to the parent container of the minimap
|
||||
const minimapMainContainer = comfyPage.page.locator(
|
||||
'.minimap-main-container'
|
||||
)
|
||||
await expect(minimapMainContainer).toHaveCSS('position', 'absolute')
|
||||
await expect(minimapMainContainer).toHaveCSS('z-index', '1000')
|
||||
})
|
||||
|
||||
test('Validate minimap toggle button state', async ({ comfyPage }) => {
|
||||
|
||||
@@ -48,7 +48,9 @@ test.describe('LiteGraph Native Reroute Node', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
|
||||
})
|
||||
|
||||
test('Can add reroute by alt clicking on link', async ({ comfyPage }) => {
|
||||
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode = (
|
||||
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
||||
)[0]
|
||||
|
||||
|
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')
|
||||
})
|
||||
|
||||
test.skip('Can convert to group node', async ({ comfyPage }) => {
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.rightClickCanvas()
|
||||
await comfyPage.clickContextMenuItem('Convert to Group Node')
|
||||
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
|
||||
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
|
||||
|
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')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Subgraph copy and paste', () => {
|
||||
test('Can copy subgraph node by dragging + alt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes
|
||||
const subgraphNodes =
|
||||
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect a second subgraph node to be created (2 total)
|
||||
expect(subgraphNodes.length).toBe(2)
|
||||
})
|
||||
|
||||
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes and expect all unique IDs
|
||||
const subgraphNodes =
|
||||
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect the second subgraph node to have a unique type
|
||||
const nodeType1 = await subgraphNodes[0].getType()
|
||||
const nodeType2 = await subgraphNodes[1].getType()
|
||||
expect(nodeType1).not.toBe(nodeType2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Operations Inside Subgraphs', () => {
|
||||
@@ -466,4 +528,103 @@ test.describe('Subgraph Operations', () => {
|
||||
expect(finalCount).toBe(parentCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Navigation Hotkeys', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Change the Exit Subgraph keybinding from Escape to Alt+Q
|
||||
await comfyPage.setSetting('Comfy.Keybinding.NewBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'q',
|
||||
ctrl: false,
|
||||
alt: true,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.setSetting('Comfy.Keybinding.UnsetBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'Escape',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Reload the page
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForTimeout(1024)
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
|
||||
// Test that Escape no longer exits subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
if (!(await isInSubgraph(comfyPage))) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Test that Alt+Q now exits subgraph
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
if (!(await isInSubgraph(comfyPage))) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('.settings-container', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Press Escape - should close dialog, not exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('.settings-container')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
|
||||
// Press Escape again - now should exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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",
|
||||
"private": true,
|
||||
"version": "1.25.5",
|
||||
"version": "1.25.7",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -49,6 +49,13 @@ export default defineConfig({
|
||||
grep: /@2x/ // Run all tests tagged with @2x
|
||||
},
|
||||
|
||||
{
|
||||
name: 'chromium-0.5x',
|
||||
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 0.5 },
|
||||
timeout: 15000,
|
||||
grep: /@0.5x/ // Run all tests tagged with @0.5x
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
|
||||
@@ -11,18 +11,33 @@
|
||||
class="p-3 border-none"
|
||||
>
|
||||
<span class="font-bold">
|
||||
{{ tab.title.toUpperCase() }}
|
||||
{{
|
||||
shouldCapitalizeTab(tab.id)
|
||||
? tab.title.toUpperCase()
|
||||
: tab.title
|
||||
}}
|
||||
</span>
|
||||
</Tab>
|
||||
</div>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="bottomPanelStore.bottomPanelVisible = false"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="isShortcutsTabActive"
|
||||
:label="$t('shortcuts.manageShortcuts')"
|
||||
icon="pi pi-cog"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="openKeybindingSettings"
|
||||
/>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="closeBottomPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
@@ -44,9 +59,32 @@ import Button from 'primevue/button'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const isShortcutsTabActive = computed(() => {
|
||||
const activeTabId = bottomPanelStore.activeBottomPanelTabId
|
||||
return (
|
||||
activeTabId === 'shortcuts-essentials' ||
|
||||
activeTabId === 'shortcuts-view-controls'
|
||||
)
|
||||
})
|
||||
|
||||
const shouldCapitalizeTab = (tabId: string): boolean => {
|
||||
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
|
||||
}
|
||||
|
||||
const openKeybindingSettings = async () => {
|
||||
dialogService.showSettingsDialog('keybinding')
|
||||
}
|
||||
|
||||
const closeBottomPanel = () => {
|
||||
bottomPanelStore.activePanel = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
@@ -98,18 +97,6 @@ const home = computed(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(
|
||||
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
||||
watch(breadcrumbElement, (el) => {
|
||||
|
||||
@@ -1,31 +1,66 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
|
||||
:style="containerStyles"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerUp"
|
||||
@wheel="handleWheel"
|
||||
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="minimap-canvas"
|
||||
<MiniMapPanel
|
||||
v-if="showOptionsPanel"
|
||||
:panel-styles="panelStyles"
|
||||
:node-colors="nodeColors"
|
||||
:show-links="showLinks"
|
||||
:show-groups="showGroups"
|
||||
:render-bypass="renderBypass"
|
||||
:render-error="renderError"
|
||||
@update-option="updateOption"
|
||||
/>
|
||||
<div class="minimap-viewport" :style="viewportStyles" />
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap relative"
|
||||
:style="containerStyles"
|
||||
>
|
||||
<Button
|
||||
class="absolute z-10"
|
||||
size="small"
|
||||
text
|
||||
severity="secondary"
|
||||
@click.stop="toggleOptionsPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:settings-2 />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="minimap-canvas"
|
||||
/>
|
||||
|
||||
<div class="minimap-viewport" :style="viewportStyles" />
|
||||
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerUp"
|
||||
@wheel="handleWheel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useMinimap } from '@/composables/useMinimap'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
import MiniMapPanel from './MiniMapPanel.vue'
|
||||
|
||||
const minimap = useMinimap()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -38,6 +73,13 @@ const {
|
||||
viewportStyles,
|
||||
width,
|
||||
height,
|
||||
panelStyles,
|
||||
nodeColors,
|
||||
showLinks,
|
||||
showGroups,
|
||||
renderBypass,
|
||||
renderError,
|
||||
updateOption,
|
||||
init,
|
||||
destroy,
|
||||
handlePointerDown,
|
||||
@@ -46,6 +88,12 @@ const {
|
||||
handleWheel
|
||||
} = minimap
|
||||
|
||||
const showOptionsPanel = ref(false)
|
||||
|
||||
const toggleOptionsPanel = () => {
|
||||
showOptionsPanel.value = !showOptionsPanel.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => canvasStore.canvas,
|
||||
async (canvas) => {
|
||||
|
||||
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>
|
||||
<Button
|
||||
v-show="isVisible"
|
||||
v-if="isUnpackVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_UnpackSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:expand />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isConvertVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||
showDelay: 1000
|
||||
@@ -20,6 +34,7 @@ import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
@@ -27,7 +42,13 @@ const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isVisible = computed(() => {
|
||||
const isUnpackVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.selectedItems?.length === 1 &&
|
||||
canvasStore.selectedItems[0] instanceof SubgraphNode
|
||||
)
|
||||
})
|
||||
const isConvertVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.groupSelected ||
|
||||
canvasStore.rerouteSelected ||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||
<SidebarHelpCenterIcon />
|
||||
<SidebarBottomPanelToggleButton />
|
||||
<SidebarShortcutsToggleButton />
|
||||
</div>
|
||||
</nav>
|
||||
</teleport>
|
||||
@@ -32,6 +33,7 @@ import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="$t('menu.toggleBottomPanel')"
|
||||
:selected="bottomPanelStore.bottomPanelVisible"
|
||||
:selected="bottomPanelStore.activePanel == 'terminal'"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
>
|
||||
<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>
|
||||
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
|
||||
<span
|
||||
v-tooltip.bottom="{
|
||||
value: workflowOption.workflow.key,
|
||||
class: 'workflow-tab-tooltip',
|
||||
showDelay: 512
|
||||
}"
|
||||
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
|
||||
>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
class="flex p-2 gap-2 workflow-tab"
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="workflow-label text-sm max-w-[150px] truncate inline-block">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
@@ -22,23 +22,33 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WorkflowTabPopover
|
||||
ref="popoverRef"
|
||||
:workflow-filename="workflowOption.workflow.filename"
|
||||
:thumbnail-url="thumbnailUrl"
|
||||
:is-active-tab="isActiveTab"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
interface WorkflowOption {
|
||||
value: string
|
||||
workflow: ComfyWorkflow
|
||||
@@ -55,6 +65,8 @@ const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
|
||||
// Use computed refs to cache autosave settings
|
||||
const autoSaveSetting = computed(() =>
|
||||
@@ -90,6 +102,27 @@ const shouldShowStatusIndicator = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
// Event handlers that delegate to the popover component
|
||||
const handleMouseEnter = (event: Event) => {
|
||||
popoverRef.value?.showPopover(event)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
popoverRef.value?.hidePopover()
|
||||
}
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
popoverRef.value?.togglePopover(event)
|
||||
}
|
||||
|
||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
@@ -135,6 +168,10 @@ usePragmaticDroppable(tabGetter, {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
popoverRef.value?.hidePopover()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
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)'
|
||||
|
||||
const modelValue = String(modelWidget.value)
|
||||
if (modelValue.includes('v2-master')) {
|
||||
if (
|
||||
modelValue.includes('v2-1-master') ||
|
||||
modelValue.includes('v2-master')
|
||||
) {
|
||||
return '$1.40/Run'
|
||||
} else if (
|
||||
modelValue.includes('v1-6') ||
|
||||
@@ -280,12 +283,19 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
console.log('durationValue', durationValue)
|
||||
|
||||
// Same pricing matrix as KlingTextToVideoNode
|
||||
if (modelValue.includes('v2-master')) {
|
||||
if (
|
||||
modelValue.includes('v2-1-master') ||
|
||||
modelValue.includes('v2-master')
|
||||
) {
|
||||
if (durationValue.includes('10')) {
|
||||
return '$2.80/Run'
|
||||
}
|
||||
return '$1.40/Run' // 5s default
|
||||
} else if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) {
|
||||
} else if (
|
||||
modelValue.includes('v2-1') ||
|
||||
modelValue.includes('v1-6') ||
|
||||
modelValue.includes('v1-5')
|
||||
) {
|
||||
if (modeValue.includes('pro')) {
|
||||
return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run'
|
||||
} else {
|
||||
@@ -418,7 +428,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const modeValue = String(modeWidget.value)
|
||||
|
||||
// Pricing matrix from CSV data based on mode string content
|
||||
if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('v2-1-master')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$2.80/Run' // price is the same as for v2-master model
|
||||
}
|
||||
return '$1.40/Run' // price is the same as for v2-master model
|
||||
} else if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$2.80/Run'
|
||||
}
|
||||
@@ -558,6 +573,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
MinimaxTextToVideoNode: {
|
||||
displayPrice: '$0.43/Run'
|
||||
},
|
||||
MinimaxHailuoVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
|
||||
if (!resolutionWidget || !durationWidget) {
|
||||
return '$0.28-0.56/Run (varies with resolution & duration)'
|
||||
}
|
||||
|
||||
const resolution = String(resolutionWidget.value)
|
||||
const duration = String(durationWidget.value)
|
||||
|
||||
if (resolution.includes('768P')) {
|
||||
if (duration.includes('6')) return '$0.28/Run'
|
||||
if (duration.includes('10')) return '$0.56/Run'
|
||||
} else if (resolution.includes('1080P')) {
|
||||
if (duration.includes('6')) return '$0.49/Run'
|
||||
}
|
||||
|
||||
return '$0.43/Run' // default median
|
||||
}
|
||||
},
|
||||
OpenAIDalle2: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const sizeWidget = node.widgets?.find(
|
||||
@@ -1278,9 +1319,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
// Google Veo video generation
|
||||
if (model.includes('veo-2.0')) {
|
||||
return '$0.5/second'
|
||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||
return '$0.00016/$0.0006 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return '$0.0003/$0.0025 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-flash')) {
|
||||
return '$0.0003/$0.0025 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-pro')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
@@ -1317,6 +1362,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return '$0.0004/$0.0016 per 1K tokens'
|
||||
} else if (model.includes('gpt-4.1')) {
|
||||
return '$0.002/$0.008 per 1K tokens'
|
||||
} else if (model.includes('gpt-5-nano')) {
|
||||
return '$0.00005/$0.0004 per 1K tokens'
|
||||
} else if (model.includes('gpt-5-mini')) {
|
||||
return '$0.00025/$0.002 per 1K tokens'
|
||||
} else if (model.includes('gpt-5')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
@@ -1358,6 +1409,7 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
MinimaxHailuoVideoNode: ['resolution', 'duration'],
|
||||
OpenAIDalle3: ['size', 'quality'],
|
||||
OpenAIDalle2: ['size', 'n'],
|
||||
OpenAIGPTImage1: ['quality', 'n'],
|
||||
|
||||
@@ -21,8 +21,10 @@ import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
@@ -46,6 +48,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
@@ -70,6 +75,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-plus',
|
||||
label: 'New Blank Workflow',
|
||||
menubarLabel: 'New',
|
||||
category: 'essentials' as const,
|
||||
function: () => workflowService.loadBlankWorkflow()
|
||||
},
|
||||
{
|
||||
@@ -77,6 +83,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Open Workflow',
|
||||
menubarLabel: 'Open',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
app.ui.loadFile()
|
||||
}
|
||||
@@ -92,6 +99,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow',
|
||||
menubarLabel: 'Save',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
@@ -104,6 +112,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow As',
|
||||
menubarLabel: 'Save As',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
@@ -116,6 +125,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-download',
|
||||
label: 'Export Workflow',
|
||||
menubarLabel: 'Export',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await workflowService.exportWorkflow('workflow', 'workflow')
|
||||
}
|
||||
@@ -133,6 +143,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Undo',
|
||||
icon: 'pi pi-undo',
|
||||
label: 'Undo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
@@ -141,6 +152,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Redo',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Redo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
@@ -149,6 +161,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ClearWorkflow',
|
||||
icon: 'pi pi-trash',
|
||||
label: 'Clear Workflow',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const settingStore = useSettingStore()
|
||||
if (
|
||||
@@ -190,6 +203,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.RefreshNodeDefinitions',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Refresh Node Definitions',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await app.refreshComboInNodes()
|
||||
}
|
||||
@@ -198,6 +212,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Interrupt',
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Interrupt',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await api.interrupt(executionStore.activePromptId)
|
||||
toastStore.add({
|
||||
@@ -212,6 +227,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ClearPendingTasks',
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Clear Pending Tasks',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await useQueueStore().clear(['queue'])
|
||||
toastStore.add({
|
||||
@@ -234,6 +250,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ZoomIn',
|
||||
icon: 'pi pi-plus',
|
||||
label: 'Zoom In',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
@@ -247,6 +264,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ZoomOut',
|
||||
icon: 'pi pi-minus',
|
||||
label: 'Zoom Out',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
@@ -260,6 +278,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.FitView',
|
||||
icon: 'pi pi-expand',
|
||||
label: 'Fit view to selected nodes',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
@@ -325,6 +344,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-play',
|
||||
label: 'Queue Prompt',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
await app.queuePrompt(0, batchCount)
|
||||
@@ -335,6 +355,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-play',
|
||||
label: 'Queue Prompt (Front)',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
await app.queuePrompt(-1, batchCount)
|
||||
@@ -371,6 +392,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-cog',
|
||||
label: 'Show Settings Dialog',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
@@ -380,6 +402,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Group Selected Nodes',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const { canvas } = app
|
||||
if (!canvas.selectedItems?.size) {
|
||||
@@ -423,6 +446,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-volume-off',
|
||||
label: 'Mute/Unmute Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
app.canvas.setDirty(true, true)
|
||||
@@ -433,6 +457,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-shield',
|
||||
label: 'Bypass/Unbypass Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
@@ -443,6 +468,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-pin',
|
||||
label: 'Pin/Unpin Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
getSelectedNodes().forEach((node) => {
|
||||
node.pin(!node.pinned)
|
||||
@@ -516,8 +542,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Bottom Panel',
|
||||
versionAdded: '1.3.22',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useBottomPanelStore().toggleBottomPanel()
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -525,6 +552,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Toggle Focus Mode',
|
||||
versionAdded: '1.3.27',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useWorkspaceStore().toggleFocusMode()
|
||||
}
|
||||
@@ -750,6 +778,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
@@ -767,6 +796,48 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.UnpackSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Unpack the selected Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const subgraphNode = app.canvas.selectedItems.values().next().value
|
||||
useNodeOutputStore().revokeSubgraphPreviews(subgraphNode)
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleBottomPanel.Shortcuts',
|
||||
icon: 'pi pi-key',
|
||||
label: 'Show Keybindings Dialog',
|
||||
versionAdded: '1.24.1',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ExitSubgraph',
|
||||
icon: 'pi pi-arrow-up',
|
||||
label: 'Exit Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
function: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
if (!canvas.graph) return
|
||||
|
||||
canvas.setGraph(
|
||||
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useRafFn, useThrottleFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
interface GraphCallbacks {
|
||||
onNodeAdded?: (node: LGraphNode) => void
|
||||
@@ -16,9 +17,17 @@ interface GraphCallbacks {
|
||||
onConnectionChange?: (node: LGraphNode) => void
|
||||
}
|
||||
|
||||
export type MinimapOptionKey =
|
||||
| 'Comfy.Minimap.NodeColors'
|
||||
| 'Comfy.Minimap.ShowLinks'
|
||||
| 'Comfy.Minimap.ShowGroups'
|
||||
| 'Comfy.Minimap.RenderBypassState'
|
||||
| 'Comfy.Minimap.RenderErrorState'
|
||||
|
||||
export function useMinimap() {
|
||||
const settingStore = useSettingStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
@@ -27,6 +36,27 @@ export function useMinimap() {
|
||||
|
||||
const visible = ref(true)
|
||||
|
||||
const nodeColors = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.NodeColors')
|
||||
)
|
||||
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
|
||||
const showGroups = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.ShowGroups')
|
||||
)
|
||||
const renderBypass = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.RenderBypassState')
|
||||
)
|
||||
const renderError = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.RenderErrorState')
|
||||
)
|
||||
|
||||
const updateOption = async (key: MinimapOptionKey, value: boolean) => {
|
||||
await settingStore.set(key, value)
|
||||
|
||||
needsFullRedraw.value = true
|
||||
updateMinimap()
|
||||
}
|
||||
|
||||
const initialized = ref(false)
|
||||
const bounds = ref({
|
||||
minX: 0,
|
||||
@@ -63,10 +93,22 @@ export function useMinimap() {
|
||||
const nodeColor = computed(
|
||||
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
|
||||
)
|
||||
const nodeColorDefault = computed(
|
||||
() => (isLightTheme.value ? '#D9D9D9' : '#353535') // this is the default node color when using nodeColors setting
|
||||
)
|
||||
const linkColor = computed(
|
||||
() => (isLightTheme.value ? '#FFB347' : '#F99614') // lighter orange for light theme
|
||||
() => (isLightTheme.value ? '#616161' : '#B3B3B3') // lighter orange for light theme
|
||||
)
|
||||
const slotColor = computed(() => linkColor.value)
|
||||
const groupColor = computed(() =>
|
||||
isLightTheme.value ? '#A2D3EC' : '#1F547A'
|
||||
)
|
||||
const groupColorDefault = computed(
|
||||
() => (isLightTheme.value ? '#283640' : '#B3C1CB') // this is the default group color when using nodeColors setting
|
||||
)
|
||||
const bypassColor = computed(() =>
|
||||
isLightTheme.value ? '#DBDBDB' : '#4B184B'
|
||||
)
|
||||
|
||||
const containerRect = ref({
|
||||
left: 0,
|
||||
@@ -106,7 +148,11 @@ export function useMinimap() {
|
||||
}
|
||||
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
const graph = ref(app.canvas?.graph)
|
||||
const graph = computed(() => {
|
||||
// If we're in a subgraph, use that; otherwise use the canvas graph
|
||||
const activeSubgraph = workflowStore.activeSubgraph
|
||||
return activeSubgraph || canvas.value?.graph
|
||||
})
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${width}px`,
|
||||
@@ -116,6 +162,14 @@ export function useMinimap() {
|
||||
borderRadius: '8px'
|
||||
}))
|
||||
|
||||
const panelStyles = computed(() => ({
|
||||
width: `210px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
|
||||
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
|
||||
borderRadius: '8px'
|
||||
}))
|
||||
|
||||
const viewportStyles = computed(() => ({
|
||||
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
|
||||
width: `${viewportTransform.value.width}px`,
|
||||
@@ -189,6 +243,35 @@ export function useMinimap() {
|
||||
return Math.min(scaleX, scaleY) * 0.9
|
||||
}
|
||||
|
||||
const renderGroups = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
) => {
|
||||
const g = graph.value
|
||||
if (!g || !g._groups || g._groups.length === 0) return
|
||||
|
||||
for (const group of g._groups) {
|
||||
const x = (group.pos[0] - bounds.value.minX) * scale.value + offsetX
|
||||
const y = (group.pos[1] - bounds.value.minY) * scale.value + offsetY
|
||||
const w = group.size[0] * scale.value
|
||||
const h = group.size[1] * scale.value
|
||||
|
||||
let color = groupColor.value
|
||||
|
||||
if (nodeColors.value) {
|
||||
color = group.color ?? groupColorDefault.value
|
||||
|
||||
if (isLightTheme.value) {
|
||||
color = adjustColor(color, { opacity: 0.5 })
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
const renderNodes = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
@@ -203,9 +286,29 @@ export function useMinimap() {
|
||||
const w = node.size[0] * scale.value
|
||||
const h = node.size[1] * scale.value
|
||||
|
||||
let color = nodeColor.value
|
||||
|
||||
if (renderBypass.value && node.mode === LGraphEventMode.BYPASS) {
|
||||
color = bypassColor.value
|
||||
} else if (nodeColors.value) {
|
||||
color = nodeColorDefault.value
|
||||
|
||||
if (node.bgcolor) {
|
||||
color = isLightTheme.value
|
||||
? adjustColor(node.bgcolor, { lightness: 0.5 })
|
||||
: node.bgcolor
|
||||
}
|
||||
}
|
||||
|
||||
// Render solid node blocks
|
||||
ctx.fillStyle = nodeColor.value
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y, w, h)
|
||||
|
||||
if (renderError.value && node.has_errors) {
|
||||
ctx.strokeStyle = '#FF0000'
|
||||
ctx.lineWidth = 0.3
|
||||
ctx.strokeRect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,9 +321,9 @@ export function useMinimap() {
|
||||
if (!g) return
|
||||
|
||||
ctx.strokeStyle = linkColor.value
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.lineWidth = 0.3
|
||||
|
||||
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
|
||||
const slotRadius = Math.max(scale.value, 0.5) // Larger slots that scale
|
||||
const connections: Array<{
|
||||
x1: number
|
||||
y1: number
|
||||
@@ -304,8 +407,15 @@ export function useMinimap() {
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
if (showGroups.value) {
|
||||
renderGroups(ctx, offsetX, offsetY)
|
||||
}
|
||||
|
||||
if (showLinks.value) {
|
||||
renderConnections(ctx, offsetX, offsetY)
|
||||
}
|
||||
|
||||
renderNodes(ctx, offsetX, offsetY)
|
||||
renderConnections(ctx, offsetX, offsetY)
|
||||
|
||||
needsFullRedraw.value = false
|
||||
updateFlags.value.nodes = false
|
||||
@@ -522,7 +632,8 @@ export function useMinimap() {
|
||||
c.setDirty(true, true)
|
||||
}
|
||||
|
||||
let originalCallbacks: GraphCallbacks = {}
|
||||
// Map to store original callbacks per graph ID
|
||||
const originalCallbacksMap = new Map<string, GraphCallbacks>()
|
||||
|
||||
const handleGraphChanged = useThrottleFn(() => {
|
||||
needsFullRedraw.value = true
|
||||
@@ -536,11 +647,18 @@ export function useMinimap() {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
originalCallbacks = {
|
||||
// Check if we've already wrapped this graph's callbacks
|
||||
if (originalCallbacksMap.has(g.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store the original callbacks for this graph
|
||||
const originalCallbacks: GraphCallbacks = {
|
||||
onNodeAdded: g.onNodeAdded,
|
||||
onNodeRemoved: g.onNodeRemoved,
|
||||
onConnectionChange: g.onConnectionChange
|
||||
}
|
||||
originalCallbacksMap.set(g.id, originalCallbacks)
|
||||
|
||||
g.onNodeAdded = function (node) {
|
||||
originalCallbacks.onNodeAdded?.call(this, node)
|
||||
@@ -565,15 +683,18 @@ export function useMinimap() {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
if (originalCallbacks.onNodeAdded !== undefined) {
|
||||
g.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
}
|
||||
if (originalCallbacks.onNodeRemoved !== undefined) {
|
||||
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
}
|
||||
if (originalCallbacks.onConnectionChange !== undefined) {
|
||||
g.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
const originalCallbacks = originalCallbacksMap.get(g.id)
|
||||
if (!originalCallbacks) {
|
||||
throw new Error(
|
||||
'Attempted to cleanup event listeners for graph that was never set up'
|
||||
)
|
||||
}
|
||||
|
||||
g.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
g.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
|
||||
originalCallbacksMap.delete(g.id)
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
@@ -646,6 +767,19 @@ export function useMinimap() {
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
|
||||
// Watch for graph changes (e.g., when navigating to/from subgraphs)
|
||||
watch(graph, (newGraph, oldGraph) => {
|
||||
if (newGraph && newGraph !== oldGraph) {
|
||||
cleanupEventListeners()
|
||||
setupEventListeners()
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateMinimap()
|
||||
}
|
||||
})
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (isVisible) {
|
||||
if (containerRef.value) {
|
||||
@@ -690,16 +824,25 @@ export function useMinimap() {
|
||||
canvasRef,
|
||||
containerStyles,
|
||||
viewportStyles,
|
||||
panelStyles,
|
||||
width,
|
||||
height,
|
||||
|
||||
nodeColors,
|
||||
showLinks,
|
||||
showGroups,
|
||||
renderBypass,
|
||||
renderError,
|
||||
|
||||
init,
|
||||
destroy,
|
||||
toggle,
|
||||
renderMinimap,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleWheel,
|
||||
setMinimapRef
|
||||
setMinimapRef,
|
||||
updateOption
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
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: 'ko', text: '한국어' },
|
||||
{ value: 'fr', text: 'Français' },
|
||||
{ value: 'es', text: 'Español' }
|
||||
{ value: 'es', text: 'Español' },
|
||||
{ value: 'ar', text: 'عربي' }
|
||||
],
|
||||
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
||||
},
|
||||
@@ -830,6 +831,41 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
versionAdded: '1.25.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Minimap.NodeColors',
|
||||
name: 'Display node with its original color on minimap',
|
||||
type: 'hidden',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.26.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Minimap.ShowLinks',
|
||||
name: 'Display links on minimap',
|
||||
type: 'hidden',
|
||||
defaultValue: true,
|
||||
versionAdded: '1.26.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Minimap.ShowGroups',
|
||||
name: 'Display node groups on minimap',
|
||||
type: 'hidden',
|
||||
defaultValue: true,
|
||||
versionAdded: '1.26.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Minimap.RenderBypassState',
|
||||
name: 'Render bypass state on minimap',
|
||||
type: 'hidden',
|
||||
defaultValue: true,
|
||||
versionAdded: '1.26.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Minimap.RenderErrorState',
|
||||
name: 'Render error state on minimap',
|
||||
type: 'hidden',
|
||||
defaultValue: true,
|
||||
versionAdded: '1.26.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Workflow.AutoSaveDelay',
|
||||
name: 'Auto Save Delay (ms)',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
type ExecutableLGraphNode,
|
||||
type ExecutionId,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
@@ -1172,8 +1173,7 @@ export class GroupNodeHandler {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
getExtraMenuOptions?.apply(this, arguments)
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
let optionIndex = options.findIndex((o) => o.content === 'Outputs')
|
||||
let optionIndex = options.findIndex((o) => o?.content === 'Outputs')
|
||||
if (optionIndex === -1) optionIndex = options.length
|
||||
else optionIndex++
|
||||
options.splice(
|
||||
@@ -1634,6 +1634,57 @@ export class GroupNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
function addConvertToGroupOptions() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addConvertOption(options, index) {
|
||||
const selected = Object.values(app.canvas.selected_nodes ?? {})
|
||||
const disabled =
|
||||
selected.length < 2 ||
|
||||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
|
||||
options.splice(index, null, {
|
||||
content: `Convert to Group Node (Deprecated)`,
|
||||
disabled,
|
||||
callback: convertSelectedNodesToGroupNode
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addManageOption(options, index) {
|
||||
const groups = app.graph.extra?.groupNodes
|
||||
const disabled = !groups || !Object.keys(groups).length
|
||||
options.splice(index, null, {
|
||||
content: `Manage Group Nodes`,
|
||||
disabled,
|
||||
callback: () => manageGroupNodes()
|
||||
})
|
||||
}
|
||||
|
||||
// Add to canvas
|
||||
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getCanvasMenuOptions.apply(this, arguments)
|
||||
const index = options.findIndex((o) => o?.content === 'Add Group')
|
||||
const insertAt = index === -1 ? options.length - 1 : index + 2
|
||||
addConvertOption(options, insertAt)
|
||||
addManageOption(options, insertAt + 1)
|
||||
return options
|
||||
}
|
||||
|
||||
// Add to nodes
|
||||
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getNodeMenuOptions.apply(this, arguments)
|
||||
if (!GroupNodeHandler.isGroupNode(node)) {
|
||||
const index = options.findIndex((o) => o?.content === 'Properties')
|
||||
const insertAt = index === -1 ? options.length - 1 : index
|
||||
addConvertOption(options, insertAt)
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
|
||||
@@ -1729,6 +1780,9 @@ const ext: ComfyExtension = {
|
||||
}
|
||||
}
|
||||
],
|
||||
setup() {
|
||||
addConvertToGroupOptions()
|
||||
},
|
||||
async beforeConfigureGraph(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
missingNodeTypes: string[]
|
||||
|
||||
@@ -3976,13 +3976,19 @@ class UIManager {
|
||||
const mainImageFilename =
|
||||
new URL(mainImageUrl).searchParams.get('filename') ?? undefined
|
||||
|
||||
const combinedImageFilename =
|
||||
let combinedImageFilename: string | null | undefined
|
||||
if (
|
||||
ComfyApp.clipspace?.combinedIndex !== undefined &&
|
||||
ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace.combinedIndex]?.src
|
||||
? new URL(
|
||||
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
|
||||
).searchParams.get('filename')
|
||||
: undefined
|
||||
ComfyApp.clipspace?.imgs &&
|
||||
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length &&
|
||||
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src
|
||||
) {
|
||||
combinedImageFilename = new URL(
|
||||
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
|
||||
).searchParams.get('filename')
|
||||
} else {
|
||||
combinedImageFilename = undefined
|
||||
}
|
||||
|
||||
const imageLayerFilenames =
|
||||
mainImageFilename !== undefined
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import arCommands from './locales/ar/commands.json'
|
||||
import ar from './locales/ar/main.json'
|
||||
import arNodes from './locales/ar/nodeDefs.json'
|
||||
import arSettings from './locales/ar/settings.json'
|
||||
import enCommands from './locales/en/commands.json'
|
||||
import en from './locales/en/main.json'
|
||||
import enNodes from './locales/en/nodeDefs.json'
|
||||
@@ -50,7 +54,8 @@ const messages = {
|
||||
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
|
||||
ko: buildLocale(ko, koNodes, koCommands, koSettings),
|
||||
fr: buildLocale(fr, frNodes, frCommands, frSettings),
|
||||
es: buildLocale(es, esNodes, esCommands, esSettings)
|
||||
es: buildLocale(es, esNodes, esCommands, esSettings),
|
||||
ar: buildLocale(ar, arNodes, arCommands, arSettings)
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
|
||||
@@ -495,6 +495,16 @@
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.graphmenu-entry.danger,
|
||||
.litemenu-entry.danger {
|
||||
color: var(--error-text) !important;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry.danger:hover:not(.disabled) {
|
||||
color: var(--error-text) !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.graphmenu-entry.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,19 @@ export class CanvasPointer {
|
||||
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
|
||||
static #maxClickDrift2 = this.#maxClickDrift ** 2
|
||||
|
||||
/** Assume that "wheel" events with both deltaX and deltaY less than this value are trackpad gestures. */
|
||||
static trackpadThreshold = 60
|
||||
|
||||
/**
|
||||
* The minimum time between "wheel" events to allow switching between trackpad
|
||||
* and mouse modes.
|
||||
*
|
||||
* This prevents trackpad "flick" panning from registering as regular mouse wheel.
|
||||
* After a flick gesture is complete, the automatic wheel events are sent with
|
||||
* reduced frequency, but much higher deltaX and deltaY values.
|
||||
*/
|
||||
static trackpadMaxGap = 200
|
||||
|
||||
/** The element this PointerState should capture input against when dragging. */
|
||||
element: Element
|
||||
/** Pointer ID used by drag capture. */
|
||||
@@ -77,6 +90,9 @@ export class CanvasPointer {
|
||||
/** The last pointerup event for the primary button */
|
||||
eUp?: CanvasPointerEvent
|
||||
|
||||
/** The last pointermove event that was treated as a trackpad gesture. */
|
||||
lastTrackpadEvent?: WheelEvent
|
||||
|
||||
/**
|
||||
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
|
||||
* @param pointer [DEPRECATED] This parameter will be removed in a future release.
|
||||
@@ -257,6 +273,35 @@ export class CanvasPointer {
|
||||
delete this.onDragStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given wheel event is part of a continued trackpad gesture.
|
||||
* @param e The wheel event to check
|
||||
* @returns `true` if the event is part of a continued trackpad gesture, otherwise `false`
|
||||
*/
|
||||
#isContinuationOfGesture(e: WheelEvent): boolean {
|
||||
const { lastTrackpadEvent } = this
|
||||
if (!lastTrackpadEvent) return false
|
||||
|
||||
return (
|
||||
e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given wheel event is part of a trackpad gesture.
|
||||
* @param e The wheel event to check
|
||||
* @returns `true` if the event is part of a trackpad gesture, otherwise `false`
|
||||
*/
|
||||
isTrackpadGesture(e: WheelEvent): boolean {
|
||||
if (this.#isContinuationOfGesture(e)) {
|
||||
this.lastTrackpadEvent = e
|
||||
return true
|
||||
}
|
||||
|
||||
const threshold = CanvasPointer.trackpadThreshold
|
||||
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the state of this {@link CanvasPointer} instance.
|
||||
*
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
Dictionary,
|
||||
HasBoundingRect,
|
||||
IContextMenuValue,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
@@ -26,7 +27,8 @@ import type {
|
||||
MethodNames,
|
||||
OptionalProps,
|
||||
Point,
|
||||
Positionable
|
||||
Positionable,
|
||||
Size
|
||||
} from './interfaces'
|
||||
import { LiteGraph, SubgraphNode } from './litegraph'
|
||||
import {
|
||||
@@ -1568,6 +1570,9 @@ export class LGraph
|
||||
boundingRect
|
||||
)
|
||||
|
||||
//Correct for title height. It's included in bounding box, but not _posSize
|
||||
subgraphNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
|
||||
// Add the subgraph node to the graph
|
||||
this.add(subgraphNode)
|
||||
|
||||
@@ -1663,6 +1668,271 @@ export class LGraph
|
||||
return { subgraph, node: subgraphNode as SubgraphNode }
|
||||
}
|
||||
|
||||
unpackSubgraph(subgraphNode: SubgraphNode) {
|
||||
if (!(subgraphNode instanceof SubgraphNode))
|
||||
throw new Error('Can only unpack Subgraph Nodes')
|
||||
this.beforeChange()
|
||||
//NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized.
|
||||
//NOTE: NODE_TITLE_HEIGHT is explicitly excluded here
|
||||
const positionables = [
|
||||
...subgraphNode.subgraph.nodes,
|
||||
...subgraphNode.subgraph.reroutes.values(),
|
||||
...subgraphNode.subgraph.groups
|
||||
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
|
||||
return {
|
||||
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
|
||||
}
|
||||
})
|
||||
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]
|
||||
const center = [bounds[0] + bounds[2] / 2, bounds[1] + bounds[3] / 2]
|
||||
|
||||
const toSelect: Positionable[] = []
|
||||
const offsetX = subgraphNode.pos[0] - center[0] + subgraphNode.size[0] / 2
|
||||
const offsetY = subgraphNode.pos[1] - center[1] + subgraphNode.size[1] / 2
|
||||
const movedNodes = multiClone(subgraphNode.subgraph.nodes)
|
||||
const nodeIdMap = new Map<NodeId, NodeId>()
|
||||
for (const n_info of movedNodes) {
|
||||
const node = LiteGraph.createNode(String(n_info.type), n_info.title)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
nodeIdMap.set(n_info.id, ++this.last_node_id)
|
||||
node.id = this.last_node_id
|
||||
n_info.id = this.last_node_id
|
||||
|
||||
this.add(node, true)
|
||||
node.configure(n_info)
|
||||
node.pos[0] += offsetX
|
||||
node.pos[1] += offsetY
|
||||
for (const input of node.inputs) {
|
||||
input.link = null
|
||||
}
|
||||
for (const output of node.outputs) {
|
||||
output.links = []
|
||||
}
|
||||
toSelect.push(node)
|
||||
}
|
||||
const groups = structuredClone(
|
||||
[...subgraphNode.subgraph.groups].map((g) => g.serialize())
|
||||
)
|
||||
for (const g_info of groups) {
|
||||
const group = new LGraphGroup(g_info.title, g_info.id)
|
||||
this.add(group, true)
|
||||
group.configure(g_info)
|
||||
group.pos[0] += offsetX
|
||||
group.pos[1] += offsetY
|
||||
toSelect.push(group)
|
||||
}
|
||||
//cleanup reoute.linkIds now, but leave link.parentIds dangling
|
||||
for (const islot of subgraphNode.inputs) {
|
||||
if (!islot.link) continue
|
||||
const link = this.links.get(islot.link)
|
||||
if (!link) {
|
||||
console.warn('Broken link', islot, islot.link)
|
||||
continue
|
||||
}
|
||||
for (const reroute of LLink.getReroutes(this, link)) {
|
||||
reroute.linkIds.delete(link.id)
|
||||
}
|
||||
}
|
||||
for (const oslot of subgraphNode.outputs) {
|
||||
for (const linkId of oslot.links ?? []) {
|
||||
const link = this.links.get(linkId)
|
||||
if (!link) {
|
||||
console.warn('Broken link', oslot, linkId)
|
||||
continue
|
||||
}
|
||||
for (const reroute of LLink.getReroutes(this, link)) {
|
||||
reroute.linkIds.delete(link.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const newLinks: {
|
||||
oid: NodeId
|
||||
oslot: number
|
||||
tid: NodeId
|
||||
tslot: number
|
||||
id: LinkId
|
||||
iparent?: RerouteId
|
||||
eparent?: RerouteId
|
||||
externalFirst: boolean
|
||||
}[] = []
|
||||
for (const [, link] of subgraphNode.subgraph._links) {
|
||||
let externalParentId: RerouteId | undefined
|
||||
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
||||
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
|
||||
if (!outerLinkId) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
}
|
||||
const outerLink = this.links[outerLinkId]
|
||||
link.origin_id = outerLink.origin_id
|
||||
link.origin_slot = outerLink.origin_slot
|
||||
externalParentId = outerLink.parentId
|
||||
} else {
|
||||
const origin_id = nodeIdMap.get(link.origin_id)
|
||||
if (!origin_id) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
}
|
||||
link.origin_id = origin_id
|
||||
}
|
||||
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
|
||||
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
|
||||
[]) {
|
||||
const sublink = this.links[linkId]
|
||||
newLinks.push({
|
||||
oid: link.origin_id,
|
||||
oslot: link.origin_slot,
|
||||
tid: sublink.target_id,
|
||||
tslot: sublink.target_slot,
|
||||
id: link.id,
|
||||
iparent: link.parentId,
|
||||
eparent: sublink.parentId,
|
||||
externalFirst: true
|
||||
})
|
||||
sublink.parentId = undefined
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
const target_id = nodeIdMap.get(link.target_id)
|
||||
if (!target_id) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
}
|
||||
link.target_id = target_id
|
||||
}
|
||||
newLinks.push({
|
||||
oid: link.origin_id,
|
||||
oslot: link.origin_slot,
|
||||
tid: link.target_id,
|
||||
tslot: link.target_slot,
|
||||
id: link.id,
|
||||
iparent: link.parentId,
|
||||
eparent: externalParentId,
|
||||
externalFirst: false
|
||||
})
|
||||
}
|
||||
this.remove(subgraphNode)
|
||||
this.subgraphs.delete(subgraphNode.subgraph.id)
|
||||
const linkIdMap = new Map<LinkId, LinkId[]>()
|
||||
for (const newLink of newLinks) {
|
||||
let created: LLink | null | undefined
|
||||
if (newLink.oid == SUBGRAPH_INPUT_ID) {
|
||||
if (!(this instanceof Subgraph)) {
|
||||
console.error('Ignoring link to subgraph outside subgraph')
|
||||
continue
|
||||
}
|
||||
const tnode = this._nodes_by_id[newLink.tid]
|
||||
created = this.inputNode.slots[newLink.oslot].connect(
|
||||
tnode.inputs[newLink.tslot],
|
||||
tnode
|
||||
)
|
||||
} else if (newLink.tid == SUBGRAPH_OUTPUT_ID) {
|
||||
if (!(this instanceof Subgraph)) {
|
||||
console.error('Ignoring link to subgraph outside subgraph')
|
||||
continue
|
||||
}
|
||||
const tnode = this._nodes_by_id[newLink.oid]
|
||||
created = this.outputNode.slots[newLink.tslot].connect(
|
||||
tnode.outputs[newLink.oslot],
|
||||
tnode
|
||||
)
|
||||
} else {
|
||||
created = this._nodes_by_id[newLink.oid].connect(
|
||||
newLink.oslot,
|
||||
this._nodes_by_id[newLink.tid],
|
||||
newLink.tslot
|
||||
)
|
||||
}
|
||||
if (!created) {
|
||||
console.error('Failed to create link')
|
||||
continue
|
||||
}
|
||||
//This is a little unwieldy since Map.has isn't a type guard
|
||||
const linkIds = linkIdMap.get(newLink.id) ?? []
|
||||
linkIds.push(created.id)
|
||||
if (!linkIdMap.has(newLink.id)) {
|
||||
linkIdMap.set(newLink.id, linkIds)
|
||||
}
|
||||
newLink.id = created.id
|
||||
}
|
||||
const rerouteIdMap = new Map<RerouteId, RerouteId>()
|
||||
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
|
||||
if (
|
||||
reroute.parentId !== undefined &&
|
||||
rerouteIdMap.get(reroute.parentId) === undefined
|
||||
) {
|
||||
console.error('Missing Parent ID')
|
||||
}
|
||||
const migratedReroute = new Reroute(++this.state.lastRerouteId, this, [
|
||||
reroute.pos[0] + offsetX,
|
||||
reroute.pos[1] + offsetY
|
||||
])
|
||||
rerouteIdMap.set(reroute.id, migratedReroute.id)
|
||||
this.reroutes.set(migratedReroute.id, migratedReroute)
|
||||
toSelect.push(migratedReroute)
|
||||
}
|
||||
//iterate over newly created links to update reroute parentIds
|
||||
for (const newLink of newLinks) {
|
||||
const linkInstance = this.links.get(newLink.id)
|
||||
if (!linkInstance) {
|
||||
continue
|
||||
}
|
||||
let instance: Reroute | LLink | undefined = linkInstance
|
||||
let parentId: RerouteId | undefined = undefined
|
||||
if (newLink.externalFirst) {
|
||||
parentId = newLink.eparent
|
||||
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
|
||||
while (parentId) {
|
||||
instance.parentId = parentId
|
||||
instance = this.reroutes.get(parentId)
|
||||
if (!instance) throw new Error('Broken Id link when unpacking')
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
parentId = instance.parentId
|
||||
}
|
||||
}
|
||||
parentId = newLink.iparent
|
||||
while (parentId) {
|
||||
const migratedId = rerouteIdMap.get(parentId)
|
||||
if (!migratedId) throw new Error('Broken Id link when unpacking')
|
||||
instance.parentId = migratedId
|
||||
instance = this.reroutes.get(migratedId)
|
||||
if (!instance) throw new Error('Broken Id link when unpacking')
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
|
||||
if (!oldReroute) throw new Error('Broken Id link when unpacking')
|
||||
parentId = oldReroute.parentId
|
||||
}
|
||||
if (!newLink.externalFirst) {
|
||||
parentId = newLink.eparent
|
||||
while (parentId) {
|
||||
instance.parentId = parentId
|
||||
instance = this.reroutes.get(parentId)
|
||||
if (!instance) throw new Error('Broken Id link when unpacking')
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
parentId = instance.parentId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of nodeIdMap.values()) {
|
||||
const node = this._nodes_by_id[nodeId]
|
||||
node._setConcreteSlots()
|
||||
node.arrange()
|
||||
}
|
||||
|
||||
this.canvasAction((c) => c.selectItems(toSelect))
|
||||
this.afterChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path of subgraph node IDs into a list of subgraph nodes.
|
||||
* Not intended to be run from subgraphs.
|
||||
@@ -2331,6 +2601,9 @@ export class Subgraph
|
||||
nodes: this.nodes.map((node) => node.serialize()),
|
||||
groups: this.groups.map((group) => group.serialize()),
|
||||
links: [...this.links.values()].map((x) => x.asSerialisable()),
|
||||
reroutes: this.reroutes.size
|
||||
? [...this.reroutes.values()].map((x) => x.asSerialisable())
|
||||
: undefined,
|
||||
extra: this.extra
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2299,6 +2299,8 @@ export class LGraphCanvas
|
||||
|
||||
const node_data = node.clone()?.serialize()
|
||||
if (node_data?.type != null) {
|
||||
// Ensure the cloned node is configured against the correct type (especially for SubgraphNodes)
|
||||
node_data.type = newType
|
||||
const cloned = LiteGraph.createNode(newType)
|
||||
if (cloned) {
|
||||
cloned.configure(node_data)
|
||||
@@ -2384,7 +2386,7 @@ export class LGraphCanvas
|
||||
// Set the width of the line for isPointInStroke checks
|
||||
const { lineWidth } = this.ctx
|
||||
this.ctx.lineWidth = this.connections_width + 7
|
||||
const dpi = window?.devicePixelRatio || 1
|
||||
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
|
||||
|
||||
for (const linkSegment of this.renderedPaths) {
|
||||
const centre = linkSegment._pos
|
||||
@@ -3453,10 +3455,6 @@ export class LGraphCanvas
|
||||
processMouseWheel(e: WheelEvent): void {
|
||||
if (!this.graph || !this.allow_dragcanvas) return
|
||||
|
||||
// TODO: Mouse wheel zoom rewrite
|
||||
// @ts-expect-error wheelDeltaY is non-standard property on WheelEvent
|
||||
const delta = e.wheelDeltaY ?? e.detail * -60
|
||||
|
||||
this.adjustMouseEvent(e)
|
||||
|
||||
const pos: Point = [e.clientX, e.clientY]
|
||||
@@ -3464,35 +3462,34 @@ export class LGraphCanvas
|
||||
|
||||
let { scale } = this.ds
|
||||
|
||||
if (
|
||||
LiteGraph.canvasNavigationMode === 'legacy' ||
|
||||
(LiteGraph.canvasNavigationMode === 'standard' && e.ctrlKey)
|
||||
) {
|
||||
if (delta > 0) {
|
||||
scale *= this.zoom_speed
|
||||
} else if (delta < 0) {
|
||||
scale *= 1 / this.zoom_speed
|
||||
}
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY])
|
||||
} else if (
|
||||
LiteGraph.macTrackpadGestures &&
|
||||
(!LiteGraph.macGesturesRequireMac || navigator.userAgent.includes('Mac'))
|
||||
) {
|
||||
if (e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
|
||||
if (e.deltaY > 0) {
|
||||
scale *= 1 / this.zoom_speed
|
||||
} else if (e.deltaY < 0) {
|
||||
scale *= this.zoom_speed
|
||||
}
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY])
|
||||
} else if (e.ctrlKey) {
|
||||
// Detect if this is a trackpad gesture or mouse wheel
|
||||
const isTrackpad = this.pointer.isTrackpadGesture(e)
|
||||
|
||||
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
|
||||
// Legacy mode or standard mode with ctrl - use wheel for zoom
|
||||
if (isTrackpad) {
|
||||
// Trackpad gesture - use smooth scaling
|
||||
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
|
||||
} else if (e.shiftKey) {
|
||||
this.ds.offset[0] -= e.deltaY * 1.18 * (1 / scale)
|
||||
} else {
|
||||
this.ds.offset[0] -= e.deltaX * 1.18 * (1 / scale)
|
||||
this.ds.offset[1] -= e.deltaY * 1.18 * (1 / scale)
|
||||
// Mouse wheel - use stepped scaling
|
||||
if (e.deltaY < 0) {
|
||||
scale *= this.zoom_speed
|
||||
} else if (e.deltaY > 0) {
|
||||
scale *= 1 / this.zoom_speed
|
||||
}
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY])
|
||||
}
|
||||
} else {
|
||||
// Standard mode without ctrl - use wheel / gestures to pan
|
||||
// Trackpads and mice work on significantly different scales
|
||||
const factor = isTrackpad ? 0.18 : 0.008_333
|
||||
|
||||
if (!isTrackpad && e.shiftKey && e.deltaX === 0) {
|
||||
this.ds.offset[0] -= e.deltaY * (1 + factor) * (1 / scale)
|
||||
} else {
|
||||
this.ds.offset[0] -= e.deltaX * (1 + factor) * (1 / scale)
|
||||
this.ds.offset[1] -= e.deltaY * (1 + factor) * (1 / scale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3610,6 +3607,7 @@ export class LGraphCanvas
|
||||
subgraphs: []
|
||||
}
|
||||
|
||||
// NOTE: logic for traversing nested subgraphs depends on this being a set.
|
||||
const subgraphs = new Set<Subgraph>()
|
||||
|
||||
// Create serialisable objects
|
||||
@@ -3648,8 +3646,13 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
// Add unique subgraph entries
|
||||
// TODO: Must find all nested subgraphs
|
||||
// NOTE: subgraphs is appended to mid iteration.
|
||||
for (const subgraph of subgraphs) {
|
||||
for (const node of subgraph.nodes) {
|
||||
if (node instanceof SubgraphNode) {
|
||||
subgraphs.add(node.subgraph)
|
||||
}
|
||||
}
|
||||
const cloned = subgraph.clone(true).asSerialisable()
|
||||
serialisable.subgraphs.push(cloned)
|
||||
}
|
||||
@@ -3766,12 +3769,19 @@ export class LGraphCanvas
|
||||
created.push(group)
|
||||
}
|
||||
|
||||
// Update subgraph ids with nesting
|
||||
function updateSubgraphIds(nodes: { type: string }[]) {
|
||||
for (const info of nodes) {
|
||||
const subgraph = results.subgraphs.get(info.type)
|
||||
if (!subgraph) continue
|
||||
info.type = subgraph.id
|
||||
updateSubgraphIds(subgraph.nodes)
|
||||
}
|
||||
}
|
||||
updateSubgraphIds(parsed.nodes)
|
||||
|
||||
// Nodes
|
||||
for (const info of parsed.nodes) {
|
||||
// If the subgraph was cloned, update references to use the new subgraph ID.
|
||||
const subgraph = results.subgraphs.get(info.type)
|
||||
if (subgraph) info.type = subgraph.id
|
||||
|
||||
const node = info.type == null ? null : LiteGraph.createNode(info.type)
|
||||
if (!node) {
|
||||
// failedNodes.push(info)
|
||||
@@ -8063,18 +8073,6 @@ export class LGraphCanvas
|
||||
options = node.getMenuOptions(this)
|
||||
} else {
|
||||
options = [
|
||||
{
|
||||
content: 'Inputs',
|
||||
has_submenu: true,
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
content: 'Outputs',
|
||||
has_submenu: true,
|
||||
disabled: true,
|
||||
callback: LGraphCanvas.showMenuNodeOptionalOutputs
|
||||
},
|
||||
null,
|
||||
{
|
||||
content: 'Convert to Subgraph 🆕',
|
||||
callback: () => {
|
||||
@@ -8242,15 +8240,19 @@ export class LGraphCanvas
|
||||
'Both in put and output slots were null when processing context menu.'
|
||||
)
|
||||
|
||||
if (_slot.removable) {
|
||||
menu_info.push(
|
||||
_slot.locked ? 'Cannot remove' : { content: 'Remove Slot', slot }
|
||||
)
|
||||
}
|
||||
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
|
||||
menu_info.push({ content: 'Rename Slot', slot })
|
||||
}
|
||||
|
||||
if (_slot.removable) {
|
||||
menu_info.push(null)
|
||||
menu_info.push(
|
||||
_slot.locked
|
||||
? 'Cannot remove'
|
||||
: { content: 'Remove Slot', slot, className: 'danger' }
|
||||
)
|
||||
}
|
||||
|
||||
if (node.getExtraSlotMenuOptions) {
|
||||
menu_info.push(...node.getExtraSlotMenuOptions(slot))
|
||||
}
|
||||
|
||||
@@ -414,6 +414,18 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
* If `input` or `output`, reroutes will not be automatically removed, and retain a connection to the input or output, respectively.
|
||||
*/
|
||||
disconnect(network: LinkNetwork, keepReroutes?: 'input' | 'output'): void {
|
||||
// Clean up the target node's input slot
|
||||
if (this.target_id !== -1) {
|
||||
const targetNode = network.getNodeById(this.target_id)
|
||||
if (targetNode) {
|
||||
const targetInput = targetNode.inputs?.[this.target_slot]
|
||||
if (targetInput && targetInput.link === this.id) {
|
||||
targetInput.link = null
|
||||
targetNode.setDirtyCanvas?.(true, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reroutes = LLink.getReroutes(network, this)
|
||||
|
||||
const lastReroute = reroutes.at(-1)
|
||||
|
||||
@@ -284,6 +284,7 @@ export class LiteGraphGlobal {
|
||||
]
|
||||
|
||||
/**
|
||||
* @deprecated Removed; has no effect.
|
||||
* If `true`, mouse wheel events will be interpreted as trackpad gestures.
|
||||
* Tested on MacBook M4 Pro.
|
||||
* @default false
|
||||
@@ -292,6 +293,7 @@ export class LiteGraphGlobal {
|
||||
macTrackpadGestures: boolean = false
|
||||
|
||||
/**
|
||||
* @deprecated Removed; has no effect.
|
||||
* If both this setting and {@link macTrackpadGestures} are `true`, trackpad gestures will
|
||||
* only be enabled when the browser user agent includes "Mac".
|
||||
* @default true
|
||||
|
||||
@@ -135,6 +135,10 @@ export class FloatingRenderLink implements RenderLink {
|
||||
return true
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return this.toType === 'output' && input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToInput(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot,
|
||||
|
||||
@@ -651,6 +651,20 @@ export class LinkConnector {
|
||||
if (!input) throw new Error('No input slot found for link.')
|
||||
|
||||
for (const link of renderLinks) {
|
||||
// Validate the connection type before proceeding
|
||||
if (
|
||||
'canConnectToSubgraphInput' in link &&
|
||||
!link.canConnectToSubgraphInput(input)
|
||||
) {
|
||||
console.warn(
|
||||
'Invalid connection type',
|
||||
link.fromSlot.type,
|
||||
'->',
|
||||
input.type
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
link.connectToSubgraphInput(input, this.events)
|
||||
}
|
||||
} else {
|
||||
@@ -795,7 +809,10 @@ export class LinkConnector {
|
||||
*/
|
||||
disconnectLinks(): void {
|
||||
for (const link of this.renderLinks) {
|
||||
if (link instanceof MovingLinkBase) {
|
||||
if (
|
||||
link instanceof MovingLinkBase ||
|
||||
link instanceof ToInputFromIoNodeLink
|
||||
) {
|
||||
link.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -892,6 +909,14 @@ export class LinkConnector {
|
||||
)
|
||||
}
|
||||
|
||||
isSubgraphInputValidDrop(input: SubgraphInput): boolean {
|
||||
return this.renderLinks.some(
|
||||
(link) =>
|
||||
'canConnectToSubgraphInput' in link &&
|
||||
link.canConnectToSubgraphInput(input)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a reroute is a valid drop target for any of the links being connected.
|
||||
* @param reroute The reroute that would be dropped on.
|
||||
|
||||
@@ -55,6 +55,10 @@ export class MovingOutputLink extends MovingLinkBase {
|
||||
return reroute.origin_id !== this.outputNode.id
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToInput(): never {
|
||||
throw new Error('MovingOutputLink cannot connect to an input.')
|
||||
}
|
||||
|
||||
@@ -135,4 +135,9 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
connectToRerouteOutput() {
|
||||
throw new Error('ToInputRenderLink cannot connect to an output.')
|
||||
}
|
||||
disconnect(): boolean {
|
||||
if (!this.existingLink) return false
|
||||
this.existingLink.disconnect(this.network, 'input')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export class ToOutputRenderLink implements RenderLink {
|
||||
return true
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToOutput(
|
||||
node: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
CallbackReturn,
|
||||
ISlotType
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { Subgraph } from './Subgraph'
|
||||
import type { SubgraphNode } from './SubgraphNode'
|
||||
@@ -263,13 +263,8 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
|
||||
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
|
||||
if (this.mode === LGraphEventMode.BYPASS) {
|
||||
const { inputs } = this
|
||||
|
||||
// Bypass nodes by finding first input with matching type
|
||||
const parentInputIndexes = Object.keys(inputs).map(Number)
|
||||
// Prioritise exact slot index
|
||||
const indexes = [slot, ...parentInputIndexes]
|
||||
const matchingIndex = indexes.find((i) => inputs[i]?.type === type)
|
||||
const matchingIndex = this.#getBypassSlotIndex(slot, type)
|
||||
|
||||
// No input types match
|
||||
if (matchingIndex === undefined) {
|
||||
@@ -326,6 +321,44 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of the input slot on this node that matches the given output {@link slot} index.
|
||||
* Used when bypassing nodes.
|
||||
* @param slot The output slot index on this node
|
||||
* @param type The type of the final target input (so type list matches are accurate)
|
||||
* @returns The index of the input slot on this node, otherwise `undefined`.
|
||||
*/
|
||||
#getBypassSlotIndex(slot: number, type: ISlotType) {
|
||||
const { inputs } = this
|
||||
const oppositeInput = inputs[slot]
|
||||
const outputType = this.node.outputs[slot].type
|
||||
|
||||
// Any type short circuit - match slot ID, fallback to first slot
|
||||
if (type === '*' || type === '') {
|
||||
return inputs.length > slot ? slot : 0
|
||||
}
|
||||
|
||||
// Prefer input with the same slot ID
|
||||
if (
|
||||
oppositeInput &&
|
||||
LiteGraph.isValidConnection(oppositeInput.type, outputType) &&
|
||||
LiteGraph.isValidConnection(oppositeInput.type, type)
|
||||
) {
|
||||
return slot
|
||||
}
|
||||
|
||||
// Find first matching slot - prefer exact type
|
||||
return (
|
||||
// Preserve legacy behaviour; use exact match first.
|
||||
inputs.findIndex((input) => input.type === type) ??
|
||||
inputs.findIndex(
|
||||
(input) =>
|
||||
LiteGraph.isValidConnection(input.type, outputType) &&
|
||||
LiteGraph.isValidConnection(input.type, type)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the link inside a subgraph node, from the subgraph IO node to the node inside the subgraph.
|
||||
* @param slot The slot index of the output on the subgraph node.
|
||||
|
||||
@@ -176,7 +176,7 @@ export abstract class SubgraphIONodeBase<
|
||||
* @param event The event that triggered the context menu.
|
||||
*/
|
||||
protected showSlotContextMenu(slot: TSlot, event: CanvasPointerEvent): void {
|
||||
const options: IContextMenuValue[] = this.#getSlotMenuOptions(slot)
|
||||
const options: (IContextMenuValue | null)[] = this.#getSlotMenuOptions(slot)
|
||||
if (!(options.length > 0)) return
|
||||
|
||||
new LiteGraph.ContextMenu(options, {
|
||||
@@ -193,20 +193,26 @@ export abstract class SubgraphIONodeBase<
|
||||
* @param slot The slot to get the context menu options for.
|
||||
* @returns The context menu options.
|
||||
*/
|
||||
#getSlotMenuOptions(slot: TSlot): IContextMenuValue[] {
|
||||
const options: IContextMenuValue[] = []
|
||||
#getSlotMenuOptions(slot: TSlot): (IContextMenuValue | null)[] {
|
||||
const options: (IContextMenuValue | null)[] = []
|
||||
|
||||
// Disconnect option if slot has connections
|
||||
if (slot !== this.emptySlot && slot.linkIds.length > 0) {
|
||||
options.push({ content: 'Disconnect Links', value: 'disconnect' })
|
||||
}
|
||||
|
||||
// Remove / rename slot option (except for the empty slot)
|
||||
// Rename slot option (except for the empty slot)
|
||||
if (slot !== this.emptySlot) {
|
||||
options.push(
|
||||
{ content: 'Remove Slot', value: 'remove' },
|
||||
{ content: 'Rename Slot', value: 'rename' }
|
||||
)
|
||||
options.push({ content: 'Rename Slot', value: 'rename' })
|
||||
}
|
||||
|
||||
if (slot !== this.emptySlot) {
|
||||
options.push(null) // separator
|
||||
options.push({
|
||||
content: 'Remove Slot',
|
||||
value: 'remove',
|
||||
className: 'danger'
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
@@ -16,7 +16,10 @@ import type {
|
||||
GraphOrSubgraph,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type {
|
||||
ExportedSubgraphInstance,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
@@ -125,6 +128,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (!input) throw new Error('Subgraph input not found')
|
||||
|
||||
input.label = newName
|
||||
if (input._widget) {
|
||||
input._widget.label = newName
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -168,7 +174,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput: SubgraphInput,
|
||||
input: INodeInputSlot & Partial<ISubgraphInput>
|
||||
) {
|
||||
input._listenerController?.abort()
|
||||
if (
|
||||
input._listenerController &&
|
||||
typeof input._listenerController.abort === 'function'
|
||||
) {
|
||||
input._listenerController.abort()
|
||||
}
|
||||
input._listenerController = new AbortController()
|
||||
const { signal } = input._listenerController
|
||||
|
||||
@@ -204,7 +215,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
for (const input of this.inputs) {
|
||||
input._listenerController?.abort()
|
||||
if (
|
||||
input._listenerController &&
|
||||
typeof input._listenerController.abort === 'function'
|
||||
) {
|
||||
input._listenerController.abort()
|
||||
}
|
||||
}
|
||||
|
||||
this.inputs.length = 0
|
||||
@@ -253,10 +269,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
if (!subgraphInput)
|
||||
throw new Error(
|
||||
`[SubgraphNode.configure] No subgraph input found for input ${input.name}`
|
||||
if (!subgraphInput) {
|
||||
// Skip inputs that don't exist in the subgraph definition
|
||||
// This can happen when loading workflows with dynamically added inputs
|
||||
console.warn(
|
||||
`[SubgraphNode.configure] No subgraph input found for input ${input.name}, skipping`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||
|
||||
@@ -515,7 +535,44 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
for (const input of this.inputs) {
|
||||
input._listenerController?.abort()
|
||||
if (
|
||||
input._listenerController &&
|
||||
typeof input._listenerController.abort === 'function'
|
||||
) {
|
||||
input._listenerController.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization
|
||||
for (let i = 0; i < this.widgets.length; i++) {
|
||||
const widget = this.widgets[i]
|
||||
const input = this.inputs.find((inp) => inp.name === widget.name)
|
||||
|
||||
if (input) {
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
|
||||
if (subgraphInput) {
|
||||
// Find all widgets connected to this subgraph input
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
|
||||
// Update the value of all connected widgets
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = widget.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call parent serialize method
|
||||
return super.serialize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
@@ -14,4 +14,84 @@ describe('LLink', () => {
|
||||
const link = new LLink(1, 'float', 4, 2, 5, 3)
|
||||
expect(link.serialize()).toMatchSnapshot('Basic')
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should clear the target input link reference when disconnecting', () => {
|
||||
// Create a graph and nodes
|
||||
const graph = new LGraph()
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
const targetNode = new LGraphNode('Target')
|
||||
|
||||
// Add nodes to graph
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode)
|
||||
|
||||
// Add slots
|
||||
sourceNode.addOutput('out', 'number')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
// Connect the nodes
|
||||
const link = sourceNode.connect(0, targetNode, 0)
|
||||
expect(link).toBeDefined()
|
||||
expect(targetNode.inputs[0].link).toBe(link?.id)
|
||||
|
||||
// Mock setDirtyCanvas
|
||||
const setDirtyCanvasSpy = vi.spyOn(targetNode, 'setDirtyCanvas')
|
||||
|
||||
// Disconnect the link
|
||||
link?.disconnect(graph)
|
||||
|
||||
// Verify the target input's link reference is cleared
|
||||
expect(targetNode.inputs[0].link).toBeNull()
|
||||
|
||||
// Verify setDirtyCanvas was called
|
||||
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(true, false)
|
||||
})
|
||||
|
||||
it('should handle disconnecting when target node is not found', () => {
|
||||
// Create a link with invalid target
|
||||
const graph = new LGraph()
|
||||
const link = new LLink(1, 'number', 1, 0, 999, 0) // Invalid target id
|
||||
|
||||
// Should not throw when disconnecting
|
||||
expect(() => link.disconnect(graph)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should only clear link reference if it matches the current link id', () => {
|
||||
// Create a graph and nodes
|
||||
const graph = new LGraph()
|
||||
const sourceNode1 = new LGraphNode('Source1')
|
||||
const sourceNode2 = new LGraphNode('Source2')
|
||||
const targetNode = new LGraphNode('Target')
|
||||
|
||||
// Add nodes to graph
|
||||
graph.add(sourceNode1)
|
||||
graph.add(sourceNode2)
|
||||
graph.add(targetNode)
|
||||
|
||||
// Add slots
|
||||
sourceNode1.addOutput('out', 'number')
|
||||
sourceNode2.addOutput('out', 'number')
|
||||
targetNode.addInput('in', 'number')
|
||||
|
||||
// Create first connection
|
||||
const link1 = sourceNode1.connect(0, targetNode, 0)
|
||||
expect(link1).toBeDefined()
|
||||
|
||||
// Disconnect first connection
|
||||
targetNode.disconnectInput(0)
|
||||
|
||||
// Create second connection
|
||||
const link2 = sourceNode2.connect(0, targetNode, 0)
|
||||
expect(link2).toBeDefined()
|
||||
expect(targetNode.inputs[0].link).toBe(link2?.id)
|
||||
|
||||
// Try to disconnect the first link (which is already disconnected)
|
||||
// It should not affect the current connection
|
||||
link1?.disconnect(graph)
|
||||
|
||||
// The input should still have the second link
|
||||
expect(targetNode.inputs[0].link).toBe(link2?.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
"label": "Convert Selection to Subgraph"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "Exit Subgraph"
|
||||
},
|
||||
"Comfy_Graph_UnpackSubgraph": {
|
||||
"label": "Unpack the selected Subgraph"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Fit Group To Contents"
|
||||
},
|
||||
|
||||
@@ -974,6 +974,7 @@
|
||||
"Export (API)": "Export (API)",
|
||||
"Give Feedback": "Give Feedback",
|
||||
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
|
||||
"Exit Subgraph": "Exit Subgraph",
|
||||
"Fit Group To Contents": "Fit Group To Contents",
|
||||
"Group Selected Nodes": "Group Selected Nodes",
|
||||
"Convert selected nodes to group node": "Convert selected nodes to group node",
|
||||
@@ -1630,5 +1631,26 @@
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"enterNewName": "Enter new name"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "Essential",
|
||||
"viewControls": "View Controls",
|
||||
"manageShortcuts": "Manage Shortcuts",
|
||||
"noKeybinding": "No keybinding",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"subcategories": {
|
||||
"workflow": "Workflow",
|
||||
"node": "Node",
|
||||
"queue": "Queue",
|
||||
"view": "View",
|
||||
"panelControls": "Panel Controls"
|
||||
}
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Node Colors",
|
||||
"showLinks": "Show Links",
|
||||
"showGroups": "Show Frames/Groups",
|
||||
"renderBypassState": "Render Bypass State",
|
||||
"renderErrorState": "Render Error State"
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,9 @@
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir selección en subgrafo"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "Salir de subgrafo"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajustar grupo al contenido"
|
||||
},
|
||||
|
||||
@@ -765,6 +765,7 @@
|
||||
"Desktop User Guide": "Guía de usuario de escritorio",
|
||||
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
|
||||
"Edit": "Editar",
|
||||
"Exit Subgraph": "Salir de subgrafo",
|
||||
"Export": "Exportar",
|
||||
"Export (API)": "Exportar (API)",
|
||||
"Fit Group To Contents": "Ajustar grupo a contenidos",
|
||||
@@ -828,6 +829,13 @@
|
||||
"Zoom In": "Acercar",
|
||||
"Zoom Out": "Alejar"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Colores de nodos",
|
||||
"renderBypassState": "Mostrar estado de omisión",
|
||||
"renderErrorState": "Mostrar estado de error",
|
||||
"showGroups": "Mostrar marcos/grupos",
|
||||
"showLinks": "Mostrar enlaces"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "No mostrar esto de nuevo",
|
||||
"missingModels": "Modelos faltantes",
|
||||
|
||||
@@ -119,6 +119,9 @@
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir la sélection en sous-graphe"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "Quitter le sous-graphe"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajuster le groupe au contenu"
|
||||
},
|
||||
|
||||
@@ -765,6 +765,7 @@
|
||||
"Desktop User Guide": "Guide de l'utilisateur de bureau",
|
||||
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
|
||||
"Edit": "Éditer",
|
||||
"Exit Subgraph": "Quitter le sous-graphe",
|
||||
"Export": "Exporter",
|
||||
"Export (API)": "Exporter (API)",
|
||||
"Fit Group To Contents": "Ajuster le groupe au contenu",
|
||||
@@ -828,6 +829,13 @@
|
||||
"Zoom In": "Zoom avant",
|
||||
"Zoom Out": "Zoom arrière"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Couleurs des nœuds",
|
||||
"renderBypassState": "Afficher l’état de contournement",
|
||||
"renderErrorState": "Afficher l’état d’erreur",
|
||||
"showGroups": "Afficher les cadres/groupes",
|
||||
"showLinks": "Afficher les liens"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "Ne plus afficher ce message",
|
||||
"missingModels": "Modèles manquants",
|
||||
|
||||
@@ -119,6 +119,9 @@
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "選択範囲をサブグラフに変換"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "サブグラフを終了"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "グループを内容に合わせて調整"
|
||||
},
|
||||
|
||||
@@ -765,6 +765,7 @@
|
||||
"Desktop User Guide": "デスクトップユーザーガイド",
|
||||
"Duplicate Current Workflow": "現在のワークフローを複製",
|
||||
"Edit": "編集",
|
||||
"Exit Subgraph": "サブグラフを終了",
|
||||
"Export": "エクスポート",
|
||||
"Export (API)": "エクスポート (API)",
|
||||
"Fit Group To Contents": "グループを内容に合わせる",
|
||||
@@ -828,6 +829,13 @@
|
||||
"Zoom In": "ズームイン",
|
||||
"Zoom Out": "ズームアウト"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "ノードの色",
|
||||
"renderBypassState": "バイパス状態を表示",
|
||||
"renderErrorState": "エラー状態を表示",
|
||||
"showGroups": "フレーム/グループを表示",
|
||||
"showLinks": "リンクを表示"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "再度表示しない",
|
||||
"missingModels": "モデルが見つかりません",
|
||||
|
||||
@@ -119,6 +119,9 @@
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "선택 영역을 서브그래프로 변환"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "서브그래프 종료"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "그룹을 내용에 맞게 맞추기"
|
||||
},
|
||||
|
||||
@@ -765,6 +765,7 @@
|
||||
"Desktop User Guide": "데스크톱 사용자 가이드",
|
||||
"Duplicate Current Workflow": "현재 워크플로 복제",
|
||||
"Edit": "편집",
|
||||
"Exit Subgraph": "서브그래프 종료",
|
||||
"Export": "내보내기",
|
||||
"Export (API)": "내보내기 (API)",
|
||||
"Fit Group To Contents": "그룹을 내용에 맞게 조정",
|
||||
@@ -828,6 +829,13 @@
|
||||
"Zoom In": "확대",
|
||||
"Zoom Out": "축소"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "노드 색상",
|
||||
"renderBypassState": "바이패스 상태 렌더링",
|
||||
"renderErrorState": "에러 상태 렌더링",
|
||||
"showGroups": "프레임/그룹 표시",
|
||||
"showLinks": "링크 표시"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "다시 보지 않기",
|
||||
"missingModels": "모델이 없습니다",
|
||||
|
||||
@@ -119,6 +119,9 @@
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Преобразовать выделенное в подграф"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "Выйти из подграфа"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Подогнать группу к содержимому"
|
||||
},
|
||||
|
||||
@@ -765,6 +765,7 @@
|
||||
"Desktop User Guide": "Руководство пользователя для настольных ПК",
|
||||
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
|
||||
"Edit": "Редактировать",
|
||||
"Exit Subgraph": "Выйти из подграфа",
|
||||
"Export": "Экспортировать",
|
||||
"Export (API)": "Экспорт (API)",
|
||||
"Fit Group To Contents": "Подогнать группу под содержимое",
|
||||
@@ -828,6 +829,13 @@
|
||||
"Zoom In": "Увеличить",
|
||||
"Zoom Out": "Уменьшить"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Цвета узлов",
|
||||
"renderBypassState": "Отображать состояние обхода",
|
||||
"renderErrorState": "Отображать состояние ошибки",
|
||||
"showGroups": "Показать фреймы/группы",
|
||||
"showLinks": "Показать связи"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "Больше не показывать это",
|
||||
"missingModels": "Отсутствующие модели",
|
||||
|
||||
@@ -119,6 +119,9 @@
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "將選取內容轉換為子圖"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "離開子圖"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "調整群組以符合內容"
|
||||
},
|
||||
|
||||
@@ -765,6 +765,7 @@
|
||||
"Desktop User Guide": "桌面應用程式使用指南",
|
||||
"Duplicate Current Workflow": "複製目前工作流程",
|
||||
"Edit": "編輯",
|
||||
"Exit Subgraph": "離開子圖",
|
||||
"Export": "匯出",
|
||||
"Export (API)": "匯出(API)",
|
||||
"Fit Group To Contents": "群組貼合內容",
|
||||
@@ -828,6 +829,13 @@
|
||||
"Zoom In": "放大",
|
||||
"Zoom Out": "縮小"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "節點顏色",
|
||||
"renderBypassState": "顯示繞過狀態",
|
||||
"renderErrorState": "顯示錯誤狀態",
|
||||
"showGroups": "顯示框架/群組",
|
||||
"showLinks": "顯示連結"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "不要再顯示此訊息",
|
||||
"missingModels": "缺少模型",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"label": "锁定视图"
|
||||
},
|
||||
"Comfy_Canvas_ToggleMinimap": {
|
||||
"label": "畫布切換小地圖"
|
||||
"label": "画布切换小地图"
|
||||
},
|
||||
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
|
||||
"label": "忽略/取消忽略选中节点"
|
||||
@@ -119,6 +119,9 @@
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "将选区转换为子图"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "退出子图"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "适应节点框到内容"
|
||||
},
|
||||
@@ -162,10 +165,10 @@
|
||||
"label": "切换进度对话框"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Decrease": {
|
||||
"label": "減小 MaskEditor 中的筆刷大小"
|
||||
"label": "减小 MaskEditor 中的笔刷大小"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Increase": {
|
||||
"label": "增加 MaskEditor 畫筆大小"
|
||||
"label": "增加 MaskEditor 画笔大小"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "打开选中节点的遮罩编辑器"
|
||||
|
||||
@@ -83,10 +83,10 @@
|
||||
}
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"clearWorkflow": "清除工作流程",
|
||||
"deleteWorkflow": "刪除工作流程",
|
||||
"duplicate": "複製",
|
||||
"enterNewName": "輸入新名稱"
|
||||
"clearWorkflow": "清空工作流",
|
||||
"deleteWorkflow": "删除工作流",
|
||||
"duplicate": "复制",
|
||||
"enterNewName": "输入新名称"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "取消",
|
||||
@@ -218,7 +218,7 @@
|
||||
"WEBCAM": "摄像头"
|
||||
},
|
||||
"desktopMenu": {
|
||||
"confirmQuit": "有未保存的工作流程开启;任何未保存的更改都将丢失。忽略此警告并退出?",
|
||||
"confirmQuit": "存在未保存的工作流;任何未保存的更改都将丢失。忽略此警告并退出?",
|
||||
"confirmReinstall": "这将清除您的 extra_models_config.yaml 文件,并重新开始安装。您确定吗?",
|
||||
"quit": "退出",
|
||||
"reinstall": "重新安装"
|
||||
@@ -272,7 +272,7 @@
|
||||
"category": "类别",
|
||||
"choose_file_to_upload": "选择要上传的文件",
|
||||
"clear": "清除",
|
||||
"clearFilters": "清除篩選",
|
||||
"clearFilters": "清除筛选",
|
||||
"close": "关闭",
|
||||
"color": "颜色",
|
||||
"comingSoon": "即将推出",
|
||||
@@ -297,7 +297,7 @@
|
||||
"devices": "设备",
|
||||
"disableAll": "禁用全部",
|
||||
"disabling": "禁用中",
|
||||
"dismiss": "關閉",
|
||||
"dismiss": "关闭",
|
||||
"download": "下载",
|
||||
"edit": "编辑",
|
||||
"empty": "空",
|
||||
@@ -312,8 +312,8 @@
|
||||
"filter": "过滤",
|
||||
"findIssues": "查找问题",
|
||||
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
|
||||
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
|
||||
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
|
||||
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不兼容。",
|
||||
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。",
|
||||
"goToNode": "转到节点",
|
||||
"help": "帮助",
|
||||
"icon": "图标",
|
||||
@@ -399,8 +399,8 @@
|
||||
"upload": "上传",
|
||||
"usageHint": "使用提示",
|
||||
"user": "用户",
|
||||
"versionMismatchWarning": "版本相容性警告",
|
||||
"versionMismatchWarningMessage": "{warning}:{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
|
||||
"versionMismatchWarning": "版本兼容性警告",
|
||||
"versionMismatchWarningMessage": "{warning}:{detail} 请参考 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新说明。",
|
||||
"videoFailedToLoad": "视频加载失败",
|
||||
"workflow": "工作流"
|
||||
},
|
||||
@@ -410,7 +410,7 @@
|
||||
"resetView": "重置视图",
|
||||
"selectMode": "选择模式",
|
||||
"toggleLinkVisibility": "切换连线可见性",
|
||||
"toggleMinimap": "切換小地圖",
|
||||
"toggleMinimap": "切换小地图",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小"
|
||||
},
|
||||
@@ -720,7 +720,7 @@
|
||||
"disabled": "禁用",
|
||||
"disabledTooltip": "工作流将不会自动执行",
|
||||
"execute": "执行",
|
||||
"help": "說明",
|
||||
"help": "说明",
|
||||
"hideMenu": "隐藏菜单",
|
||||
"instant": "实时",
|
||||
"instantTooltip": "工作流将会在生成完成后立即执行",
|
||||
@@ -734,9 +734,9 @@
|
||||
"run": "运行",
|
||||
"runWorkflow": "运行工作流程(Shift排在前面)",
|
||||
"runWorkflowFront": "运行工作流程(排在前面)",
|
||||
"settings": "設定",
|
||||
"settings": "设定",
|
||||
"showMenu": "显示菜单",
|
||||
"theme": "主題",
|
||||
"theme": "主题",
|
||||
"toggleBottomPanel": "底部面板"
|
||||
},
|
||||
"menuLabels": {
|
||||
@@ -746,7 +746,7 @@
|
||||
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
|
||||
"Canvas Toggle Link Visibility": "切换连线可见性",
|
||||
"Canvas Toggle Lock": "切换视图锁定",
|
||||
"Canvas Toggle Minimap": "畫布切換小地圖",
|
||||
"Canvas Toggle Minimap": "画布切换小地图",
|
||||
"Check for Updates": "检查更新",
|
||||
"Clear Pending Tasks": "清除待处理任务",
|
||||
"Clear Workflow": "清除工作流",
|
||||
@@ -765,6 +765,7 @@
|
||||
"Desktop User Guide": "桌面端用户指南",
|
||||
"Duplicate Current Workflow": "复制当前工作流",
|
||||
"Edit": "编辑",
|
||||
"Exit Subgraph": "退出子圖",
|
||||
"Export": "导出",
|
||||
"Export (API)": "导出 (API)",
|
||||
"Fit Group To Contents": "适应组内容",
|
||||
@@ -813,13 +814,13 @@
|
||||
"Toggle Bottom Panel": "切换底部面板",
|
||||
"Toggle Focus Mode": "切换专注模式",
|
||||
"Toggle Logs Bottom Panel": "切换日志底部面板",
|
||||
"Toggle Model Library Sidebar": "切換模型庫側邊欄",
|
||||
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
|
||||
"Toggle Queue Sidebar": "切換佇列側邊欄",
|
||||
"Toggle Model Library Sidebar": "切换模型库侧边栏",
|
||||
"Toggle Node Library Sidebar": "切换节点库侧边栏",
|
||||
"Toggle Queue Sidebar": "切换队列侧边栏",
|
||||
"Toggle Search Box": "切换搜索框",
|
||||
"Toggle Terminal Bottom Panel": "切换终端底部面板",
|
||||
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
|
||||
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
|
||||
"Toggle Workflows Sidebar": "切换工作流侧边栏",
|
||||
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
|
||||
"Undo": "撤销",
|
||||
@@ -828,6 +829,13 @@
|
||||
"Zoom In": "放大画面",
|
||||
"Zoom Out": "缩小画面"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "節點顏色",
|
||||
"renderBypassState": "顯示繞過狀態",
|
||||
"renderErrorState": "顯示錯誤狀態",
|
||||
"showGroups": "顯示框架/群組",
|
||||
"showLinks": "顯示連結"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "不再显示此消息",
|
||||
"missingModels": "缺少模型",
|
||||
@@ -1612,10 +1620,10 @@
|
||||
"required": "必填"
|
||||
},
|
||||
"versionMismatchWarning": {
|
||||
"dismiss": "關閉",
|
||||
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
|
||||
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 版或更高版本。",
|
||||
"title": "版本相容性警告",
|
||||
"dismiss": "关闭",
|
||||
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不兼容。",
|
||||
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 版或更高版本。",
|
||||
"title": "版本兼容性警告",
|
||||
"updateFrontend": "更新前端"
|
||||
},
|
||||
"welcome": {
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片,并选择“设为背景”来使用它。"
|
||||
},
|
||||
"Comfy_Canvas_NavigationMode": {
|
||||
"name": "畫布導航模式",
|
||||
"name": "画布导航模式",
|
||||
"options": {
|
||||
"Left-Click Pan (Legacy)": "左鍵拖曳(舊版)",
|
||||
"Standard (New)": "標準(新)"
|
||||
"Left-Click Pan (Legacy)": "左键拖曳(旧版)",
|
||||
"Standard (New)": "标准(新)"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_SelectionToolbox": {
|
||||
@@ -334,7 +334,7 @@
|
||||
"Disabled": "禁用",
|
||||
"Top": "顶部"
|
||||
},
|
||||
"tooltip": "選單列位置。在行動裝置上,選單始終顯示於頂端。"
|
||||
"tooltip": "菜单栏位置。在移动设备上,菜单始终显示于顶端。"
|
||||
},
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "校验工作流"
|
||||
|
||||
@@ -476,6 +476,11 @@ const zSettings = z.object({
|
||||
'Comfy.InstalledVersion': z.string().nullable(),
|
||||
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
|
||||
'Comfy.Minimap.Visible': z.boolean(),
|
||||
'Comfy.Minimap.NodeColors': z.boolean(),
|
||||
'Comfy.Minimap.ShowLinks': z.boolean(),
|
||||
'Comfy.Minimap.ShowGroups': z.boolean(),
|
||||
'Comfy.Minimap.RenderBypassState': z.boolean(),
|
||||
'Comfy.Minimap.RenderErrorState': z.boolean(),
|
||||
'Comfy.Canvas.NavigationMode': z.string(),
|
||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||
'Comfy-Desktop.SendStatistics': z.boolean(),
|
||||
|
||||
@@ -385,8 +385,15 @@ export class ComfyApp {
|
||||
static pasteFromClipspace(node: LGraphNode) {
|
||||
if (ComfyApp.clipspace) {
|
||||
// image paste
|
||||
const combinedImgSrc =
|
||||
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src
|
||||
let combinedImgSrc: string | undefined
|
||||
if (
|
||||
ComfyApp.clipspace.combinedIndex !== undefined &&
|
||||
ComfyApp.clipspace.imgs &&
|
||||
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length
|
||||
) {
|
||||
combinedImgSrc =
|
||||
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
|
||||
}
|
||||
if (ComfyApp.clipspace.imgs && node.imgs) {
|
||||
if (node.images && ComfyApp.clipspace.images) {
|
||||
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import {
|
||||
KeyComboImpl,
|
||||
KeybindingImpl,
|
||||
@@ -11,6 +12,7 @@ export const useKeybindingService = () => {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const keybindHandler = async function (event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
@@ -32,6 +34,19 @@ export const useKeybindingService = () => {
|
||||
|
||||
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
||||
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
|
||||
// Special handling for Escape key - let dialogs handle it first
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
// If dialogs are open, don't execute the keybinding - let the dialog handle it
|
||||
if (dialogStore.dialogStack.length > 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default browser behavior first, then execute the command
|
||||
event.preventDefault()
|
||||
await commandStore.execute(keybinding.commandId)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import _ from 'lodash'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
||||
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
@@ -63,6 +64,7 @@ export const useLitegraphService = () => {
|
||||
const toastStore = useToastStore()
|
||||
const widgetStore = useWidgetStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
|
||||
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
||||
function registerSubgraphNodeDef(
|
||||
@@ -363,6 +365,7 @@ export const useLitegraphService = () => {
|
||||
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
||||
// because `registerNodeType` will overwrite the assignments.
|
||||
node.category = nodeDef.category
|
||||
node.skip_list = true
|
||||
node.title = nodeDef.display_name || nodeDef.name
|
||||
}
|
||||
|
||||
@@ -761,15 +764,8 @@ export const useLitegraphService = () => {
|
||||
options.push({
|
||||
content: 'Bypass',
|
||||
callback: () => {
|
||||
const mode =
|
||||
this.mode === LGraphEventMode.BYPASS
|
||||
? LGraphEventMode.ALWAYS
|
||||
: LGraphEventMode.BYPASS
|
||||
for (const item of app.canvas.selectedItems) {
|
||||
if (item instanceof LGraphNode) item.mode = mode
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.graph.change()
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -804,6 +800,15 @@ export const useLitegraphService = () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (this instanceof SubgraphNode) {
|
||||
options.unshift({
|
||||
content: 'Unpack Subgraph',
|
||||
callback: () => {
|
||||
useNodeOutputStore().revokeSubgraphPreviews(this)
|
||||
this.graph.unpackSubgraph(this)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||
import { t } from '@/i18n'
|
||||
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -21,6 +22,7 @@ export const useWorkflowService = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const toastStore = useToastStore()
|
||||
const dialogService = useDialogService()
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
async function getFilename(defaultName: string): Promise<string | null> {
|
||||
@@ -287,8 +289,14 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const beforeLoadNewGraph = () => {
|
||||
// Use workspaceStore here as it is patched in unit tests.
|
||||
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
|
||||
domWidgetStore.clear()
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.changeTracker.store()
|
||||
// Capture thumbnail before loading new graph
|
||||
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
||||
domWidgetStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ComfyCommand {
|
||||
versionAdded?: string
|
||||
confirmation?: string // If non-nullish, this command will prompt for confirmation
|
||||
source?: string
|
||||
category?: 'essentials' | 'view-controls' // For shortcuts panel organization
|
||||
}
|
||||
|
||||
export class ComfyCommandImpl implements ComfyCommand {
|
||||
@@ -29,6 +30,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
versionAdded?: string
|
||||
confirmation?: string
|
||||
source?: string
|
||||
category?: 'essentials' | 'view-controls'
|
||||
|
||||
constructor(command: ComfyCommand) {
|
||||
this.id = command.id
|
||||
@@ -40,6 +42,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
this.versionAdded = command.versionAdded
|
||||
this.confirmation = command.confirmation
|
||||
this.source = command.source
|
||||
this.category = command.category
|
||||
}
|
||||
|
||||
get label() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
ExecutedWsMessage,
|
||||
ResultItem,
|
||||
@@ -268,6 +268,20 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
app.nodePreviewImages = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all preview of a subgraph node and the graph it contains.
|
||||
* Does not recurse to contents of nested subgraphs.
|
||||
*/
|
||||
function revokeSubgraphPreviews(subgraphNode: SubgraphNode) {
|
||||
const graphId = subgraphNode.graph.isRootGraph
|
||||
? ''
|
||||
: subgraphNode.graph.id + ':'
|
||||
revokePreviewsByLocatorId(graphId + subgraphNode.id)
|
||||
for (const node of subgraphNode.subgraph.nodes) {
|
||||
revokePreviewsByLocatorId(subgraphNode.subgraph.id + node.id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getNodeOutputs,
|
||||
getNodeImageUrls,
|
||||
@@ -279,6 +293,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
setNodePreviewsByNodeId,
|
||||
revokePreviewsByExecutionId,
|
||||
revokeAllPreviews,
|
||||
revokeSubgraphPreviews,
|
||||
getPreviewParam
|
||||
}
|
||||
})
|
||||
|
||||
@@ -226,6 +226,14 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip fetching if API nodes are disabled via argv
|
||||
if (
|
||||
systemStatsStore.systemStats?.system?.argv?.includes(
|
||||
'--disable-api-nodes'
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import _ from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
@@ -327,6 +328,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
if (workflow.isTemporary) {
|
||||
// Clear thumbnail when temporary workflow is closed
|
||||
clearThumbnail(workflow.key)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} else {
|
||||
workflow.unload()
|
||||
@@ -387,12 +390,14 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
|
||||
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
|
||||
const isBusy = ref<boolean>(false)
|
||||
const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail()
|
||||
|
||||
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
// Capture all needed values upfront
|
||||
const oldPath = workflow.path
|
||||
const oldKey = workflow.key
|
||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
@@ -403,6 +408,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
|
||||
// Move thumbnail from old key to new key (using workflow keys, not full paths)
|
||||
const newKey = workflow.key
|
||||
moveWorkflowThumbnail(oldKey, newKey)
|
||||
// Update bookmarks
|
||||
if (wasBookmarked) {
|
||||
await bookmarkStore.setBookmarked(oldPath, false)
|
||||
@@ -420,6 +428,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||
await bookmarkStore.setBookmarked(workflow.path, false)
|
||||
}
|
||||
// Clear thumbnail when workflow is deleted
|
||||
clearThumbnail(workflow.key)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useShortcutsTab } from '@/composables/bottomPanelTabs/useShortcutsTab'
|
||||
import {
|
||||
useCommandTerminalTab,
|
||||
useLogsTerminalTab
|
||||
@@ -10,45 +11,110 @@ import { ComfyExtension } from '@/types/comfy'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
type PanelType = 'terminal' | 'shortcuts'
|
||||
|
||||
interface PanelState {
|
||||
tabs: BottomPanelExtension[]
|
||||
activeTabId: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
const bottomPanelVisible = ref(false)
|
||||
const toggleBottomPanel = () => {
|
||||
// If there are no tabs, don't show the bottom panel
|
||||
if (bottomPanelTabs.value.length === 0) {
|
||||
return
|
||||
// Multi-panel state
|
||||
const panels = ref<Record<PanelType, PanelState>>({
|
||||
terminal: { tabs: [], activeTabId: '', visible: false },
|
||||
shortcuts: { tabs: [], activeTabId: '', visible: false }
|
||||
})
|
||||
|
||||
const activePanel = ref<PanelType | null>(null)
|
||||
|
||||
// Computed properties for active panel
|
||||
const activePanelState = computed(() =>
|
||||
activePanel.value ? panels.value[activePanel.value] : null
|
||||
)
|
||||
|
||||
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
|
||||
const state = activePanelState.value
|
||||
if (!state) return null
|
||||
return state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
|
||||
})
|
||||
|
||||
const bottomPanelVisible = computed({
|
||||
get: () => !!activePanel.value,
|
||||
set: (visible: boolean) => {
|
||||
if (!visible) {
|
||||
activePanel.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
const bottomPanelTabs = computed(() => activePanelState.value?.tabs ?? [])
|
||||
const activeBottomPanelTabId = computed({
|
||||
get: () => activePanelState.value?.activeTabId ?? '',
|
||||
set: (tabId: string) => {
|
||||
const state = activePanelState.value
|
||||
if (state) {
|
||||
state.activeTabId = tabId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const togglePanel = (panelType: PanelType) => {
|
||||
const panel = panels.value[panelType]
|
||||
if (panel.tabs.length === 0) return
|
||||
|
||||
if (activePanel.value === panelType) {
|
||||
// Hide current panel
|
||||
activePanel.value = null
|
||||
} else {
|
||||
// Show target panel
|
||||
activePanel.value = panelType
|
||||
if (!panel.activeTabId && panel.tabs.length > 0) {
|
||||
panel.activeTabId = panel.tabs[0].id
|
||||
}
|
||||
}
|
||||
bottomPanelVisible.value = !bottomPanelVisible.value
|
||||
}
|
||||
|
||||
const bottomPanelTabs = ref<BottomPanelExtension[]>([])
|
||||
const activeBottomPanelTabId = ref<string>('')
|
||||
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
|
||||
return (
|
||||
bottomPanelTabs.value.find(
|
||||
(tab) => tab.id === activeBottomPanelTabId.value
|
||||
) ?? null
|
||||
)
|
||||
})
|
||||
const setActiveTab = (tabId: string) => {
|
||||
activeBottomPanelTabId.value = tabId
|
||||
const toggleBottomPanel = () => {
|
||||
// Legacy method - toggles terminal panel
|
||||
togglePanel('terminal')
|
||||
}
|
||||
|
||||
const setActiveTab = (tabId: string) => {
|
||||
const state = activePanelState.value
|
||||
if (state) {
|
||||
state.activeTabId = tabId
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBottomPanelTab = (tabId: string) => {
|
||||
if (activeBottomPanelTabId.value === tabId && bottomPanelVisible.value) {
|
||||
bottomPanelVisible.value = false
|
||||
} else {
|
||||
activeBottomPanelTabId.value = tabId
|
||||
bottomPanelVisible.value = true
|
||||
// Find which panel contains this tab
|
||||
for (const [panelType, panel] of Object.entries(panels.value)) {
|
||||
const tab = panel.tabs.find((t) => t.id === tabId)
|
||||
if (tab) {
|
||||
if (activePanel.value === panelType && panel.activeTabId === tabId) {
|
||||
activePanel.value = null
|
||||
} else {
|
||||
activePanel.value = panelType as PanelType
|
||||
panel.activeTabId = tabId
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
const registerBottomPanelTab = (tab: BottomPanelExtension) => {
|
||||
bottomPanelTabs.value = [...bottomPanelTabs.value, tab]
|
||||
if (bottomPanelTabs.value.length === 1) {
|
||||
activeBottomPanelTabId.value = tab.id
|
||||
const targetPanel = tab.targetPanel ?? 'terminal'
|
||||
const panel = panels.value[targetPanel]
|
||||
|
||||
panel.tabs = [...panel.tabs, tab]
|
||||
if (panel.tabs.length === 1) {
|
||||
panel.activeTabId = tab.id
|
||||
}
|
||||
|
||||
useCommandStore().registerCommand({
|
||||
id: `Workspace.ToggleBottomPanelTab.${tab.id}`,
|
||||
icon: 'pi pi-list',
|
||||
label: `Toggle ${tab.title} Bottom Panel`,
|
||||
category: 'view-controls' as const,
|
||||
function: () => toggleBottomPanelTab(tab.id),
|
||||
source: 'System'
|
||||
})
|
||||
@@ -59,6 +125,7 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
if (isElectron()) {
|
||||
registerBottomPanelTab(useCommandTerminalTab())
|
||||
}
|
||||
useShortcutsTab().forEach(registerBottomPanelTab)
|
||||
}
|
||||
|
||||
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
|
||||
@@ -68,6 +135,11 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
// Multi-panel API
|
||||
panels,
|
||||
activePanel,
|
||||
togglePanel,
|
||||
|
||||
bottomPanelVisible,
|
||||
toggleBottomPanel,
|
||||
bottomPanelTabs,
|
||||
|
||||
@@ -44,6 +44,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
label: labelFunction,
|
||||
tooltip: tooltipFunction,
|
||||
versionAdded: '1.3.9',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
toggleSidebarTab(tab.id)
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface BaseSidebarTabExtension {
|
||||
export interface BaseBottomPanelExtension {
|
||||
id: string
|
||||
title: string
|
||||
targetPanel?: 'terminal' | 'shortcuts'
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('should return high price for kling-v2-1-master model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v2-1-master' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.40/Run')
|
||||
})
|
||||
|
||||
it('should return high price for kling-v2-master model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode', [
|
||||
@@ -104,6 +114,16 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe('$1.40/Run')
|
||||
})
|
||||
|
||||
it('should return high price for kling-v2-1-master model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingImage2VideoNode', [
|
||||
{ name: 'model_name', value: 'v2-1-master' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.40/Run')
|
||||
})
|
||||
|
||||
it('should return standard price for kling-v1-6 model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingImage2VideoNode', [
|
||||
@@ -219,6 +239,49 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - MinimaxHailuoVideoNode', () => {
|
||||
it('should return $0.28 for 6s duration and 768P resolution', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('MinimaxHailuoVideoNode', [
|
||||
{ name: 'duration', value: '6' },
|
||||
{ name: 'resolution', value: '768P' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28/Run')
|
||||
})
|
||||
|
||||
it('should return $0.60 for 10s duration and 768P resolution', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('MinimaxHailuoVideoNode', [
|
||||
{ name: 'duration', value: '10' },
|
||||
{ name: 'resolution', value: '768P' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.56/Run')
|
||||
})
|
||||
|
||||
it('should return $0.49 for 6s duration and 1080P resolution', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('MinimaxHailuoVideoNode', [
|
||||
{ name: 'duration', value: '6' },
|
||||
{ name: 'resolution', value: '1080P' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.49/Run')
|
||||
})
|
||||
|
||||
it('should return range when duration widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('MinimaxHailuoVideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28-0.56/Run (varies with resolution & duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - OpenAIDalle2', () => {
|
||||
it('should return $0.02 for 1024x1024 size', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
@@ -1384,11 +1447,19 @@ describe('useNodePricing', () => {
|
||||
const testCases = [
|
||||
{
|
||||
model: 'gemini-2.5-pro-preview-05-06',
|
||||
expected: '$0.00016/$0.0006 per 1K tokens'
|
||||
expected: '$0.00125/$0.01 per 1K tokens'
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-pro',
|
||||
expected: '$0.00125/$0.01 per 1K tokens'
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-flash-preview-04-17',
|
||||
expected: '$0.00125/$0.01 per 1K tokens'
|
||||
expected: '$0.0003/$0.0025 per 1K tokens'
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-flash',
|
||||
expected: '$0.0003/$0.0025 per 1K tokens'
|
||||
},
|
||||
{ model: 'unknown-gemini-model', expected: 'Token-based' }
|
||||
]
|
||||
@@ -1441,7 +1512,10 @@ describe('useNodePricing', () => {
|
||||
{ model: 'gpt-4o', expected: '$0.0025/$0.01 per 1K tokens' },
|
||||
{ model: 'gpt-4.1-nano', expected: '$0.0001/$0.0004 per 1K tokens' },
|
||||
{ model: 'gpt-4.1-mini', expected: '$0.0004/$0.0016 per 1K tokens' },
|
||||
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' }
|
||||
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' },
|
||||
{ model: 'gpt-5-nano', expected: '$0.00005/$0.0004 per 1K tokens' },
|
||||
{ model: 'gpt-5-mini', expected: '$0.00025/$0.002 per 1K tokens' },
|
||||
{ model: 'gpt-5', expected: '$0.00125/$0.01 per 1K tokens' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ model, expected }) => {
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||