Compare commits
37 Commits
blueprint-
...
webcam-cap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882d4c186a | ||
|
|
d5751aea13 | ||
|
|
ac879765fc | ||
|
|
bc8569080d | ||
|
|
0c50251943 | ||
|
|
e7481c5c36 | ||
|
|
3d5aef4aa0 | ||
|
|
474b15c48b | ||
|
|
1c37a3633d | ||
|
|
1e384c6735 | ||
|
|
e092986cfd | ||
|
|
51ab1541dd | ||
|
|
8031a832f4 | ||
|
|
7b109df599 | ||
|
|
f7f0c05d1a | ||
|
|
335b72bc62 | ||
|
|
f8ede78dc6 | ||
|
|
af8ad95af1 | ||
|
|
cd5f6fd0b9 | ||
|
|
68b6159f99 | ||
|
|
4b628724bd | ||
|
|
cfbb4c708f | ||
|
|
dc60b54ab4 | ||
|
|
ad85956c77 | ||
|
|
f9b7e51b63 | ||
|
|
59761eb65d | ||
|
|
16b6b5bbbc | ||
|
|
ca335baac9 | ||
|
|
48c21f6f25 | ||
|
|
a455c7be4c | ||
|
|
e1693dc341 | ||
|
|
a65b063a98 | ||
|
|
7fc51c6d96 | ||
|
|
0ac768722c | ||
|
|
450b8b9954 | ||
|
|
74aadb3227 | ||
|
|
6f4c335566 |
@@ -96,7 +96,6 @@
|
||||
"typescript/restrict-template-expressions": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
},
|
||||
|
||||
@@ -98,10 +98,12 @@ const config: StorybookConfig = {
|
||||
},
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
experimental: {
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
treeshake: false,
|
||||
output: {
|
||||
keepNames: true,
|
||||
strictExecutionOrder: true
|
||||
keepNames: true
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
|
||||
@@ -23,7 +23,9 @@ export class SettingDialog extends BaseDialog {
|
||||
* @param value - The value to set
|
||||
*/
|
||||
async setStringSetting(id: string, value: string) {
|
||||
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
await settingInputDiv.locator('input').fill(value)
|
||||
}
|
||||
|
||||
@@ -32,31 +34,16 @@ export class SettingDialog extends BaseDialog {
|
||||
* @param id - The id of the setting
|
||||
*/
|
||||
async toggleBooleanSetting(id: string) {
|
||||
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
await settingInputDiv.locator('input').click()
|
||||
}
|
||||
|
||||
get searchBox() {
|
||||
return this.root.getByPlaceholder(/Search/)
|
||||
}
|
||||
|
||||
get categories() {
|
||||
return this.root.locator('nav').getByRole('button')
|
||||
}
|
||||
|
||||
category(name: string) {
|
||||
return this.root.locator('nav').getByRole('button', { name })
|
||||
}
|
||||
|
||||
get contentArea() {
|
||||
return this.root.getByRole('main')
|
||||
}
|
||||
|
||||
async goToAboutPanel() {
|
||||
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||
name: 'About'
|
||||
})
|
||||
await aboutButton.click()
|
||||
await this.page.waitForSelector('.about-container')
|
||||
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
|
||||
await this.page
|
||||
.getByTestId(TestIds.dialogs.about)
|
||||
.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,9 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
|
||||
await bottomPanel.shortcuts.manageButton.click()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: 'Keybinding' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,13 +244,9 @@ test.describe('Missing models warning', () => {
|
||||
test.describe('Settings', () => {
|
||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const contentArea = settingsDialog.locator('main')
|
||||
await expect(contentArea).toBeVisible()
|
||||
const isUsableHeight = await contentArea.evaluate(
|
||||
const settingsContent = comfyPage.page.locator('.settings-content')
|
||||
await expect(settingsContent).toBeVisible()
|
||||
const isUsableHeight = await settingsContent.evaluate(
|
||||
(el) => el.clientHeight > 30
|
||||
)
|
||||
expect(isUsableHeight).toBeTruthy()
|
||||
@@ -260,9 +256,7 @@ test.describe('Settings', () => {
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.press(',')
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
const settingsLocator = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
const settingsLocator = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsLocator).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(settingsLocator).not.toBeVisible()
|
||||
@@ -281,15 +275,10 @@ test.describe('Settings', () => {
|
||||
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
||||
// Open the settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
|
||||
await comfyPage.page.waitForSelector('.settings-container')
|
||||
|
||||
// Open the keybinding tab
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await settingsDialog
|
||||
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
||||
.click()
|
||||
await comfyPage.page.getByLabel('Keybinding').click()
|
||||
await comfyPage.page.waitForSelector(
|
||||
'[placeholder="Search Keybindings..."]'
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 16 KiB |
@@ -5,6 +5,13 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Graph.DeduplicateSubgraphNodeIds',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -61,10 +61,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -80,10 +77,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -826,7 +820,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
await comfyPage.page.waitForSelector('.settings-container', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
@@ -836,7 +830,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
comfyPage.page.locator('.settings-container')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
|
||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 68 KiB |
@@ -22,6 +22,7 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
name: 'TestSettingsExtension',
|
||||
settings: [
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestHiddenSetting' as TestSettingId,
|
||||
name: 'Test Hidden Setting',
|
||||
type: 'hidden',
|
||||
@@ -29,6 +30,7 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
category: ['Test', 'Hidden']
|
||||
},
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestDeprecatedSetting' as TestSettingId,
|
||||
name: 'Test Deprecated Setting',
|
||||
type: 'text',
|
||||
@@ -37,6 +39,7 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
category: ['Test', 'Deprecated']
|
||||
},
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestVisibleSetting' as TestSettingId,
|
||||
name: 'Test Visible Setting',
|
||||
type: 'text',
|
||||
@@ -49,143 +52,238 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
})
|
||||
|
||||
test('can open settings dialog and use search box', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await expect(dialog.searchBox).toHaveAttribute(
|
||||
// Find the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await expect(searchBox).toBeVisible()
|
||||
|
||||
// Verify search box has the correct placeholder
|
||||
await expect(searchBox).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search')
|
||||
)
|
||||
})
|
||||
|
||||
test('search box is functional and accepts input', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await dialog.searchBox.fill('Comfy')
|
||||
await expect(dialog.searchBox).toHaveValue('Comfy')
|
||||
// Find and interact with the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Comfy')
|
||||
|
||||
// Verify the input was accepted
|
||||
await expect(searchBox).toHaveValue('Comfy')
|
||||
})
|
||||
|
||||
test('search box clears properly', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await dialog.searchBox.fill('test')
|
||||
await expect(dialog.searchBox).toHaveValue('test')
|
||||
// Find and interact with the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('test')
|
||||
await expect(searchBox).toHaveValue('test')
|
||||
|
||||
await dialog.searchBox.clear()
|
||||
await expect(dialog.searchBox).toHaveValue('')
|
||||
// Clear the search box
|
||||
await searchBox.clear()
|
||||
await expect(searchBox).toHaveValue('')
|
||||
})
|
||||
|
||||
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
expect(await dialog.categories.count()).toBeGreaterThan(0)
|
||||
// Check that the sidebar has categories
|
||||
const categories = comfyPage.page.locator(
|
||||
'.settings-sidebar .p-listbox-option'
|
||||
)
|
||||
expect(await categories.count()).toBeGreaterThan(0)
|
||||
|
||||
// Check that at least one category is visible
|
||||
await expect(categories.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('can select different categories in sidebar', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
const categoryCount = await dialog.categories.count()
|
||||
// Click on a specific category (Appearance) to verify category switching
|
||||
const appearanceCategory = comfyPage.page.getByRole('option', {
|
||||
name: 'Appearance'
|
||||
})
|
||||
await appearanceCategory.click()
|
||||
|
||||
if (categoryCount > 1) {
|
||||
await dialog.categories.nth(1).click()
|
||||
// Verify the category is selected
|
||||
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
|
||||
})
|
||||
|
||||
await expect(dialog.categories.nth(1)).toHaveClass(
|
||||
/bg-interface-menu-component-surface-selected/
|
||||
)
|
||||
}
|
||||
test('settings content area is visible', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Check that the content area is visible
|
||||
const contentArea = comfyPage.page.locator('.settings-content')
|
||||
await expect(contentArea).toBeVisible()
|
||||
|
||||
// Check that tab panels are visible
|
||||
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
|
||||
await expect(tabPanels).toBeVisible()
|
||||
})
|
||||
|
||||
test('search functionality affects UI state', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await dialog.searchBox.fill('graph')
|
||||
await expect(dialog.searchBox).toHaveValue('graph')
|
||||
// Find the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
|
||||
// Type in search box
|
||||
await searchBox.fill('graph')
|
||||
|
||||
// Verify that the search input is handled
|
||||
await expect(searchBox).toHaveValue('graph')
|
||||
})
|
||||
|
||||
test('settings dialog can be closed', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Close with escape key
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(settingsDialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await dialog.searchBox.fill('a')
|
||||
await dialog.searchBox.fill('ab')
|
||||
await dialog.searchBox.fill('abc')
|
||||
await dialog.searchBox.fill('abcd')
|
||||
// Type rapidly in search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('a')
|
||||
await searchBox.fill('ab')
|
||||
await searchBox.fill('abc')
|
||||
await searchBox.fill('abcd')
|
||||
|
||||
await expect(dialog.searchBox).toHaveValue('abcd')
|
||||
// Verify final value
|
||||
await expect(searchBox).toHaveValue('abcd')
|
||||
})
|
||||
|
||||
test('search excludes hidden settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await dialog.searchBox.fill('Test')
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should show visible setting but not hidden setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
})
|
||||
|
||||
test('search excludes deprecated settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await dialog.searchBox.fill('Test')
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should show visible setting but not deprecated setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
})
|
||||
|
||||
test('search shows visible settings but excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await dialog.searchBox.fill('Test')
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should only show the visible setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
|
||||
// Should not show hidden or deprecated settings
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
})
|
||||
|
||||
test('search by setting name excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Hidden')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Deprecated')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
// Search specifically for hidden setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Hidden')
|
||||
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Visible')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
// Should not show the hidden setting even when searching by name
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
|
||||
// Search specifically for deprecated setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Deprecated')
|
||||
|
||||
// Should not show the deprecated setting even when searching by name
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
|
||||
// Search for visible setting by name - should work
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Visible')
|
||||
|
||||
// Should show the visible setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 79 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.40.2",
|
||||
"version": "1.39.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "catalog:"
|
||||
"vite": "^8.0.0-beta.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
|
||||
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
556
pnpm-lock.yaml
generated
@@ -92,7 +92,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vite: 8.0.0-beta.13
|
||||
vite: ^8.0.0-beta.8
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition
|
||||
} from '@/base/common/downloadUtil'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
|
||||
let mockIsCloud = false
|
||||
|
||||
@@ -158,14 +155,10 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
blob: blobFn
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
@@ -202,147 +195,5 @@ describe('downloadUtil', () => {
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
|
||||
expect(mockLink.download).toBe('user-friendly.png')
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
|
||||
)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(mockLink.download).toBe('中文.png')
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl, 'my-fallback.png')
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(mockLink.download).toBe('my-fallback.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty header', () => {
|
||||
expect(extractFilenameFromContentDisposition('')).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts filename from simple quoted format', () => {
|
||||
const header = 'attachment; filename="test-file.png"'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test-file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts filename from unquoted format', () => {
|
||||
const header = 'attachment; filename=test-file.png'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test-file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts filename from RFC 5987 format', () => {
|
||||
const header = "attachment; filename*=UTF-8''test%20file.png"
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers RFC 5987 format over simple format', () => {
|
||||
const header =
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'preferred.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles unicode characters in RFC 5987 format', () => {
|
||||
const header =
|
||||
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
|
||||
})
|
||||
|
||||
it('falls back to simple format when RFC 5987 decoding fails', () => {
|
||||
const header =
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
|
||||
})
|
||||
|
||||
it('handles header with only attachment disposition', () => {
|
||||
const header = 'attachment'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles case-insensitive filename parameter', () => {
|
||||
const header = 'attachment; FILENAME="test.png"'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,57 +75,14 @@ const extractFilenameFromUrl = (url: string): string | null => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from Content-Disposition header
|
||||
* Handles both simple format: attachment; filename="name.png"
|
||||
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
|
||||
* @param header - The Content-Disposition header value
|
||||
* @returns The extracted filename or null if not found
|
||||
*/
|
||||
export function extractFilenameFromContentDisposition(
|
||||
header: string | null
|
||||
): string | null {
|
||||
if (!header) return null
|
||||
|
||||
// Try RFC 5987 extended format first (filename*=UTF-8''...)
|
||||
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (extendedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(extendedMatch[1])
|
||||
} catch {
|
||||
// Fall through to simple format
|
||||
}
|
||||
}
|
||||
|
||||
// Try simple quoted format: filename="..."
|
||||
const quotedMatch = header.match(/filename="([^"]+)"/i)
|
||||
if (quotedMatch?.[1]) {
|
||||
return quotedMatch[1]
|
||||
}
|
||||
|
||||
// Try unquoted format: filename=...
|
||||
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
|
||||
if (unquotedMatch?.[1]) {
|
||||
return unquotedMatch[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadViaBlobFetch = async (
|
||||
href: string,
|
||||
fallbackFilename: string
|
||||
filename: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
const headerFilename =
|
||||
extractFilenameFromContentDisposition(contentDisposition)
|
||||
|
||||
const blob = await response.blob()
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -19,11 +19,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const mockData = vi.hoisted(() => ({
|
||||
isLoggedIn: false,
|
||||
isDesktop: false,
|
||||
setShowConflictRedDot: (_value: boolean) => {}
|
||||
}))
|
||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => {
|
||||
@@ -40,36 +36,6 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
return mockData.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => ({
|
||||
shouldShowRedDot: computed(() => true)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => {
|
||||
const shouldShowConflictRedDot = ref(false)
|
||||
mockData.setShowConflictRedDot = (value: boolean) => {
|
||||
shouldShowConflictRedDot.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
useConflictAcknowledgment: () => ({
|
||||
shouldShowRedDot: shouldShowConflictRedDot
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({
|
||||
shouldShowManagerButtons: computed(() => true),
|
||||
openManager: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
currentUser: null,
|
||||
@@ -148,7 +114,6 @@ describe('TopMenuSection', () => {
|
||||
localStorage.clear()
|
||||
mockData.isDesktop = false
|
||||
mockData.isLoggedIn = false
|
||||
mockData.setShowConflictRedDot(false)
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
@@ -365,16 +330,4 @@ describe('TopMenuSection', () => {
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows manager red dot only for manager conflicts', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Release red dot is mocked as true globally for this test file.
|
||||
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
|
||||
|
||||
mockData.setShowConflictRedDot(true)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -145,6 +145,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -172,6 +173,8 @@ const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
@@ -233,8 +236,10 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
}
|
||||
])
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
// Right side panel toggle
|
||||
|
||||
@@ -89,12 +89,12 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isShortcutsTabActive = computed(() => {
|
||||
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
|
||||
}
|
||||
|
||||
const openKeybindingSettings = async () => {
|
||||
settingsDialog.show('keybinding')
|
||||
dialogService.showSettingsDialog('keybinding')
|
||||
}
|
||||
|
||||
const closeBottomPanel = () => {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
nodeContent: ({ context }) => ({
|
||||
class: 'group/tree-node',
|
||||
onClick: (e: MouseEvent) =>
|
||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode<T>),
|
||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
|
||||
onContextmenu: (e: MouseEvent) =>
|
||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode<T>)
|
||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
|
||||
}),
|
||||
nodeToggleButton: () => ({
|
||||
onClick: (e: MouseEvent) => {
|
||||
@@ -36,11 +36,15 @@
|
||||
</Tree>
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
</template>
|
||||
<script setup lang="ts" generic="T">
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import Tree from 'primevue/tree'
|
||||
import { computed, provide, ref, shallowRef } from 'vue'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
@@ -56,10 +60,6 @@ import type {
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
|
||||
required: true
|
||||
})
|
||||
@@ -69,13 +69,13 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
|
||||
const storeSelectionKeys = selectionKeys.value !== undefined
|
||||
|
||||
const props = defineProps<{
|
||||
root: TreeExplorerNode<T>
|
||||
root: TreeExplorerNode
|
||||
class?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'nodeClick', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
|
||||
(e: 'nodeDelete', node: RenderedTreeExplorerNode<T>): void
|
||||
(e: 'contextMenu', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
|
||||
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
|
||||
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
|
||||
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
@@ -83,19 +83,19 @@ const {
|
||||
getAddFolderMenuItem,
|
||||
handleFolderCreation,
|
||||
addFolderCommand
|
||||
} = useTreeFolderOperations<T>(
|
||||
/* expandNode */ (node: TreeExplorerNode<T>) => {
|
||||
} = useTreeFolderOperations(
|
||||
/* expandNode */ (node: TreeExplorerNode) => {
|
||||
expandedKeys.value[node.key] = true
|
||||
}
|
||||
)
|
||||
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode<T>>(() => {
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
const renderedRoot = fillNodeInfo(props.root)
|
||||
return newFolderNode.value
|
||||
? combineTrees(renderedRoot, newFolderNode.value)
|
||||
: renderedRoot
|
||||
})
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
||||
if (node.getIcon) {
|
||||
const icon = node.getIcon()
|
||||
if (icon) {
|
||||
@@ -111,9 +111,7 @@ const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
|
||||
}
|
||||
const fillNodeInfo = (
|
||||
node: TreeExplorerNode<T>
|
||||
): RenderedTreeExplorerNode<T> => {
|
||||
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
||||
const children = node.children?.map(fillNodeInfo) ?? []
|
||||
const totalLeaves = node.leaf
|
||||
? 1
|
||||
@@ -130,7 +128,7 @@ const fillNodeInfo = (
|
||||
}
|
||||
const onNodeContentClick = async (
|
||||
e: MouseEvent,
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
node: RenderedTreeExplorerNode
|
||||
) => {
|
||||
if (!storeSelectionKeys) {
|
||||
selectionKeys.value = {}
|
||||
@@ -141,22 +139,20 @@ const onNodeContentClick = async (
|
||||
emit('nodeClick', node, e)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const menuTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const extraMenuItems = computed(() => {
|
||||
const node = menuTargetNode.value
|
||||
return node?.contextMenuItems
|
||||
? typeof node.contextMenuItems === 'function'
|
||||
? node.contextMenuItems(node)
|
||||
: node.contextMenuItems
|
||||
return menuTargetNode.value?.contextMenuItems
|
||||
? typeof menuTargetNode.value.contextMenuItems === 'function'
|
||||
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
|
||||
: menuTargetNode.value.contextMenuItems
|
||||
: []
|
||||
})
|
||||
const renameEditingNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const errorHandling = useErrorHandling()
|
||||
const handleNodeLabelEdit = async (
|
||||
n: RenderedTreeExplorerNode,
|
||||
node: RenderedTreeExplorerNode,
|
||||
newName: string
|
||||
) => {
|
||||
const node = n as RenderedTreeExplorerNode<T>
|
||||
await errorHandling.wrapWithErrorHandlingAsync(
|
||||
async () => {
|
||||
if (node.key === newFolderNode.value?.key) {
|
||||
@@ -174,36 +170,35 @@ const handleNodeLabelEdit = async (
|
||||
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
|
||||
|
||||
const { t } = useI18n()
|
||||
const renameCommand = (node: RenderedTreeExplorerNode<T>) => {
|
||||
const renameCommand = (node: RenderedTreeExplorerNode) => {
|
||||
renameEditingNode.value = node
|
||||
}
|
||||
const deleteCommand = async (node: RenderedTreeExplorerNode<T>) => {
|
||||
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
|
||||
await node.handleDelete?.()
|
||||
emit('nodeDelete', node)
|
||||
}
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const node = menuTargetNode.value
|
||||
return [
|
||||
getAddFolderMenuItem(node),
|
||||
const menuItems = computed<MenuItem[]>(() =>
|
||||
[
|
||||
getAddFolderMenuItem(menuTargetNode.value),
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-file-edit',
|
||||
command: () => {
|
||||
if (node) {
|
||||
renameCommand(node)
|
||||
if (menuTargetNode.value) {
|
||||
renameCommand(menuTargetNode.value)
|
||||
}
|
||||
},
|
||||
visible: node?.handleRename !== undefined
|
||||
visible: menuTargetNode.value?.handleRename !== undefined
|
||||
},
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
if (node) {
|
||||
await deleteCommand(node)
|
||||
if (menuTargetNode.value) {
|
||||
await deleteCommand(menuTargetNode.value)
|
||||
}
|
||||
},
|
||||
visible: node?.handleDelete !== undefined,
|
||||
visible: menuTargetNode.value?.handleDelete !== undefined,
|
||||
isAsync: true // The delete command can be async
|
||||
},
|
||||
...extraMenuItems.value
|
||||
@@ -215,12 +210,9 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
})
|
||||
: undefined
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
const handleContextMenu = (
|
||||
e: MouseEvent,
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
) => {
|
||||
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
|
||||
menuTargetNode.value = node
|
||||
emit('contextMenu', node, e)
|
||||
if (menuItems.value.filter((item) => item.visible).length > 0) {
|
||||
@@ -232,13 +224,15 @@ const wrapCommandWithErrorHandler = (
|
||||
command: (event: MenuItemCommandEvent) => void,
|
||||
{ isAsync = false }: { isAsync: boolean }
|
||||
) => {
|
||||
const node = menuTargetNode.value
|
||||
return isAsync
|
||||
? errorHandling.wrapWithErrorHandlingAsync(
|
||||
command as (event: MenuItemCommandEvent) => Promise<void>,
|
||||
node?.handleError
|
||||
menuTargetNode.value?.handleError
|
||||
)
|
||||
: errorHandling.wrapWithErrorHandling(
|
||||
command,
|
||||
menuTargetNode.value?.handleError
|
||||
)
|
||||
: errorHandling.wrapWithErrorHandling(command, node?.handleError)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
<script setup lang="ts">
|
||||
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
|
||||
import Badge from 'primevue/badge'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
@@ -53,17 +53,17 @@ import type {
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
node: RenderedTreeExplorerNode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'itemDropped',
|
||||
node: RenderedTreeExplorerNode<T>,
|
||||
data: RenderedTreeExplorerNode<T>
|
||||
node: RenderedTreeExplorerNode,
|
||||
data: RenderedTreeExplorerNode
|
||||
): void
|
||||
(e: 'dragStart', node: RenderedTreeExplorerNode<T>): void
|
||||
(e: 'dragEnd', node: RenderedTreeExplorerNode<T>): void
|
||||
(e: 'dragStart', node: RenderedTreeExplorerNode): void
|
||||
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
|
||||
}>()
|
||||
|
||||
const nodeBadgeText = computed<string>(() => {
|
||||
@@ -80,7 +80,7 @@ const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
|
||||
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
|
||||
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
|
||||
const handleRename = (newName: string) => {
|
||||
handleEditLabel?.(props.node as RenderedTreeExplorerNode, newName)
|
||||
handleEditLabel?.(props.node, newName)
|
||||
}
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
@@ -117,13 +117,9 @@ if (props.node.droppable) {
|
||||
onDrop: async (event) => {
|
||||
const dndData = event.source.data as TreeExplorerDragAndDropData
|
||||
if (dndData.type === 'tree-explorer-node') {
|
||||
await props.node.handleDrop?.(dndData as TreeExplorerDragAndDropData<T>)
|
||||
await props.node.handleDrop?.(dndData)
|
||||
canDrop.value = false
|
||||
emit(
|
||||
'itemDropped',
|
||||
props.node,
|
||||
dndData.data as RenderedTreeExplorerNode<T>
|
||||
)
|
||||
emit('itemDropped', props.node, dndData.data)
|
||||
}
|
||||
},
|
||||
onDragEnter: (event) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
size="md"
|
||||
class="workflow-template-selector-dialog"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[comfy--template]" />
|
||||
@@ -854,3 +854,19 @@ onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Ensure the workflow template selector dialog fits within provided dialog */
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
width: 100% !important;
|
||||
max-width: 1400px;
|
||||
height: 100% !important;
|
||||
aspect-ratio: auto !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
max-width: 1600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
|
||||
@@ -116,7 +116,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -134,7 +134,10 @@ const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
function openBlueprintOverwriteSetting() {
|
||||
useDialogStore().closeDialog()
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite'
|
||||
)
|
||||
}
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
@@ -64,7 +64,7 @@ import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
@@ -105,7 +105,10 @@ const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
|
||||
@@ -80,7 +80,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
@@ -103,7 +103,10 @@ const handleGotItClick = () => {
|
||||
|
||||
function openShowMissingNodesSetting() {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingNodesWarning'
|
||||
)
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
@@ -162,7 +162,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -173,7 +173,7 @@ const { isInsufficientCredits = false } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogStore = useDialogStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
: 'credits'
|
||||
settingsDialog.show(settingsPanel)
|
||||
dialogService.showSettingsDialog(settingsPanel)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -172,7 +172,7 @@ const { isInsufficientCredits = false } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
||||
})
|
||||
await fetchBalance()
|
||||
handleClose(false)
|
||||
settingsDialog.show('workspace')
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else if (response.status === 'pending') {
|
||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
|
||||
<PanelTemplate
|
||||
value="About"
|
||||
class="about-container"
|
||||
data-testid="about-panel"
|
||||
>
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('g.about') }}
|
||||
</h2>
|
||||
@@ -28,7 +32,7 @@
|
||||
v-if="systemStatsStore.systemStats"
|
||||
:stats="systemStatsStore.systemStats"
|
||||
/>
|
||||
</div>
|
||||
</PanelTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -39,6 +43,8 @@ import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
<PanelTemplate value="Keybinding" class="keybinding-panel">
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
@@ -131,7 +135,7 @@
|
||||
<i class="pi pi-replay" />
|
||||
{{ $t('g.resetAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PanelTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -155,6 +159,7 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
const filters = ref({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="credits-container h-full">
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
@@ -102,7 +102,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -110,6 +110,7 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
|
||||
@@ -6,15 +6,14 @@
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span class="text-base font-semibold text-base-foreground">
|
||||
<span
|
||||
v-if="uiConfig.showMembersList"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>
|
||||
<template v-if="activeView === 'active'">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count:
|
||||
isSingleSeatPlan || isPersonalWorkspace
|
||||
? 1
|
||||
: members.length,
|
||||
maxSeats: maxSeats
|
||||
count: members.length
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
@@ -28,10 +27,7 @@
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="uiConfig.showSearch && !isSingleSeatPlan"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
@@ -49,16 +45,14 @@
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
isSingleSeatPlan
|
||||
? 'grid-cols-1 py-0'
|
||||
: activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
@@ -107,19 +101,17 @@
|
||||
<div />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="!isSingleSeatPlan">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +166,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
|
||||
uiConfig.membersGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
@@ -214,14 +206,14 @@
|
||||
</div>
|
||||
<!-- Join date -->
|
||||
<span
|
||||
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
|
||||
v-if="uiConfig.showDateColumn"
|
||||
class="text-sm text-muted-foreground text-right"
|
||||
>
|
||||
{{ formatDate(member.joinDate) }}
|
||||
</span>
|
||||
<!-- Remove member action (OWNER only, can't remove yourself) -->
|
||||
<div
|
||||
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
|
||||
v-if="permissions.canRemoveMembers"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
@@ -245,29 +237,8 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Upsell Banner -->
|
||||
<div
|
||||
v-if="isSingleSeatPlan"
|
||||
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
|
||||
>
|
||||
<p class="m-0 text-sm text-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.members.upsellBannerUpgrade')
|
||||
: $t('workspacePanel.members.upsellBannerSubscribe')
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="cursor-pointer underline text-sm"
|
||||
@click="showSubscriptionDialog()"
|
||||
>
|
||||
{{ $t('workspacePanel.members.viewPlans') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<template v-if="activeView === 'pending'">
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(invite, index) in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
@@ -371,8 +342,6 @@ import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type {
|
||||
PendingInvite,
|
||||
@@ -398,27 +367,6 @@ const {
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
subscription,
|
||||
showSubscriptionDialog,
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const maxSeats = computed(() => {
|
||||
if (isPersonalWorkspace.value) return 1
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return 1
|
||||
const tierKey = TIER_TO_KEY[tier]
|
||||
if (!tierKey) return 1
|
||||
return getMaxSeats(tierKey)
|
||||
})
|
||||
|
||||
const isSingleSeatPlan = computed(() => {
|
||||
if (isPersonalWorkspace.value) return false
|
||||
if (!isActiveSubscription.value) return true
|
||||
return maxSeats.value <= 1
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeView = ref<'active' | 'pending'>('active')
|
||||
|
||||
21
src/components/dialog/content/setting/PanelTemplate.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
|
||||
<div class="flex h-full w-full flex-col gap-2">
|
||||
<slot name="header" />
|
||||
<ScrollPanel class="h-0 grow pr-2">
|
||||
<slot />
|
||||
</ScrollPanel>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="user-settings-container h-full">
|
||||
<TabPanel value="User" class="user-settings-container h-full">
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
|
||||
<Divider class="mb-3" />
|
||||
@@ -95,12 +95,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<TabPanel value="Workspace" class="h-full">
|
||||
<WorkspacePanelContent />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<header class="mb-8 flex items-center gap-4">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
@@ -8,38 +8,44 @@
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
<TabsRoot v-model="activeTab">
|
||||
</div>
|
||||
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabsList class="flex items-center gap-2 pb-1">
|
||||
<TabsTrigger
|
||||
<TabList unstyled class="flex w-full gap-2">
|
||||
<Tab
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||
buttonVariants({
|
||||
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'plan' && 'text-base-foreground no-underline'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
</Tab>
|
||||
<Tab
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||
buttonVariants({
|
||||
variant: activeTab === 'members' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'members' && 'text-base-foreground no-underline',
|
||||
'ml-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.tabs.membersCount', {
|
||||
count: members.length
|
||||
count: isInPersonalWorkspace ? 1 : members.length
|
||||
})
|
||||
}}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
v-tooltip="
|
||||
@@ -49,22 +55,20 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!isSingleSeatPlan && isInviteLimitReached"
|
||||
:class="
|
||||
!isSingleSeatPlan &&
|
||||
isInviteLimitReached &&
|
||||
'opacity-50 cursor-not-allowed'
|
||||
"
|
||||
:disabled="isInviteLimitReached"
|
||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
<i class="pi pi-plus text-sm" />
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus ml-1 text-sm" />
|
||||
</Button>
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="ml-2"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
@@ -72,21 +76,17 @@
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<button
|
||||
<div
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
type="button"
|
||||
:disabled="!!item.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 bg-transparent border-none cursor-pointer',
|
||||
item.class,
|
||||
item.disabled && 'pointer-events-auto cursor-not-allowed'
|
||||
)
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
|
||||
]"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
@@ -96,47 +96,44 @@
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
<TabPanels unstyled>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabPanel>
|
||||
<TabPanel value="members">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { buttonVariants } from '@/components/ui/button/button.variants'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const tabTriggerBase =
|
||||
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
|
||||
const tabTriggerActive =
|
||||
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
|
||||
const tabTriggerInactive =
|
||||
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
@@ -147,26 +144,19 @@ const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
|
||||
|
||||
const isSingleSeatPlan = computed(() => {
|
||||
if (!isActiveSubscription.value) return true
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return true
|
||||
const tierKey = TIER_TO_KEY[tier]
|
||||
if (!tierKey) return true
|
||||
return getMaxSeats(tierKey) <= 1
|
||||
})
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
|
||||
storeToRefs(workspaceStore)
|
||||
const {
|
||||
workspaceName,
|
||||
members,
|
||||
isInviteLimitReached,
|
||||
isWorkspaceSubscribed,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
|
||||
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
|
||||
const activeTab = ref(defaultTab)
|
||||
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
|
||||
useWorkspaceUI()
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
@@ -197,16 +187,11 @@ const deleteTooltip = computed(() => {
|
||||
})
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
if (isSingleSeatPlan.value) return null
|
||||
if (!isInviteLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
if (isSingleSeatPlan.value) {
|
||||
showInviteMemberUpsellDialog()
|
||||
return
|
||||
}
|
||||
if (isInviteLimitReached.value) return
|
||||
showInviteMemberDialog()
|
||||
}
|
||||
@@ -246,6 +231,7 @@ const menuItems = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setActiveTab(defaultTab)
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
|
||||
<span>{{ workspaceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
</script>
|
||||
@@ -70,17 +70,31 @@
|
||||
@click="onSelectLink"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-3 top-2.5 cursor-pointer"
|
||||
class="absolute right-4 top-2 cursor-pointer"
|
||||
@click="onCopyLink"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi size-4',
|
||||
justCopied ? 'pi-check text-green-500' : 'pi-copy'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
>
|
||||
<g clip-path="url(#clip0_2127_14348)">
|
||||
<path
|
||||
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
|
||||
stroke="white"
|
||||
stroke-width="1.3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2127_14348">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +118,6 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -117,7 +130,6 @@ const loading = ref(false)
|
||||
const email = ref('')
|
||||
const step = ref<'email' | 'link'>('email')
|
||||
const generatedLink = ref('')
|
||||
const justCopied = ref(false)
|
||||
|
||||
const isValidEmail = computed(() => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
@@ -149,10 +161,6 @@ async function onCreateLink() {
|
||||
async function onCopyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedLink.value)
|
||||
justCopied.value = true
|
||||
setTimeout(() => {
|
||||
justCopied.value = false
|
||||
}, 759)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
|
||||
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onDismiss"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
|
||||
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onDismiss">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onUpgrade">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
|
||||
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
|
||||
function onDismiss() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
}
|
||||
|
||||
function onUpgrade() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
32
src/components/dialog/header/SettingDialogHeader.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
|
||||
<i class="pi pi-cog" />
|
||||
<span>{{ $t('g.settings') }}</span>
|
||||
<Tag
|
||||
v-if="isStaging"
|
||||
value="staging"
|
||||
severity="warn"
|
||||
class="ml-2 text-xs"
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
import { isStaging } from '@/config/staging'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
const { flags } = useFeatureFlags()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pi-cog {
|
||||
font-size: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.version-tag {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -14,13 +14,11 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TabError from './TabError.vue'
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
import TabNodes from './parameters/TabNodes.vue'
|
||||
@@ -35,7 +33,6 @@ import {
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
@@ -90,25 +87,10 @@ function closePanel() {
|
||||
type RightSidePanelTabList = Array<{
|
||||
label: () => string
|
||||
value: RightSidePanelTab
|
||||
icon?: string
|
||||
}>
|
||||
|
||||
//FIXME all errors if nothing selected?
|
||||
const selectedNodeErrors = computed(() =>
|
||||
selectedNodes.value
|
||||
.map((node) => executionStore.getNodeErrors(`${node.id}`))
|
||||
.filter((nodeError) => !!nodeError)
|
||||
)
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = []
|
||||
if (selectedNodeErrors.value.length) {
|
||||
list.push({
|
||||
label: () => t('g.error'),
|
||||
value: 'error',
|
||||
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||
})
|
||||
}
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
@@ -289,7 +271,6 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
</nav>
|
||||
@@ -307,7 +288,6 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:node="selectedSingleNode"
|
||||
/>
|
||||
<template v-else>
|
||||
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
errors: NodeError[]
|
||||
}>()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
|
||||
{{ t('g.copy') }}
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
|
||||
:key="index"
|
||||
class="px-2"
|
||||
>
|
||||
<h3 class="text-error" v-text="error.message" />
|
||||
<div class="text-muted-foreground" v-text="error.details" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -83,7 +83,10 @@ const favoriteNode = computed(() =>
|
||||
)
|
||||
|
||||
const widgetValue = computed({
|
||||
get: () => widget.value,
|
||||
get: () => {
|
||||
widget.vueTrack?.()
|
||||
return widget.value
|
||||
},
|
||||
set: (newValue: string | number | boolean | object) => {
|
||||
emit('update:widgetValue', newValue)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
@@ -20,7 +20,7 @@ import LayoutField from './LayoutField.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
// NODES settings
|
||||
const showAdvancedParameters = computed({
|
||||
@@ -92,7 +92,7 @@ function updateGridSpacingFromInput(value: number | null | undefined) {
|
||||
}
|
||||
|
||||
function openFullSettings() {
|
||||
settingsDialog.show()
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -108,14 +108,15 @@ import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingPanelType } from '@/platform/settings/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
@@ -128,7 +129,7 @@ const commandStore = useCommandStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogStore = useDialogStore()
|
||||
const managerState = useManagerState()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -165,8 +166,15 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
|
||||
}
|
||||
}
|
||||
|
||||
const showSettings = (defaultPanel?: SettingPanelType) => {
|
||||
settingsDialog.show(defaultPanel)
|
||||
const showSettings = (defaultPanel?: string) => {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent,
|
||||
props: {
|
||||
defaultPanel
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const showManageExtensions = async () => {
|
||||
|
||||
@@ -50,8 +50,7 @@
|
||||
<template #before-label="{ node: treeNode }">
|
||||
<span
|
||||
v-if="
|
||||
(treeNode.data as ComfyWorkflow)?.isModified ||
|
||||
!(treeNode.data as ComfyWorkflow)?.isPersisted
|
||||
treeNode.data?.isModified || !treeNode.data?.isPersisted
|
||||
"
|
||||
>*</span
|
||||
>
|
||||
|
||||
@@ -215,11 +215,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
}
|
||||
)
|
||||
|
||||
interface TreeExplorerExposed {
|
||||
addFolderCommand: (targetNodeKey: string) => void
|
||||
}
|
||||
|
||||
const treeExplorerRef = ref<TreeExplorerExposed | null>(null)
|
||||
const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null)
|
||||
defineExpose({
|
||||
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
const expandedKeys = inject(InjectKeyExpandedKeys)
|
||||
const handleItemDrop = (node: RenderedTreeExplorerNode<ComfyNodeDefImpl>) => {
|
||||
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
|
||||
if (!expandedKeys) return
|
||||
expandedKeys.value[node.key] = true
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<Toast group="invite-accepted" position="top-right">
|
||||
<template #message="slotProps">
|
||||
<div class="flex items-center gap-2 justify-between w-full">
|
||||
<div class="flex flex-col justify-start">
|
||||
<div class="text-base">
|
||||
{{ slotProps.message.summary }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-foreground">
|
||||
{{ slotProps.message.detail.text }} <br />
|
||||
{{ slotProps.message.detail.workspaceName }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
variant="inverted"
|
||||
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
|
||||
>
|
||||
{{ t('workspace.viewWorkspace') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
|
||||
function viewWorkspace(workspaceId: string) {
|
||||
void switchWithConfirmation(workspaceId)
|
||||
toast.removeGroup('invite-accepted')
|
||||
}
|
||||
</script>
|
||||
@@ -31,15 +31,6 @@ vi.mock('pinia')
|
||||
const mockShowSettingsDialog = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
|
||||
// Mock the settings dialog composable
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: mockShowSettingsDialog,
|
||||
hide: vi.fn(),
|
||||
showAbout: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
@@ -73,6 +64,7 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showSettingsDialog: mockShowSettingsDialog,
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -152,7 +152,6 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
@@ -166,7 +165,6 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
@@ -200,7 +198,7 @@ const canUpgrade = computed(() => {
|
||||
})
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
settingsDialog.show('user')
|
||||
dialogService.showSettingsDialog('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -211,9 +209,9 @@ const handleOpenPlansAndPricing = () => {
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
settingsDialog.show('subscription')
|
||||
dialogService.showSettingsDialog('subscription')
|
||||
} else {
|
||||
settingsDialog.show('credits')
|
||||
dialogService.showSettingsDialog('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
|
||||
@@ -55,61 +55,63 @@
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
||||
<template v-if="showCreditsSection">
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<!-- Subscribed: Show Add Credits button -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<!-- Unsubscribed: Show Subscribe button -->
|
||||
<SubscribeButton
|
||||
v-else-if="isPersonalWorkspace"
|
||||
:fluid="false"
|
||||
:label="
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
<!-- Non-personal workspace: Show pricing table -->
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
{{
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<!-- Add Credits (subscribed + personal or workspace owner only) -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && permissions.canTopUp"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<!-- Subscribe/Resubscribe (only when not subscribed or cancelled) -->
|
||||
<SubscribeButton
|
||||
v-if="showSubscribeAction && isPersonalWorkspace"
|
||||
:fluid="false"
|
||||
:label="
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
<Button
|
||||
v-if="showSubscribeAction && !isPersonalWorkspace"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
{{
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
<Divider class="mx-0 my-2" />
|
||||
</template>
|
||||
|
||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||
<div
|
||||
@@ -220,16 +222,16 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
initState,
|
||||
workspaceName,
|
||||
isInPersonalWorkspace: isPersonalWorkspace
|
||||
isInPersonalWorkspace: isPersonalWorkspace,
|
||||
isWorkspaceSubscribed
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -240,7 +242,6 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
|
||||
useBillingContext()
|
||||
@@ -274,24 +275,22 @@ const canUpgrade = computed(() => {
|
||||
})
|
||||
|
||||
const showPlansAndPricing = computed(
|
||||
() => permissions.value.canManageSubscription
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
const showManagePlan = computed(
|
||||
() => permissions.value.canManageSubscription && isActiveSubscription.value
|
||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
||||
)
|
||||
const showSubscribeAction = computed(
|
||||
() =>
|
||||
permissions.value.canManageSubscription &&
|
||||
(!isActiveSubscription.value || isCancelled.value)
|
||||
const showCreditsSection = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
settingsDialog.show('user')
|
||||
dialogService.showSettingsDialog('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceSettings = () => {
|
||||
settingsDialog.show('workspace')
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -302,9 +301,9 @@ const handleOpenPlansAndPricing = () => {
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
settingsDialog.show('workspace')
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else {
|
||||
settingsDialog.show('credits')
|
||||
dialogService.showSettingsDialog('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
|
||||
@@ -36,7 +36,7 @@ defineProps<{
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
>
|
||||
<slot>
|
||||
<div class="flex flex-col p-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
:class="cn('rounded-2xl overflow-hidden relative', sizeClasses)"
|
||||
class="base-widget-layout rounded-2xl overflow-hidden relative"
|
||||
@keydown.esc.capture="handleEscape"
|
||||
>
|
||||
<div
|
||||
@@ -141,31 +141,14 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'h-[80vh] w-[90vw] max-w-[960px]',
|
||||
md: 'h-[80vh] w-[90vw] max-w-[1400px]',
|
||||
lg: 'h-[80vh] w-[90vw] max-w-[1280px] aspect-[20/13] min-[1450px]:max-w-[1724px]',
|
||||
full: 'h-full w-full max-w-[1400px] 2xl:max-w-[1600px]'
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg'
|
||||
} = defineProps<{
|
||||
const { contentTitle, rightPanelTitle } = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
|
||||
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||
default: false
|
||||
})
|
||||
@@ -232,3 +215,17 @@ function handleEscape(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.base-widget-layout {
|
||||
height: 80vh;
|
||||
width: 90vw;
|
||||
max-width: 1280px;
|
||||
aspect-ratio: 20/13;
|
||||
}
|
||||
|
||||
@media (min-width: 1450px) {
|
||||
.base-widget-layout {
|
||||
max-width: 1724px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
@@ -74,5 +73,4 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
|
||||
() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
const isInPersonalWorkspace = { value: true }
|
||||
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
|
||||
return {
|
||||
...(original as Record<string, unknown>),
|
||||
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
|
||||
useTeamWorkspaceStore: () => ({
|
||||
isInPersonalWorkspace: isInPersonalWorkspace.value,
|
||||
activeWorkspace: activeWorkspace.value,
|
||||
_setPersonalWorkspace: (value: boolean) => {
|
||||
isInPersonalWorkspace.value = value
|
||||
activeWorkspace.value = value
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get isInPersonalWorkspace() {
|
||||
return mockIsPersonal.value
|
||||
},
|
||||
get activeWorkspace() {
|
||||
return mockIsPersonal.value
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
},
|
||||
updateActiveWorkspace: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
@@ -77,18 +52,20 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
|
||||
useBillingPlans: () => ({
|
||||
get plans() {
|
||||
return mockPlans
|
||||
},
|
||||
currentPlanSlug: { value: null },
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
}))
|
||||
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
|
||||
const plans = { value: [] }
|
||||
const currentPlanSlug = { value: null }
|
||||
return {
|
||||
useBillingPlans: () => ({
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
@@ -111,9 +88,6 @@ describe('useBillingContext', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
@@ -187,51 +161,4 @@ describe('useBillingContext', () => {
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
it('returns 1 for personal workspaces regardless of tier', () => {
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('standard')).toBe(1)
|
||||
expect(getMaxSeats('creator')).toBe(1)
|
||||
expect(getMaxSeats('pro')).toBe(1)
|
||||
expect(getMaxSeats('founder')).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to hardcoded values when no API plans available', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('standard')).toBe(1)
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
expect(getMaxSeats('pro')).toBe(20)
|
||||
expect(getMaxSeats('founder')).toBe(1)
|
||||
})
|
||||
|
||||
it('prefers API max_seats when plans are loaded', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockPlans.value = [
|
||||
{
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 2110000,
|
||||
max_seats: 50,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 10000,
|
||||
total_credits_cents: 2110000
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('pro')).toBe(50)
|
||||
// Tiers without API plans still fall back to hardcoded values
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,6 @@ import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
@@ -120,16 +115,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
|
||||
const apiTier = KEY_TO_TIER[tierKey]
|
||||
const plan = plans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
|
||||
)
|
||||
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
@@ -238,7 +223,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
fetchStatus,
|
||||
|
||||
@@ -1,76 +1,49 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
|
||||
function createTestGraph() {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('input', 'INT')
|
||||
node.addWidget('number', 'testnum', 2, () => undefined, {})
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const onReactivityUpdate = vi.fn()
|
||||
watch(vueNodeData, onReactivityUpdate)
|
||||
|
||||
return [node, graph, onReactivityUpdate] as const
|
||||
}
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
it('should trigger on callback', async () => {
|
||||
const [node, , onReactivityUpdate] = createTestGraph()
|
||||
|
||||
function createTestGraph() {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('input', 'INT')
|
||||
node.addWidget('number', 'testnum', 2, () => undefined, {})
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
return { node, graph, vueNodeData }
|
||||
}
|
||||
|
||||
it('widget values are reactive through the store', async () => {
|
||||
const { node } = createTestGraph()
|
||||
const store = useWidgetValueStore()
|
||||
const widget = node.widgets![0]
|
||||
|
||||
// Verify widget is a BaseWidget with correct value and node assignment
|
||||
expect(widget).toBeInstanceOf(BaseWidget)
|
||||
expect(widget.value).toBe(2)
|
||||
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||
|
||||
// Initial value should be in store after setNodeId was called
|
||||
expect(store.getWidget(node.id, 'testnum')?.value).toBe(2)
|
||||
|
||||
const onValueChange = vi.fn()
|
||||
const widgetValue = computed(
|
||||
() => store.getWidget(node.id, 'testnum')?.value
|
||||
)
|
||||
watch(widgetValue, onValueChange)
|
||||
|
||||
widget.value = 42
|
||||
node.widgets![0].callback!(2)
|
||||
await nextTick()
|
||||
|
||||
expect(widgetValue.value).toBe(42)
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('widget values remain reactive after a connection is made', async () => {
|
||||
const { node, graph } = createTestGraph()
|
||||
const store = useWidgetValueStore()
|
||||
const onValueChange = vi.fn()
|
||||
it('should remain reactive after a connection is made', async () => {
|
||||
const [node, graph, onReactivityUpdate] = createTestGraph()
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: String(node.id),
|
||||
nodeId: '1',
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
await nextTick()
|
||||
onReactivityUpdate.mockClear()
|
||||
|
||||
const widgetValue = computed(
|
||||
() => store.getWidget(node.id, 'testnum')?.value
|
||||
)
|
||||
watch(widgetValue, onValueChange)
|
||||
|
||||
node.widgets![0].value = 99
|
||||
node.widgets![0].callback!(2)
|
||||
await nextTick()
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(widgetValue.value).toBe(99)
|
||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
import { customRef, reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
@@ -11,7 +11,10 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
@@ -38,37 +41,19 @@ export interface WidgetSlotMetadata {
|
||||
linked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal render-specific widget data extracted from LiteGraph widgets.
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
nodeId?: NodeId
|
||||
name: string
|
||||
type: string
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
value: WidgetValue
|
||||
borderStyle?: string
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
/** Control widget for seed randomization/increment/decrement */
|
||||
controlWidget?: SafeControlWidget
|
||||
/** Whether widget has custom layout size computation */
|
||||
hasLayoutSize?: boolean
|
||||
/** Whether widget is a DOM widget */
|
||||
isDOMWidget?: boolean
|
||||
/** Node type (for subgraph promoted widgets) */
|
||||
label?: string
|
||||
nodeType?: string
|
||||
/**
|
||||
* Widget options needed for render decisions.
|
||||
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
|
||||
*/
|
||||
options?: {
|
||||
canvasOnly?: boolean
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
options?: IWidgetOptions
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
|
||||
@@ -110,6 +95,23 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function widgetWithVueTrack(
|
||||
widget: IBaseWidget
|
||||
): asserts widget is IBaseWidget & { vueTrack: () => void } {
|
||||
if (widget.vueTrack) return
|
||||
|
||||
customRef((track, trigger) => {
|
||||
widget.callback = useChainCallback(widget.callback, trigger)
|
||||
widget.vueTrack = track
|
||||
return { get() {}, set() {} }
|
||||
})
|
||||
}
|
||||
function useReactiveWidgetValue(widget: IBaseWidget) {
|
||||
widgetWithVueTrack(widget)
|
||||
widget.vueTrack()
|
||||
return widget.value
|
||||
}
|
||||
|
||||
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find(
|
||||
(w) => w.name == 'control_after_generate'
|
||||
@@ -131,18 +133,26 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
||||
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
||||
*/
|
||||
interface SharedWidgetEnhancements {
|
||||
/** Reactive widget value that updates when the widget changes */
|
||||
value: WidgetValue
|
||||
/** Control widget for seed randomization/increment/decrement */
|
||||
controlWidget?: SafeControlWidget
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Node type (for subgraph promoted widgets) */
|
||||
nodeType?: string
|
||||
/** Border style for promoted/advanced widgets */
|
||||
borderStyle?: string
|
||||
/** Widget label */
|
||||
label?: string
|
||||
/** Widget options */
|
||||
options?: IWidgetOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts common widget enhancements shared across different rendering contexts.
|
||||
* This function centralizes the logic for extracting metadata from widgets.
|
||||
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
|
||||
* This function centralizes the logic for extracting metadata and reactive values
|
||||
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
|
||||
*/
|
||||
export function getSharedWidgetEnhancements(
|
||||
node: LGraphNode,
|
||||
@@ -151,9 +161,17 @@ export function getSharedWidgetEnhancements(
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
return {
|
||||
value: useReactiveWidgetValue(widget),
|
||||
controlWidget: getControlWidget(widget),
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
||||
nodeType: getNodeType(node, widget)
|
||||
nodeType: getNodeType(node, widget),
|
||||
borderStyle: widget.promoted
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined,
|
||||
label: widget.label,
|
||||
options: widget.options as IWidgetOptions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +212,7 @@ function safeWidgetMapper(
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
return function (widget) {
|
||||
try {
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
@@ -210,41 +228,20 @@ function safeWidgetMapper(
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
const options = widget.options
|
||||
? {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
: undefined
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const localId = isProxyWidget(widget)
|
||||
? widget._overlay?.nodeId
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const name = isProxyWidget(widget)
|
||||
? widget._overlay.widgetName
|
||||
: widget.name
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
name,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
options,
|
||||
slotMetadata: slotInfo
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text'
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,7 @@ export const usePriceBadge = () => {
|
||||
return badges
|
||||
}
|
||||
|
||||
function isCreditsBadge(
|
||||
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
|
||||
): boolean {
|
||||
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
|
||||
const badgeInstance = typeof badge === 'function' ? badge() : badge
|
||||
return badgeInstance.icon?.image === componentIconSvg
|
||||
}
|
||||
@@ -63,7 +61,6 @@ export const usePriceBadge = () => {
|
||||
}
|
||||
return {
|
||||
getCreditsBadge,
|
||||
isCreditsBadge,
|
||||
updateSubgraphCredits
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { shallowRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
@@ -8,14 +8,12 @@ import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
* Use this to handle folder operations in a tree.
|
||||
* @param expandNode - The function to expand a node.
|
||||
*/
|
||||
export function useTreeFolderOperations<T>(
|
||||
expandNode: (node: RenderedTreeExplorerNode<T>) => void
|
||||
export function useTreeFolderOperations(
|
||||
expandNode: (node: RenderedTreeExplorerNode) => void
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const newFolderNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||
const addFolderTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(
|
||||
null
|
||||
)
|
||||
const newFolderNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const addFolderTargetNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
|
||||
// Generate a unique temporary key for the new folder
|
||||
const generateTempKey = (parentKey: string) => {
|
||||
@@ -39,7 +37,7 @@ export function useTreeFolderOperations<T>(
|
||||
* The command to add a folder to a node via the context menu
|
||||
* @param targetNode - The node where the folder will be added under
|
||||
*/
|
||||
const addFolderCommand = (targetNode: RenderedTreeExplorerNode<T>) => {
|
||||
const addFolderCommand = (targetNode: RenderedTreeExplorerNode) => {
|
||||
expandNode(targetNode)
|
||||
newFolderNode.value = {
|
||||
key: generateTempKey(targetNode.key),
|
||||
@@ -51,13 +49,13 @@ export function useTreeFolderOperations<T>(
|
||||
totalLeaves: 0,
|
||||
badgeText: '',
|
||||
isEditingLabel: true
|
||||
} as RenderedTreeExplorerNode<T>
|
||||
}
|
||||
addFolderTargetNode.value = targetNode
|
||||
}
|
||||
|
||||
// Generate the "Add Folder" menu item
|
||||
const getAddFolderMenuItem = (
|
||||
targetNode: RenderedTreeExplorerNode<T> | null
|
||||
targetNode: RenderedTreeExplorerNode | null
|
||||
): MenuItem => {
|
||||
return {
|
||||
label: t('g.newFolder'),
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
} from '@/renderer/core/canvas/canvasStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
@@ -74,7 +73,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
@@ -584,7 +582,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
versionAdded: '1.3.7',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
settingsDialog.show()
|
||||
void dialogService.showSettingsDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -833,7 +831,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'About ComfyUI',
|
||||
versionAdded: '1.6.4',
|
||||
function: () => {
|
||||
settingsDialog.showAbout()
|
||||
void dialogService.showSettingsDialog('about')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,7 +61,7 @@ export function useFeatureFlags() {
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
|
||||
@@ -7,13 +7,8 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createNode, isImageNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
cloneDataTransfer,
|
||||
pasteImageNode,
|
||||
pasteImageNodes,
|
||||
usePaste
|
||||
} from './usePaste'
|
||||
import { isImageNode } from '@/utils/litegraphUtil'
|
||||
import { pasteImageNode, usePaste } from './usePaste'
|
||||
|
||||
function createMockNode() {
|
||||
return {
|
||||
@@ -91,7 +86,6 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
createNode: vi.fn(),
|
||||
isAudioNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isVideoNode: vi.fn()
|
||||
@@ -105,32 +99,34 @@ describe('pasteImageNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should create new LoadImage node when no image node provided', async () => {
|
||||
it('should create new LoadImage node when no image node provided', () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items
|
||||
)
|
||||
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
|
||||
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pos).toEqual([100, 200])
|
||||
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.graph!.change).toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing image node when provided', async () => {
|
||||
it('should use existing image node when provided', () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await pasteImageNode(
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -140,13 +136,13 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
|
||||
})
|
||||
|
||||
it('should handle multiple image files', async () => {
|
||||
it('should handle multiple image files', () => {
|
||||
const mockNode = createMockNode()
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const dataTransfer = createDataTransfer([file1, file2])
|
||||
|
||||
await pasteImageNode(
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -156,11 +152,11 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
|
||||
})
|
||||
|
||||
it('should do nothing when no image files present', async () => {
|
||||
it('should do nothing when no image files present', () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
await pasteImageNode(
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -170,13 +166,13 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter non-image items', async () => {
|
||||
it('should filter non-image items', () => {
|
||||
const mockNode = createMockNode()
|
||||
const imageFile = createImageFile()
|
||||
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
||||
const dataTransfer = createDataTransfer([textFile, imageFile])
|
||||
|
||||
await pasteImageNode(
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -187,61 +183,21 @@ describe('pasteImageNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteImageNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create multiple nodes for multiple files', async () => {
|
||||
const mockNode1 = createMockNode()
|
||||
const mockNode2 = createMockNode()
|
||||
vi.mocked(createNode)
|
||||
.mockResolvedValueOnce(mockNode1 as unknown as LGraphNode)
|
||||
.mockResolvedValueOnce(mockNode2 as unknown as LGraphNode)
|
||||
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const fileList = createDataTransfer([file1, file2]).files
|
||||
|
||||
const result = await pasteImageNodes(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
fileList
|
||||
)
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
|
||||
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
|
||||
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
||||
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
||||
expect(result).toEqual([mockNode1, mockNode2])
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const fileList = createDataTransfer([]).files
|
||||
|
||||
const result = await pasteImageNodes(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
fileList
|
||||
)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanvas.current_node = null
|
||||
mockWorkspaceStore.shiftDown = false
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle image paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
usePaste()
|
||||
|
||||
@@ -251,7 +207,7 @@ describe('usePaste', () => {
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
@@ -356,62 +312,3 @@ describe('usePaste', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloneDataTransfer', () => {
|
||||
it('should clone string data', () => {
|
||||
const original = new DataTransfer()
|
||||
original.setData('text/plain', 'test text')
|
||||
original.setData('text/html', '<p>test html</p>')
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
expect(cloned.getData('text/plain')).toBe('test text')
|
||||
expect(cloned.getData('text/html')).toBe('<p>test html</p>')
|
||||
})
|
||||
|
||||
it('should clone files', () => {
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const original = createDataTransfer([file1, file2])
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
// Files are added from both .files and .items, causing duplicates
|
||||
expect(cloned.files.length).toBeGreaterThanOrEqual(2)
|
||||
expect(Array.from(cloned.files)).toContain(file1)
|
||||
expect(Array.from(cloned.files)).toContain(file2)
|
||||
})
|
||||
|
||||
it('should preserve dropEffect and effectAllowed', () => {
|
||||
const original = new DataTransfer()
|
||||
original.dropEffect = 'copy'
|
||||
original.effectAllowed = 'copyMove'
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
expect(cloned.dropEffect).toBe('copy')
|
||||
expect(cloned.effectAllowed).toBe('copyMove')
|
||||
})
|
||||
|
||||
it('should handle empty DataTransfer', () => {
|
||||
const original = new DataTransfer()
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
expect(cloned.types.length).toBe(0)
|
||||
expect(cloned.files.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should clone both string data and files', () => {
|
||||
const file = createImageFile()
|
||||
const original = createDataTransfer([file])
|
||||
original.setData('text/plain', 'test')
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
expect(cloned.getData('text/plain')).toBe('test')
|
||||
// Files are added from both .files and .items
|
||||
expect(cloned.files.length).toBeGreaterThanOrEqual(1)
|
||||
expect(Array.from(cloned.files)).toContain(file)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,41 +6,9 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import {
|
||||
createNode,
|
||||
isAudioNode,
|
||||
isImageNode,
|
||||
isVideoNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
|
||||
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
|
||||
|
||||
export function cloneDataTransfer(original: DataTransfer): DataTransfer {
|
||||
const persistent = new DataTransfer()
|
||||
|
||||
// Copy string data
|
||||
for (const type of original.types) {
|
||||
const data = original.getData(type)
|
||||
if (data) {
|
||||
persistent.setData(type, data)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of original.items) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
persistent.items.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve dropEffect and effectAllowed
|
||||
persistent.dropEffect = original.dropEffect
|
||||
persistent.effectAllowed = original.effectAllowed
|
||||
|
||||
return persistent
|
||||
}
|
||||
|
||||
function pasteClipboardItems(data: DataTransfer): boolean {
|
||||
const rawData = data.getData('text/html')
|
||||
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
@@ -80,37 +48,27 @@ function pasteItemsOnNode(
|
||||
)
|
||||
}
|
||||
|
||||
export async function pasteImageNode(
|
||||
export function pasteImageNode(
|
||||
canvas: LGraphCanvas,
|
||||
items: DataTransferItemList,
|
||||
imageNode: LGraphNode | null = null
|
||||
): Promise<LGraphNode | null> {
|
||||
// No image node selected: add a new one
|
||||
): void {
|
||||
const {
|
||||
graph,
|
||||
graph_mouse: [posX, posY]
|
||||
} = canvas
|
||||
|
||||
if (!imageNode) {
|
||||
imageNode = await createNode(canvas, 'LoadImage')
|
||||
// No image node selected: add a new one
|
||||
const newNode = LiteGraph.createNode('LoadImage')
|
||||
if (newNode) {
|
||||
newNode.pos = [posX, posY]
|
||||
imageNode = graph?.add(newNode) ?? null
|
||||
}
|
||||
graph?.change()
|
||||
}
|
||||
|
||||
pasteItemsOnNode(items, imageNode, 'image')
|
||||
return imageNode
|
||||
}
|
||||
|
||||
export async function pasteImageNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: FileList
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
for (const file of fileList) {
|
||||
const transfer = new DataTransfer()
|
||||
transfer.items.add(file)
|
||||
const imageNode = await pasteImageNode(canvas, transfer.items)
|
||||
|
||||
if (imageNode) {
|
||||
nodes.push(imageNode)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,7 +93,6 @@ export const usePaste = () => {
|
||||
const { graph } = canvas
|
||||
let data: DataTransfer | string | null = e.clipboardData
|
||||
if (!data) throw new Error('No clipboard data on clipboard event')
|
||||
data = cloneDataTransfer(data)
|
||||
|
||||
const { items } = data
|
||||
|
||||
@@ -157,7 +114,7 @@ export const usePaste = () => {
|
||||
// Look for image paste data
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
return
|
||||
} else if (item.type.startsWith('video/')) {
|
||||
if (!videoNode) {
|
||||
|
||||
@@ -32,6 +32,15 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
props: {
|
||||
onClose: hide,
|
||||
initialCategory
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
content: { class: '!px-0 overflow-hidden h-full !py-0' },
|
||||
root: {
|
||||
style:
|
||||
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
20
src/config/staging.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
const BUILD_TIME_IS_STAGING = !__USE_PROD_CONFIG__
|
||||
|
||||
/**
|
||||
* Returns whether the current environment is staging.
|
||||
* - Cloud builds use runtime configuration (firebase_config.projectId containing '-dev')
|
||||
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
|
||||
*/
|
||||
export const isStaging = computed(() => {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_IS_STAGING
|
||||
}
|
||||
|
||||
const projectId = remoteConfig.value.firebase_config?.projectId
|
||||
return projectId?.includes('-dev') ?? BUILD_TIME_IS_STAGING
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
VramManagement
|
||||
} from '@/types/serverArgs'
|
||||
|
||||
export type ServerConfigValue = string | number | boolean | null | undefined
|
||||
export type ServerConfigValue = string | number | true | null | undefined
|
||||
|
||||
export interface ServerConfig<T> extends FormItem {
|
||||
id: string
|
||||
@@ -19,7 +19,7 @@ export interface ServerConfig<T> extends FormItem {
|
||||
getValue?: (value: T) => Record<string, ServerConfigValue>
|
||||
}
|
||||
|
||||
export const SERVER_CONFIG_ITEMS = [
|
||||
export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
|
||||
// Network settings
|
||||
{
|
||||
id: 'listen',
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
@@ -45,10 +43,6 @@ function setupSubgraph(
|
||||
}
|
||||
|
||||
describe('Subgraph proxyWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('Can add simple widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
@@ -115,11 +115,9 @@ const onConfigure = function (
|
||||
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
|
||||
return [w]
|
||||
})
|
||||
this.widgets = this.widgets.filter((w) => {
|
||||
if (isProxyWidget(w)) return false
|
||||
const widgetName = w.name
|
||||
return !parsed.some(([, name]) => widgetName === name)
|
||||
})
|
||||
this.widgets = this.widgets.filter(
|
||||
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
|
||||
)
|
||||
this.widgets.push(...newWidgets)
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
@@ -154,7 +152,6 @@ function newProxyWidget(
|
||||
computedHeight: undefined,
|
||||
isProxyWidget: true,
|
||||
last_y: undefined,
|
||||
label: name,
|
||||
name,
|
||||
node: subgraphNode,
|
||||
onRemove: undefined,
|
||||
|
||||
@@ -57,20 +57,15 @@ export function demoteWidget(
|
||||
widget.promoted = false
|
||||
}
|
||||
|
||||
function getWidgetName(w: IBaseWidget): string {
|
||||
return isProxyWidget(w) ? w._overlay.widgetName : w.name
|
||||
}
|
||||
|
||||
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
|
||||
return ([n, w]: WidgetItem) =>
|
||||
n.id == nodeId && getWidgetName(w) === widgetName
|
||||
return ([n, w]: WidgetItem) => n.id == nodeId && w.name === widgetName
|
||||
}
|
||||
export function matchesPropertyItem([n, w]: WidgetItem) {
|
||||
return ([nodeId, widgetName]: [string, string]) =>
|
||||
n.id == nodeId && getWidgetName(w) === widgetName
|
||||
n.id == nodeId && w.name === widgetName
|
||||
}
|
||||
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
|
||||
return [`${n.id}`, getWidgetName(w)]
|
||||
return [`${n.id}`, w.name]
|
||||
}
|
||||
|
||||
function getParentNodes(): SubgraphNode[] {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
const toastStore = useToastStore()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
|
||||
const onChangeRestartApp = (newValue: unknown, oldValue: unknown) => {
|
||||
const onChangeRestartApp = (newValue: string, oldValue: string) => {
|
||||
// Add a delay to allow changes to take effect before restarting.
|
||||
if (oldValue !== undefined && newValue !== oldValue) {
|
||||
electronAPI.restartApp('Restart ComfyUI to apply changes.', 1500)
|
||||
|
||||
@@ -1412,7 +1412,7 @@ export class GroupNodeHandler {
|
||||
handlerGroupData.oldToNewWidgetMap[Number(n)]?.[w]
|
||||
const widget = this.widgets.find((wg) => wg.name === widgetName)
|
||||
if (widget) {
|
||||
widget.hidden = true
|
||||
widget.type = 'hidden'
|
||||
widget.computeSize = () => [0, -4]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@ useExtensionService().registerExtension({
|
||||
|
||||
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
||||
this,
|
||||
'preview_markdown',
|
||||
'preview',
|
||||
['MARKDOWN', {}],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
const showValueWidgetPlain = ComfyWidgets['STRING'](
|
||||
this,
|
||||
'preview_text',
|
||||
'preview',
|
||||
['STRING', { multiline: true }],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
@@ -48,7 +48,6 @@ useExtensionService().registerExtension({
|
||||
showValueWidgetPlain.options.hidden = value
|
||||
}
|
||||
|
||||
showValueWidget.label = 'Preview'
|
||||
showValueWidget.hidden = true
|
||||
showValueWidget.options.hidden = true
|
||||
showValueWidget.options.read_only = true
|
||||
@@ -56,7 +55,6 @@ useExtensionService().registerExtension({
|
||||
showValueWidget.element.disabled = true
|
||||
showValueWidget.serialize = false
|
||||
|
||||
showValueWidgetPlain.label = 'Preview'
|
||||
showValueWidgetPlain.hidden = false
|
||||
showValueWidgetPlain.options.hidden = false
|
||||
showValueWidgetPlain.options.read_only = true
|
||||
@@ -73,7 +71,7 @@ useExtensionService().registerExtension({
|
||||
: onExecuted.apply(this, [message])
|
||||
|
||||
const previewWidgets =
|
||||
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
|
||||
this.widgets?.filter((w) => w.name === 'preview') ?? []
|
||||
|
||||
for (const previewWidget of previewWidgets) {
|
||||
const text = message.text ?? ''
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { app } from '../../scripts/app'
|
||||
@@ -6,17 +5,10 @@ import { ComfyWidgets } from '../../scripts/widgets'
|
||||
|
||||
// Adds defaults for quickly adding nodes with middle click on the input/output
|
||||
|
||||
interface SlotDefaultsExtension extends ComfyExtension {
|
||||
suggestionsNumber: { value: number } | null
|
||||
slot_types_default_out: Record<string, string[]>
|
||||
slot_types_default_in: Record<string, string[]>
|
||||
setDefaults(maxNum?: number | null): void
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.SlotDefaults',
|
||||
suggestionsNumber: null,
|
||||
init(this: SlotDefaultsExtension) {
|
||||
init() {
|
||||
LiteGraph.search_filter_enabled = true
|
||||
LiteGraph.middle_click_slot_add_default_node = true
|
||||
this.suggestionsNumber = app.ui.settings.addSetting({
|
||||
@@ -32,13 +24,13 @@ app.registerExtension({
|
||||
},
|
||||
defaultValue: 5,
|
||||
onChange: (newVal) => {
|
||||
this.setDefaults(newVal as number)
|
||||
this.setDefaults(newVal)
|
||||
}
|
||||
})
|
||||
},
|
||||
slot_types_default_out: {},
|
||||
slot_types_default_in: {},
|
||||
async beforeRegisterNodeDef(this: SlotDefaultsExtension, nodeType, nodeData) {
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
var nodeId = nodeData.name
|
||||
const inputs = nodeData['input']?.['required'] //only show required inputs to reduce the mess also not logical to create node with optional inputs
|
||||
for (const inputKey in inputs) {
|
||||
@@ -91,23 +83,22 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
var maxNum = this.suggestionsNumber?.value
|
||||
var maxNum = this.suggestionsNumber.value
|
||||
this.setDefaults(maxNum)
|
||||
},
|
||||
setDefaults(this: SlotDefaultsExtension, maxNum?: number | null) {
|
||||
setDefaults(maxNum?: number | null) {
|
||||
LiteGraph.slot_types_default_out = {}
|
||||
LiteGraph.slot_types_default_in = {}
|
||||
|
||||
const max = maxNum ?? undefined
|
||||
for (const type in this.slot_types_default_out) {
|
||||
LiteGraph.slot_types_default_out[type] = this.slot_types_default_out[
|
||||
type
|
||||
].slice(0, max)
|
||||
].slice(0, maxNum)
|
||||
}
|
||||
for (const type in this.slot_types_default_in) {
|
||||
LiteGraph.slot_types_default_in[type] = this.slot_types_default_in[
|
||||
type
|
||||
].slice(0, max)
|
||||
].slice(0, maxNum)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { api } from '../../scripts/api'
|
||||
import { app } from '../../scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
function updateUIWidget(
|
||||
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
||||
@@ -71,7 +70,6 @@ async function uploadFile(
|
||||
api.apiURL(getResourceURL(...splitFilePath(path)))
|
||||
)
|
||||
|
||||
audioWidget.value = path
|
||||
// Manually trigger the callback to update VueNodes
|
||||
audioWidget.callback?.(path)
|
||||
}
|
||||
@@ -139,16 +137,9 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
audioUIWidget.options.getValue = () =>
|
||||
(useWidgetValueStore().getWidget(node.id, inputName)
|
||||
?.value as string) ?? ''
|
||||
audioUIWidget.options.setValue = (v) => {
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
node.id,
|
||||
inputName
|
||||
)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
let value = ''
|
||||
audioUIWidget.options.getValue = () => value
|
||||
audioUIWidget.options.setValue = (v) => (value = v)
|
||||
|
||||
return { widget: audioUIWidget }
|
||||
}
|
||||
|
||||
@@ -48,17 +48,6 @@ describe('LGraph', () => {
|
||||
|
||||
expect(result1).toEqual(result2)
|
||||
})
|
||||
|
||||
it('should handle adding null node gracefully', () => {
|
||||
const graph = new LGraph()
|
||||
const initialNodeCount = graph.nodes.length
|
||||
|
||||
const result = graph.add(null)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(graph.nodes.length).toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('can be instantiated', ({ expect }) => {
|
||||
// @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised
|
||||
const graph = new LGraph({ extra: 'TestGraph' })
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -90,13 +89,12 @@ export interface LGraphState {
|
||||
lastRerouteId: number
|
||||
}
|
||||
|
||||
type ParamsArray<T, K extends MethodNames<T>> = Parameters<
|
||||
Extract<T[K], (...args: never[]) => unknown>
|
||||
>[1] extends undefined
|
||||
?
|
||||
| Parameters<Extract<T[K], (...args: never[]) => unknown>>
|
||||
| Parameters<Extract<T[K], (...args: never[]) => unknown>>[0]
|
||||
: Parameters<Extract<T[K], (...args: never[]) => unknown>>
|
||||
type ParamsArray<
|
||||
T extends Record<any, any>,
|
||||
K extends MethodNames<T>
|
||||
> = Parameters<T[K]>[1] extends undefined
|
||||
? Parameters<T[K]> | Parameters<T[K]>[0]
|
||||
: Parameters<T[K]>
|
||||
|
||||
/** Configuration used by {@link LGraph} `config`. */
|
||||
export interface LGraphConfig {
|
||||
@@ -160,6 +158,7 @@ export class LGraph
|
||||
|
||||
static STATUS_STOPPED = 1
|
||||
static STATUS_RUNNING = 2
|
||||
static deduplicateSubgraphIds = false
|
||||
|
||||
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
||||
static readonly ConfigureProperties = new Set([
|
||||
@@ -776,10 +775,8 @@ export class LGraph
|
||||
let max_size = 100
|
||||
let y = margin + LiteGraph.NODE_TITLE_HEIGHT
|
||||
for (const node of column) {
|
||||
node.setPos(
|
||||
layout == LiteGraph.VERTICAL_LAYOUT ? y : x,
|
||||
layout == LiteGraph.VERTICAL_LAYOUT ? x : y
|
||||
)
|
||||
node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x
|
||||
node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y
|
||||
const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0
|
||||
if (node.size[max_size_index] > max_size) {
|
||||
max_size = node.size[max_size_index]
|
||||
@@ -898,7 +895,7 @@ export class LGraph
|
||||
* @deprecated Use options object instead
|
||||
*/
|
||||
add(
|
||||
node: LGraphNode | LGraphGroup | null,
|
||||
node: LGraphNode | LGraphGroup,
|
||||
skipComputeOrder?: boolean
|
||||
): LGraphNode | null | undefined
|
||||
add(
|
||||
@@ -965,14 +962,6 @@ export class LGraph
|
||||
node.flags.ghost = true
|
||||
}
|
||||
|
||||
// Register all widgets with the WidgetValueStore now that node has a valid ID.
|
||||
// Widgets added before the node was in the graph deferred their setNodeId call.
|
||||
if (node.widgets) {
|
||||
for (const widget of node.widgets) {
|
||||
if (isNodeBindable(widget)) widget.setNodeId(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
node.graph = this
|
||||
this._version++
|
||||
|
||||
@@ -1761,10 +1750,7 @@ export class LGraph
|
||||
)
|
||||
|
||||
//Correct for title height. It's included in bounding box, but not _posSize
|
||||
subgraphNode.setPos(
|
||||
subgraphNode.pos[0],
|
||||
subgraphNode.pos[1] + LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
)
|
||||
subgraphNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
|
||||
// Add the subgraph node to the graph
|
||||
this.add(subgraphNode)
|
||||
@@ -1931,7 +1917,8 @@ export class LGraph
|
||||
|
||||
this.add(node, true)
|
||||
node.configure(n_info)
|
||||
node.setPos(node.pos[0] + offsetX, node.pos[1] + offsetY)
|
||||
node.pos[0] += offsetX
|
||||
node.pos[1] += offsetY
|
||||
for (const input of node.inputs) {
|
||||
input.link = null
|
||||
}
|
||||
@@ -2450,7 +2437,7 @@ export class LGraph
|
||||
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
||||
}
|
||||
|
||||
if (this.isRootGraph) {
|
||||
if (this.isRootGraph && LGraph.deduplicateSubgraphIds) {
|
||||
const reservedNodeIds = nodesData
|
||||
?.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
@@ -13,9 +13,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
batchUpdateNodeBounds: vi.fn()
|
||||
getSlotLayout: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
@@ -2394,7 +2396,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const cloned = items?.created[0] as LGraphNode | undefined
|
||||
if (!cloned) return
|
||||
|
||||
cloned.setPos(cloned.pos[0] + 5, cloned.pos[1] + 5)
|
||||
cloned.pos[0] += 5
|
||||
cloned.pos[1] += 5
|
||||
|
||||
if (this.allow_dragnodes) {
|
||||
pointer.onDragStart = (pointer) => {
|
||||
@@ -3578,14 +3581,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (dragEvent) {
|
||||
this.adjustMouseEvent(dragEvent)
|
||||
const e = dragEvent as CanvasPointerEvent
|
||||
node.setPos(e.canvasX - node.size[0] / 2, e.canvasY + 10)
|
||||
node.pos[0] = e.canvasX - node.size[0] / 2
|
||||
node.pos[1] = e.canvasY + 10
|
||||
// Update last_mouse to prevent jump on first drag move
|
||||
this.last_mouse = [e.clientX, e.clientY]
|
||||
} else {
|
||||
node.setPos(
|
||||
this.graph_mouse[0] - node.size[0] / 2,
|
||||
this.graph_mouse[1] + 10
|
||||
)
|
||||
node.pos[0] = this.graph_mouse[0] - node.size[0] / 2
|
||||
node.pos[1] = this.graph_mouse[1] + 10
|
||||
}
|
||||
|
||||
// Sync position to layout store for Vue node rendering
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
const mutations = this.initLayoutMutations()
|
||||
mutations.moveNode(node.id, { x: node.pos[0], y: node.pos[1] })
|
||||
}
|
||||
|
||||
this.state.ghostNodeId = node.id
|
||||
@@ -4154,30 +4162,31 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust positions - use move/setPos to ensure layout store is updated
|
||||
const dx = position[0] - offsetX
|
||||
const dy = position[1] - offsetY
|
||||
// Adjust positions
|
||||
for (const item of created) {
|
||||
if (item instanceof LGraphNode) {
|
||||
item.setPos(item.pos[0] + dx, item.pos[1] + dy)
|
||||
} else if (item instanceof Reroute) {
|
||||
item.move(dx, dy)
|
||||
}
|
||||
item.pos[0] += position[0] - offsetX
|
||||
item.pos[1] += position[1] - offsetY
|
||||
}
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created
|
||||
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
||||
.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: node.size?.[1] ?? 200
|
||||
.map((node) => {
|
||||
const fullHeight = node.size?.[1] ?? 200
|
||||
const layoutHeight = LiteGraph.vueNodesMode
|
||||
? removeNodeTitleHeight(fullHeight)
|
||||
: fullHeight
|
||||
return {
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: layoutHeight
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
@@ -5051,7 +5060,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
octx.save()
|
||||
|
||||
const scale = overlayCanvas.width / (overlayCanvas.clientWidth || 1)
|
||||
const scale = window.devicePixelRatio
|
||||
octx.setTransform(scale, 0, 0, scale, 0, 0)
|
||||
|
||||
this.ds.toCanvasContext(octx)
|
||||
@@ -6398,7 +6407,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
options
|
||||
)
|
||||
) {
|
||||
node.setPos(node.pos[0] - node.size[0] * 0.5, node.pos[1])
|
||||
node.pos[0] -= node.size[0] * 0.5
|
||||
}
|
||||
})
|
||||
break
|
||||
@@ -8338,7 +8347,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
callback: LGraphCanvas.onMenuNodeCollapse
|
||||
})
|
||||
}
|
||||
if (node.hasAdvancedWidgets()) {
|
||||
if (node.widgets?.some((w) => w.advanced)) {
|
||||
options.push({
|
||||
content: node.showAdvanced ? 'Hide Advanced' : 'Show Advanced',
|
||||
callback: LGraphCanvas.onMenuToggleAdvanced
|
||||
@@ -8686,14 +8695,27 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* Apply batched node position updates
|
||||
*/
|
||||
private applyNodePositionUpdates(
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>,
|
||||
mutations: ReturnType<typeof useLayoutMutations>
|
||||
): void {
|
||||
for (const { node, newPos } of nodesToMove) {
|
||||
// setPos automatically syncs to layout store
|
||||
node.setPos(newPos.x, newPos.y)
|
||||
// Update LiteGraph position first so next drag uses correct base position
|
||||
node.pos[0] = newPos.x
|
||||
node.pos[1] = newPos.y
|
||||
// Then update layout store which will update Vue nodes
|
||||
mutations.moveNode(node.id, newPos)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize layout mutations with Canvas source
|
||||
*/
|
||||
private initLayoutMutations(): ReturnType<typeof useLayoutMutations> {
|
||||
const mutations = useLayoutMutations()
|
||||
mutations.setSource(LayoutSource.Canvas)
|
||||
return mutations
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all nodes that are children of groups in the selection
|
||||
*/
|
||||
@@ -8741,6 +8763,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
) {
|
||||
const mutations = this.initLayoutMutations()
|
||||
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
|
||||
const nodesToMove: NewNodePosition[] = []
|
||||
|
||||
@@ -8766,11 +8789,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
// Now apply all the node moves at once
|
||||
this.applyNodePositionUpdates(nodesToMove)
|
||||
this.applyNodePositionUpdates(nodesToMove, mutations)
|
||||
}
|
||||
|
||||
repositionNodesVueMode(nodesToReposition: NewNodePosition[]) {
|
||||
this.applyNodePositionUpdates(nodesToReposition)
|
||||
const mutations = this.initLayoutMutations()
|
||||
this.applyNodePositionUpdates(nodesToReposition, mutations)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
@@ -44,7 +42,6 @@ describe('LGraphNode', () => {
|
||||
let origLiteGraph: typeof LiteGraph
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
origLiteGraph = Object.assign({}, LiteGraph)
|
||||
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
|
||||
delete origLiteGraph.Classes
|
||||
|
||||
@@ -10,11 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
commonType,
|
||||
isNodeBindable,
|
||||
toClass
|
||||
} from '@/lib/litegraph/src/utils/type'
|
||||
import { commonType, toClass } from '@/lib/litegraph/src/utils/type'
|
||||
|
||||
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type { DragAndScale } from './DragAndScale'
|
||||
@@ -490,17 +486,6 @@ export class LGraphNode
|
||||
|
||||
this._pos[0] = value[0]
|
||||
this._pos[1] = value[1]
|
||||
|
||||
const mutations = useLayoutMutations()
|
||||
mutations.setSource(LayoutSource.Canvas)
|
||||
mutations.moveNode(String(this.id), { x: value[0], y: value[1] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the node position to an absolute location.
|
||||
*/
|
||||
setPos(x: number, y: number): void {
|
||||
this.pos = [x, y]
|
||||
}
|
||||
|
||||
public get size() {
|
||||
@@ -512,13 +497,6 @@ export class LGraphNode
|
||||
|
||||
this._size[0] = value[0]
|
||||
this._size[1] = value[1]
|
||||
|
||||
const mutations = useLayoutMutations()
|
||||
mutations.setSource(LayoutSource.Canvas)
|
||||
mutations.resizeNode(String(this.id), {
|
||||
width: value[0],
|
||||
height: value[1]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1979,14 +1957,6 @@ export class LGraphNode
|
||||
this.widgets ||= []
|
||||
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
|
||||
this.widgets.push(widget)
|
||||
|
||||
// Only register with store if node has a valid ID (is already in a graph).
|
||||
// If the node isn't in a graph yet (id === -1), registration happens
|
||||
// when the node is added via LGraph.add() -> node.onAdded.
|
||||
if (this.id !== -1 && isNodeBindable(widget)) {
|
||||
widget.setNodeId(this.id)
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
@@ -2050,7 +2020,8 @@ export class LGraphNode
|
||||
return
|
||||
}
|
||||
|
||||
this.pos = [this._pos[0] + deltaX, this._pos[1] + deltaY]
|
||||
this.pos[0] += deltaX
|
||||
this.pos[1] += deltaY
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3528,7 +3499,7 @@ export class LGraphNode
|
||||
* Toggles advanced mode of the node, showing advanced widgets
|
||||
*/
|
||||
toggleAdvanced() {
|
||||
if (!this.hasAdvancedWidgets()) return
|
||||
if (!this.widgets?.some((w) => w.advanced)) return
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
this.graph._version++
|
||||
this.showAdvanced = !this.showAdvanced
|
||||
@@ -3906,21 +3877,6 @@ export class LGraphNode
|
||||
return !isHidden
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all widgets that should participate in layout calculations.
|
||||
* Filters out hidden widgets only (not collapsed/advanced).
|
||||
*/
|
||||
getLayoutWidgets(): IBaseWidget[] {
|
||||
return this.widgets?.filter((w) => !w.hidden) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the node has any advanced widgets.
|
||||
*/
|
||||
hasAdvancedWidgets(): boolean {
|
||||
return this.widgets?.some((w) => w.advanced) ?? false
|
||||
}
|
||||
|
||||
updateComputedDisabled() {
|
||||
if (!this.widgets) return
|
||||
for (const widget of this.widgets)
|
||||
@@ -4131,7 +4087,7 @@ export class LGraphNode
|
||||
w: IBaseWidget
|
||||
}[] = []
|
||||
|
||||
const visibleWidgets = this.getLayoutWidgets()
|
||||
const visibleWidgets = this.widgets.filter((w) => !w.hidden)
|
||||
|
||||
for (const w of visibleWidgets) {
|
||||
if (w.computeSize) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -10,7 +8,6 @@ describe('LGraphNode widget ordering', () => {
|
||||
let node: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
node = new LGraphNode('TestNode')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
|
||||
import type {
|
||||
CanvasPointer,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
NodeId
|
||||
} from '../litegraph'
|
||||
import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph'
|
||||
import type { CanvasPointerEvent } from './events'
|
||||
|
||||
export interface NodeBindable {
|
||||
setNodeId(nodeId: NodeId): void
|
||||
}
|
||||
|
||||
export interface IWidgetOptions<TValues = unknown> {
|
||||
export interface IWidgetOptions<TValues = unknown[]> {
|
||||
on?: string
|
||||
off?: string
|
||||
max?: number
|
||||
@@ -310,7 +301,7 @@ export type TWidgetValue = IWidget['value']
|
||||
export interface IBaseWidget<
|
||||
TValue = boolean | number | string | object | undefined,
|
||||
TType extends string = string,
|
||||
TOptions extends IWidgetOptions = IWidgetOptions
|
||||
TOptions extends IWidgetOptions<unknown> = IWidgetOptions<unknown>
|
||||
> {
|
||||
[symbol: symbol]: boolean
|
||||
|
||||
|
||||
@@ -138,7 +138,8 @@ export function alignNodes(
|
||||
})
|
||||
|
||||
for (const { node, newPos } of nodePositions) {
|
||||
node.setPos(newPos.x, newPos.y)
|
||||
node.pos[0] = newPos.x
|
||||
node.pos[1] = newPos.y
|
||||
}
|
||||
return nodePositions
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { without } from 'es-toolkit'
|
||||
|
||||
import type { IColorable, ISlotType } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeBindable } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Converts a plain object to a class instance if it is not already an instance of the class.
|
||||
@@ -30,15 +29,6 @@ export function isColorable(obj: unknown): obj is IColorable {
|
||||
)
|
||||
}
|
||||
|
||||
export function isNodeBindable(widget: unknown): widget is NodeBindable {
|
||||
return (
|
||||
typeof widget === 'object' &&
|
||||
widget !== null &&
|
||||
'setNodeId' in widget &&
|
||||
typeof widget.setNodeId === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export function commonType(...types: ISlotType[]): ISlotType | undefined {
|
||||
if (!isStrings(types)) return undefined
|
||||
|
||||
|
||||