Compare commits
34 Commits
cloud/1.39
...
feat/gradi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1583c15637 | ||
|
|
78635294ce | ||
|
|
2f09c6321e | ||
|
|
233f240503 | ||
|
|
38edba7024 | ||
|
|
f851c3189f | ||
|
|
71d26eb4d9 | ||
|
|
d04dd32235 | ||
|
|
c52f48af45 | ||
|
|
01cf3244b8 | ||
|
|
0f33444eef | ||
|
|
44ce9379eb | ||
|
|
138fa6a2ce | ||
|
|
ce9d0ca670 | ||
|
|
6cf0357b3e | ||
|
|
c0c81dba49 | ||
|
|
553ea63357 | ||
|
|
995ebc4ba4 | ||
|
|
d282353370 | ||
|
|
85ae0a57c3 | ||
|
|
0d64d503ec | ||
|
|
30ef6f2b8c | ||
|
|
6012341fd1 | ||
|
|
a80f6d7922 | ||
|
|
0f5aca6726 | ||
|
|
4fc1d2ef5b | ||
|
|
92b7437d86 | ||
|
|
dd1fefe843 | ||
|
|
adcb663b3e | ||
|
|
28b171168a | ||
|
|
69062c6da1 | ||
|
|
a7c2115166 | ||
|
|
d044bed9b2 | ||
|
|
d873c8048f |
@@ -96,6 +96,7 @@
|
||||
"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,12 +98,10 @@ const config: StorybookConfig = {
|
||||
},
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
experimental: {
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
treeshake: false,
|
||||
output: {
|
||||
keepNames: true
|
||||
keepNames: true,
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
{
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "T2IAdapterLoader",
|
||||
"pos": [100, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONTROL_NET",
|
||||
"type": "CONTROL_NET",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "T2IAdapterLoader"
|
||||
},
|
||||
"widgets_values": ["t2iadapter_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 300],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ResizeImagesByLongerEdge",
|
||||
"pos": [500, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ResizeImagesByLongerEdge"
|
||||
},
|
||||
"widgets_values": [1024]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "ImageScaleBy",
|
||||
"pos": [500, 280],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2, 3],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageScaleBy"
|
||||
},
|
||||
"widgets_values": ["lanczos", 1.5]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "ImageBatch",
|
||||
"pos": [900, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "image2",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [4],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageBatch"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SaveImage",
|
||||
"pos": [900, 300],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1250, 100],
|
||||
"size": [300, 250],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 3, 0, 4, 0, "IMAGE"],
|
||||
[2, 4, 0, 5, 0, "IMAGE"],
|
||||
[3, 4, 0, 6, 0, "IMAGE"],
|
||||
[4, 5, 0, 7, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
{
|
||||
"last_node_id": 5,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Load3DAnimation",
|
||||
"pos": [100, 100],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MESH",
|
||||
"type": "MESH",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Load3DAnimation"
|
||||
},
|
||||
"widgets_values": ["model.glb"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Preview3DAnimation",
|
||||
"pos": [450, 100],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "mesh",
|
||||
"type": "MESH",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Preview3DAnimation"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ConditioningAverage ",
|
||||
"pos": [100, 300],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "conditioning_to",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "conditioning_from",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ConditioningAverage "
|
||||
},
|
||||
"widgets_values": [1]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "SDV_img2vid_Conditioning",
|
||||
"pos": [450, 300],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip_vision",
|
||||
"type": "CLIP_VISION",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "init_image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"links": [2],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SDV_img2vid_Conditioning"
|
||||
},
|
||||
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [800, 300],
|
||||
"size": [300, 262],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 3, 0, 5, 1, "CONDITIONING"],
|
||||
[2, 4, 2, 5, 3, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -23,9 +23,7 @@ export class SettingDialog extends BaseDialog {
|
||||
* @param value - The value to set
|
||||
*/
|
||||
async setStringSetting(id: string, value: string) {
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||
await settingInputDiv.locator('input').fill(value)
|
||||
}
|
||||
|
||||
@@ -34,16 +32,31 @@ export class SettingDialog extends BaseDialog {
|
||||
* @param id - The id of the setting
|
||||
*/
|
||||
async toggleBooleanSetting(id: string) {
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
const settingInputDiv = this.root.locator(`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() {
|
||||
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
|
||||
await this.page
|
||||
.getByTestId(TestIds.dialogs.about)
|
||||
.waitFor({ state: 'visible' })
|
||||
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||
name: 'About'
|
||||
})
|
||||
await aboutButton.click()
|
||||
await this.page.waitForSelector('.about-container')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,9 +226,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
|
||||
await bottomPanel.shortcuts.manageButton.click()
|
||||
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: 'Keybinding' })
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,9 +244,13 @@ test.describe('Missing models warning', () => {
|
||||
test.describe('Settings', () => {
|
||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsContent = comfyPage.page.locator('.settings-content')
|
||||
await expect(settingsContent).toBeVisible()
|
||||
const isUsableHeight = await settingsContent.evaluate(
|
||||
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(
|
||||
(el) => el.clientHeight > 30
|
||||
)
|
||||
expect(isUsableHeight).toBeTruthy()
|
||||
@@ -256,7 +260,9 @@ 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('.settings-container')
|
||||
const settingsLocator = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await expect(settingsLocator).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(settingsLocator).not.toBeVisible()
|
||||
@@ -275,10 +281,15 @@ test.describe('Settings', () => {
|
||||
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
||||
// Open the settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('.settings-container')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
|
||||
|
||||
// Open the keybinding tab
|
||||
await comfyPage.page.getByLabel('Keybinding').click()
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await settingsDialog
|
||||
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
||||
.click()
|
||||
await comfyPage.page.waitForSelector(
|
||||
'[placeholder="Search Keybindings..."]'
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 99 KiB |
@@ -5,13 +5,6 @@ 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,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -820,7 +826,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('.settings-container', {
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
@@ -830,7 +836,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('.settings-container')
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 115 KiB |
@@ -22,7 +22,6 @@ 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',
|
||||
@@ -30,7 +29,6 @@ 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',
|
||||
@@ -39,7 +37,6 @@ 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',
|
||||
@@ -52,238 +49,143 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
})
|
||||
|
||||
test('can open settings dialog and use search box', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// 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(
|
||||
await expect(dialog.searchBox).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search')
|
||||
)
|
||||
})
|
||||
|
||||
test('search box is functional and accepts input', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// 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')
|
||||
await dialog.searchBox.fill('Comfy')
|
||||
await expect(dialog.searchBox).toHaveValue('Comfy')
|
||||
})
|
||||
|
||||
test('search box clears properly', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// 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.fill('test')
|
||||
await expect(dialog.searchBox).toHaveValue('test')
|
||||
|
||||
// Clear the search box
|
||||
await searchBox.clear()
|
||||
await expect(searchBox).toHaveValue('')
|
||||
await dialog.searchBox.clear()
|
||||
await expect(dialog.searchBox).toHaveValue('')
|
||||
})
|
||||
|
||||
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// 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()
|
||||
expect(await dialog.categories.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('can select different categories in sidebar', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Click on a specific category (Appearance) to verify category switching
|
||||
const appearanceCategory = comfyPage.page.getByRole('option', {
|
||||
name: 'Appearance'
|
||||
})
|
||||
await appearanceCategory.click()
|
||||
const categoryCount = await dialog.categories.count()
|
||||
|
||||
// Verify the category is selected
|
||||
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
|
||||
})
|
||||
if (categoryCount > 1) {
|
||||
await dialog.categories.nth(1).click()
|
||||
|
||||
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()
|
||||
await expect(dialog.categories.nth(1)).toHaveClass(
|
||||
/bg-interface-menu-component-surface-selected/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('search functionality affects UI state', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// 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')
|
||||
await dialog.searchBox.fill('graph')
|
||||
await expect(dialog.searchBox).toHaveValue('graph')
|
||||
})
|
||||
|
||||
test('settings dialog can be closed', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Close with escape key
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(settingsDialog).not.toBeVisible()
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// 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 dialog.searchBox.fill('a')
|
||||
await dialog.searchBox.fill('ab')
|
||||
await dialog.searchBox.fill('abc')
|
||||
await dialog.searchBox.fill('abcd')
|
||||
|
||||
// Verify final value
|
||||
await expect(searchBox).toHaveValue('abcd')
|
||||
await expect(dialog.searchBox).toHaveValue('abcd')
|
||||
})
|
||||
|
||||
test('search excludes hidden settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// 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')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
})
|
||||
|
||||
test('search excludes deprecated settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// 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')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
})
|
||||
|
||||
test('search shows visible settings but excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// 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')
|
||||
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'
|
||||
)
|
||||
})
|
||||
|
||||
test('search by setting name excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
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('Hidden')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
|
||||
// Search specifically for hidden setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Hidden')
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Deprecated')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated 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')
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Visible')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 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: 62 KiB After Width: | Height: | Size: 65 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: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 80 KiB |
25
global.d.ts
vendored
@@ -10,28 +10,9 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
client_id: string | number | undefined
|
||||
session_id: string | number | undefined
|
||||
session_number: string | number | undefined
|
||||
}
|
||||
|
||||
interface GtagFunction {
|
||||
<TField extends GtagGetFieldName>(
|
||||
command: 'get',
|
||||
targetId: string,
|
||||
fieldName: TField,
|
||||
callback: (value: GtagGetFieldValueMap[TField]) => void
|
||||
): void
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
ga_measurement_id?: string
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
@@ -55,8 +36,12 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
__ga_identity__?: {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.39.12",
|
||||
"version": "1.40.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "^8.0.0-beta.8"
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/design-system/src/icons/comfy-c.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
||||
|
After 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.8
|
||||
vite: 8.0.0-beta.13
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
|
||||
@@ -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 } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -19,7 +19,11 @@ 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 }))
|
||||
const mockData = vi.hoisted(() => ({
|
||||
isLoggedIn: false,
|
||||
isDesktop: false,
|
||||
setShowConflictRedDot: (_value: boolean) => {}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => {
|
||||
@@ -36,6 +40,36 @@ 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,
|
||||
@@ -114,6 +148,7 @@ describe('TopMenuSection', () => {
|
||||
localStorage.clear()
|
||||
mockData.isDesktop = false
|
||||
mockData.isLoggedIn = false
|
||||
mockData.setShowConflictRedDot(false)
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
@@ -330,4 +365,16 @@ 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,7 +145,6 @@ 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'
|
||||
@@ -173,8 +172,6 @@ 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)
|
||||
@@ -236,10 +233,8 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
}
|
||||
])
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
return 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 { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isShortcutsTabActive = computed(() => {
|
||||
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
|
||||
}
|
||||
|
||||
const openKeybindingSettings = async () => {
|
||||
dialogService.showSettingsDialog('keybinding')
|
||||
settingsDialog.show('keybinding')
|
||||
}
|
||||
|
||||
const closeBottomPanel = () => {
|
||||
|
||||
70
src/components/colorcorrect/GradientSlider.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import GradientSlider from './GradientSlider.vue'
|
||||
import type { ColorStop } from './gradients'
|
||||
import { interpolateStops } from './gradients'
|
||||
|
||||
const TEST_STOPS: ColorStop[] = [
|
||||
[0, 0, 0, 0],
|
||||
[1, 255, 255, 255]
|
||||
]
|
||||
|
||||
function mountSlider(props: {
|
||||
stops?: ColorStop[]
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}) {
|
||||
return mount(GradientSlider, {
|
||||
props: { stops: TEST_STOPS, ...props }
|
||||
})
|
||||
}
|
||||
|
||||
describe('GradientSlider', () => {
|
||||
it('passes min, max, step to SliderRoot', () => {
|
||||
const wrapper = mountSlider({
|
||||
modelValue: 50,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 5
|
||||
})
|
||||
const thumb = wrapper.find('[role="slider"]')
|
||||
expect(thumb.attributes('aria-valuemin')).toBe('-100')
|
||||
expect(thumb.attributes('aria-valuemax')).toBe('100')
|
||||
})
|
||||
|
||||
it('renders slider root with track and thumb', () => {
|
||||
const wrapper = mountSlider({ modelValue: 0 })
|
||||
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render SliderRange', () => {
|
||||
const wrapper = mountSlider({ modelValue: 50 })
|
||||
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interpolateStops', () => {
|
||||
it('returns start color at t=0', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 0)).toBe('rgb(0,0,0)')
|
||||
})
|
||||
|
||||
it('returns end color at t=1', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 1)).toBe('rgb(255,255,255)')
|
||||
})
|
||||
|
||||
it('returns midpoint color at t=0.5', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 0.5)).toBe('rgb(128,128,128)')
|
||||
})
|
||||
|
||||
it('clamps values below 0', () => {
|
||||
expect(interpolateStops(TEST_STOPS, -1)).toBe('rgb(0,0,0)')
|
||||
})
|
||||
|
||||
it('clamps values above 1', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 2)).toBe('rgb(255,255,255)')
|
||||
})
|
||||
})
|
||||
87
src/components/colorcorrect/GradientSlider.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { SliderRoot, SliderThumb, SliderTrack } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ColorStop } from '@/components/colorcorrect/gradients'
|
||||
import {
|
||||
interpolateStops,
|
||||
stopsToGradient
|
||||
} from '@/components/colorcorrect/gradients'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
stops,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
stops: ColorStop[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const sliderValue = computed({
|
||||
get: () => [modelValue.value],
|
||||
set: (v: number[]) => {
|
||||
if (v.length) modelValue.value = v[0]
|
||||
}
|
||||
})
|
||||
|
||||
const gradient = computed(() => stopsToGradient(stops))
|
||||
|
||||
const thumbColor = computed(() => {
|
||||
const t = max === min ? 0 : (modelValue.value - min) / (max - min)
|
||||
return interpolateStops(stops, t)
|
||||
})
|
||||
|
||||
const pressed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SliderRoot
|
||||
v-model="sliderValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full touch-none items-center select-none',
|
||||
'data-[disabled]:opacity-50'
|
||||
)
|
||||
"
|
||||
:style="{ '--reka-slider-thumb-transform': 'translate(-50%, -50%)' }"
|
||||
@slide-start="pressed = true"
|
||||
@slide-move="pressed = true"
|
||||
@slide-end="pressed = false"
|
||||
>
|
||||
<SliderTrack
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2.5 w-full grow cursor-pointer overflow-visible rounded-full',
|
||||
'before:absolute before:-inset-2 before:block before:bg-transparent'
|
||||
)
|
||||
"
|
||||
:style="{ background: gradient }"
|
||||
>
|
||||
<SliderThumb
|
||||
:class="
|
||||
cn(
|
||||
'block size-4 shrink-0 cursor-grab rounded-full shadow-md ring-1 ring-black/25',
|
||||
'transition-[color,box-shadow,background-color]',
|
||||
'before:absolute before:-inset-1.5 before:block before:rounded-full before:bg-transparent',
|
||||
'hover:ring-2 hover:ring-black/40 focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-hidden',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed }
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: thumbColor, top: '50%' }"
|
||||
/>
|
||||
</SliderTrack>
|
||||
</SliderRoot>
|
||||
</template>
|
||||
37
src/components/colorcorrect/gradients.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type ColorStop = readonly [
|
||||
offset: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
]
|
||||
|
||||
export function stopsToGradient(stops: ColorStop[]): string {
|
||||
const colors = stops.map(
|
||||
([offset, r, g, b]) => `rgb(${r},${g},${b}) ${offset * 100}%`
|
||||
)
|
||||
return `linear-gradient(to right, ${colors.join(', ')})`
|
||||
}
|
||||
|
||||
export function interpolateStops(stops: ColorStop[], t: number): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
|
||||
if (clamped <= stops[0][0]) {
|
||||
const [, r, g, b] = stops[0]
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const [o1, r1, g1, b1] = stops[i]
|
||||
const [o2, r2, g2, b2] = stops[i + 1]
|
||||
if (clamped >= o1 && clamped <= o2) {
|
||||
const f = o2 === o1 ? 0 : (clamped - o1) / (o2 - o1)
|
||||
const r = Math.round(r1 + (r2 - r1) * f)
|
||||
const g = Math.round(g1 + (g2 - g1) * f)
|
||||
const b = Math.round(b1 + (b2 - b1) * f)
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
}
|
||||
|
||||
const [, r, g, b] = stops[stops.length - 1]
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
@@ -12,9 +12,9 @@
|
||||
nodeContent: ({ context }) => ({
|
||||
class: 'group/tree-node',
|
||||
onClick: (e: MouseEvent) =>
|
||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
|
||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode<T>),
|
||||
onContextmenu: (e: MouseEvent) =>
|
||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
|
||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode<T>)
|
||||
}),
|
||||
nodeToggleButton: () => ({
|
||||
onClick: (e: MouseEvent) => {
|
||||
@@ -36,15 +36,11 @@
|
||||
</Tree>
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import Tree from 'primevue/tree'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { computed, provide, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
@@ -60,6 +56,10 @@ 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
|
||||
root: TreeExplorerNode<T>
|
||||
class?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
|
||||
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
|
||||
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
|
||||
(e: 'nodeClick', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
|
||||
(e: 'nodeDelete', node: RenderedTreeExplorerNode<T>): void
|
||||
(e: 'contextMenu', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
@@ -83,19 +83,19 @@ const {
|
||||
getAddFolderMenuItem,
|
||||
handleFolderCreation,
|
||||
addFolderCommand
|
||||
} = useTreeFolderOperations(
|
||||
/* expandNode */ (node: TreeExplorerNode) => {
|
||||
} = useTreeFolderOperations<T>(
|
||||
/* expandNode */ (node: TreeExplorerNode<T>) => {
|
||||
expandedKeys.value[node.key] = true
|
||||
}
|
||||
)
|
||||
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode<T>>(() => {
|
||||
const renderedRoot = fillNodeInfo(props.root)
|
||||
return newFolderNode.value
|
||||
? combineTrees(renderedRoot, newFolderNode.value)
|
||||
: renderedRoot
|
||||
})
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
|
||||
if (node.getIcon) {
|
||||
const icon = node.getIcon()
|
||||
if (icon) {
|
||||
@@ -111,7 +111,9 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
|
||||
}
|
||||
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
||||
const fillNodeInfo = (
|
||||
node: TreeExplorerNode<T>
|
||||
): RenderedTreeExplorerNode<T> => {
|
||||
const children = node.children?.map(fillNodeInfo) ?? []
|
||||
const totalLeaves = node.leaf
|
||||
? 1
|
||||
@@ -128,7 +130,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
||||
}
|
||||
const onNodeContentClick = async (
|
||||
e: MouseEvent,
|
||||
node: RenderedTreeExplorerNode
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
) => {
|
||||
if (!storeSelectionKeys) {
|
||||
selectionKeys.value = {}
|
||||
@@ -139,20 +141,22 @@ const onNodeContentClick = async (
|
||||
emit('nodeClick', node, e)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const menuTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||
const extraMenuItems = computed(() => {
|
||||
return menuTargetNode.value?.contextMenuItems
|
||||
? typeof menuTargetNode.value.contextMenuItems === 'function'
|
||||
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
|
||||
: menuTargetNode.value.contextMenuItems
|
||||
const node = menuTargetNode.value
|
||||
return node?.contextMenuItems
|
||||
? typeof node.contextMenuItems === 'function'
|
||||
? node.contextMenuItems(node)
|
||||
: node.contextMenuItems
|
||||
: []
|
||||
})
|
||||
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const renameEditingNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||
const errorHandling = useErrorHandling()
|
||||
const handleNodeLabelEdit = async (
|
||||
node: RenderedTreeExplorerNode,
|
||||
n: RenderedTreeExplorerNode,
|
||||
newName: string
|
||||
) => {
|
||||
const node = n as RenderedTreeExplorerNode<T>
|
||||
await errorHandling.wrapWithErrorHandlingAsync(
|
||||
async () => {
|
||||
if (node.key === newFolderNode.value?.key) {
|
||||
@@ -170,35 +174,36 @@ const handleNodeLabelEdit = async (
|
||||
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
|
||||
|
||||
const { t } = useI18n()
|
||||
const renameCommand = (node: RenderedTreeExplorerNode) => {
|
||||
const renameCommand = (node: RenderedTreeExplorerNode<T>) => {
|
||||
renameEditingNode.value = node
|
||||
}
|
||||
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
|
||||
const deleteCommand = async (node: RenderedTreeExplorerNode<T>) => {
|
||||
await node.handleDelete?.()
|
||||
emit('nodeDelete', node)
|
||||
}
|
||||
const menuItems = computed<MenuItem[]>(() =>
|
||||
[
|
||||
getAddFolderMenuItem(menuTargetNode.value),
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const node = menuTargetNode.value
|
||||
return [
|
||||
getAddFolderMenuItem(node),
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-file-edit',
|
||||
command: () => {
|
||||
if (menuTargetNode.value) {
|
||||
renameCommand(menuTargetNode.value)
|
||||
if (node) {
|
||||
renameCommand(node)
|
||||
}
|
||||
},
|
||||
visible: menuTargetNode.value?.handleRename !== undefined
|
||||
visible: node?.handleRename !== undefined
|
||||
},
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
if (menuTargetNode.value) {
|
||||
await deleteCommand(menuTargetNode.value)
|
||||
if (node) {
|
||||
await deleteCommand(node)
|
||||
}
|
||||
},
|
||||
visible: menuTargetNode.value?.handleDelete !== undefined,
|
||||
visible: node?.handleDelete !== undefined,
|
||||
isAsync: true // The delete command can be async
|
||||
},
|
||||
...extraMenuItems.value
|
||||
@@ -210,9 +215,12 @@ const menuItems = computed<MenuItem[]>(() =>
|
||||
})
|
||||
: undefined
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
|
||||
const handleContextMenu = (
|
||||
e: MouseEvent,
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
) => {
|
||||
menuTargetNode.value = node
|
||||
emit('contextMenu', node, e)
|
||||
if (menuItems.value.filter((item) => item.visible).length > 0) {
|
||||
@@ -224,15 +232,13 @@ const wrapCommandWithErrorHandler = (
|
||||
command: (event: MenuItemCommandEvent) => void,
|
||||
{ isAsync = false }: { isAsync: boolean }
|
||||
) => {
|
||||
const node = menuTargetNode.value
|
||||
return isAsync
|
||||
? errorHandling.wrapWithErrorHandlingAsync(
|
||||
command as (event: MenuItemCommandEvent) => Promise<void>,
|
||||
menuTargetNode.value?.handleError
|
||||
)
|
||||
: errorHandling.wrapWithErrorHandling(
|
||||
command,
|
||||
menuTargetNode.value?.handleError
|
||||
node?.handleError
|
||||
)
|
||||
: errorHandling.wrapWithErrorHandling(command, node?.handleError)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts" generic="T">
|
||||
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
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'itemDropped',
|
||||
node: RenderedTreeExplorerNode,
|
||||
data: RenderedTreeExplorerNode
|
||||
node: RenderedTreeExplorerNode<T>,
|
||||
data: RenderedTreeExplorerNode<T>
|
||||
): void
|
||||
(e: 'dragStart', node: RenderedTreeExplorerNode): void
|
||||
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
|
||||
(e: 'dragStart', node: RenderedTreeExplorerNode<T>): void
|
||||
(e: 'dragEnd', node: RenderedTreeExplorerNode<T>): 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, newName)
|
||||
handleEditLabel?.(props.node as RenderedTreeExplorerNode, newName)
|
||||
}
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
@@ -117,9 +117,13 @@ 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)
|
||||
await props.node.handleDrop?.(dndData as TreeExplorerDragAndDropData<T>)
|
||||
canDrop.value = false
|
||||
emit('itemDropped', props.node, dndData.data)
|
||||
emit(
|
||||
'itemDropped',
|
||||
props.node,
|
||||
dndData.data as RenderedTreeExplorerNode<T>
|
||||
)
|
||||
}
|
||||
},
|
||||
onDragEnter: (event) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
class="workflow-template-selector-dialog"
|
||||
size="md"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[comfy--template]" />
|
||||
@@ -854,19 +854,3 @@ 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,12 +4,7 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
class="global-dialog"
|
||||
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 { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -134,10 +134,7 @@ const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
function openBlueprintOverwriteSetting() {
|
||||
useDialogStore().closeDialog()
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite'
|
||||
)
|
||||
useSettingsDialog().show(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 { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
@@ -105,10 +105,7 @@ const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
|
||||
:class="isCloud ? 'border-b' : ''"
|
||||
class="flex w-[490px] flex-col border-t-1 border-border-default"
|
||||
:class="isCloud ? 'border-b-1' : ''"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-5 text-muted-foreground">
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.description')
|
||||
@@ -14,210 +14,32 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
|
||||
|
||||
<!-- QUICK FIX AVAILABLE Section -->
|
||||
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
|
||||
<!-- Section header with Replace button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase text-primary">
|
||||
{{ $t('nodeReplacement.quickFixAvailable') }}
|
||||
</span>
|
||||
<div class="h-2 w-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
|
||||
variant="primary"
|
||||
size="md"
|
||||
:disabled="selectedTypes.size === 0"
|
||||
@click="handleReplaceSelected"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
|
||||
{{
|
||||
$t('nodeReplacement.replaceSelected', {
|
||||
count: selectedTypes.size
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Replaceable nodes list -->
|
||||
<div
|
||||
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<!-- Select All row (sticky header) -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
|
||||
pendingNodes.length > 0
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'opacity-50 pointer-events-none'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="
|
||||
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
|
||||
"
|
||||
@click="toggleSelectAll"
|
||||
@keydown.enter.prevent="toggleSelectAll"
|
||||
@keydown.space.prevent="toggleSelectAll"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
isAllSelected || isSomeSelected
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="isAllSelected"
|
||||
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else-if="isSomeSelected"
|
||||
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs font-medium uppercase text-muted-foreground">
|
||||
{{ $t('nodeReplacement.compatibleAlternatives') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Replaceable node items -->
|
||||
<div
|
||||
v-for="node in replaceableNodes"
|
||||
:key="node.label"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2',
|
||||
replacedTypes.has(node.label)
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
? 'true'
|
||||
: 'false'
|
||||
"
|
||||
@click="toggleNode(node.label)"
|
||||
@keydown.enter.prevent="toggleNode(node.label)"
|
||||
@keydown.space.prevent="toggleNode(node.label)"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
"
|
||||
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="replacedTypes.has(node.label)"
|
||||
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaced') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaceable') }}
|
||||
</span>
|
||||
<span class="text-sm text-foreground">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MANUAL INSTALLATION REQUIRED Section -->
|
||||
<!-- Missing Nodes List Wrapper -->
|
||||
<div
|
||||
v-if="nonReplaceableNodes.length > 0"
|
||||
class="flex max-h-[200px] flex-col gap-2"
|
||||
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
||||
>
|
||||
<!-- Section header -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase text-error">
|
||||
{{ $t('nodeReplacement.installationRequired') }}
|
||||
<div
|
||||
v-for="(node, i) in uniqueNodes"
|
||||
:key="i"
|
||||
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
|
||||
>
|
||||
<span class="text-xs">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
<i class="icon-[lucide--info] text-xs text-error" />
|
||||
</div>
|
||||
|
||||
<!-- Non-replaceable nodes list -->
|
||||
<div
|
||||
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<div
|
||||
v-for="node in nonReplaceableNodes"
|
||||
:key="node.label"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
|
||||
>
|
||||
{{ $t('nodeReplacement.notReplaceable') }}
|
||||
</span>
|
||||
<span class="text-sm text-foreground">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="node.hint" class="text-xs text-muted-foreground">
|
||||
{{ node.hint }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="node.action"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
@click="node.action.callback"
|
||||
>
|
||||
{{ node.action.text }}
|
||||
</Button>
|
||||
</div>
|
||||
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom instruction box -->
|
||||
<div
|
||||
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<p class="m-0 text-xs leading-5 text-neutral-foreground">
|
||||
<i18n-t keypath="nodeReplacement.instructionMessage">
|
||||
<template #red>
|
||||
<span class="text-error">{{
|
||||
$t('nodeReplacement.redHighlight')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<!-- Bottom instruction -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.replacementInstruction')
|
||||
: $t('missingNodes.oss.replacementInstruction')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,39 +47,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
// Get missing core nodes for OSS mode
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
interface ProcessedNode {
|
||||
label: string
|
||||
hint?: string
|
||||
action?: { text: string; callback: () => void }
|
||||
isReplaceable: boolean
|
||||
replacement?: NodeReplacement
|
||||
}
|
||||
|
||||
const replacedTypes = ref<Set<string>>(new Set())
|
||||
|
||||
const uniqueNodes = computed<ProcessedNode[]>(() => {
|
||||
const seenTypes = new Set<string>()
|
||||
return missingNodeTypes
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
if (seenTypes.has(type)) return false
|
||||
@@ -269,81 +75,10 @@ const uniqueNodes = computed<ProcessedNode[]>(() => {
|
||||
return {
|
||||
label: node.type,
|
||||
hint: node.hint,
|
||||
action: node.action,
|
||||
isReplaceable: node.isReplaceable ?? false,
|
||||
replacement: node.replacement
|
||||
action: node.action
|
||||
}
|
||||
}
|
||||
return { label: node, isReplaceable: false }
|
||||
return { label: node }
|
||||
})
|
||||
})
|
||||
|
||||
const replaceableNodes = computed(() =>
|
||||
uniqueNodes.value.filter((n) => n.isReplaceable)
|
||||
)
|
||||
|
||||
const pendingNodes = computed(() =>
|
||||
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
|
||||
)
|
||||
|
||||
const nonReplaceableNodes = computed(() =>
|
||||
uniqueNodes.value.filter((n) => !n.isReplaceable)
|
||||
)
|
||||
|
||||
// Selection state - all pending nodes selected by default
|
||||
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
|
||||
|
||||
const isAllSelected = computed(
|
||||
() =>
|
||||
pendingNodes.value.length > 0 &&
|
||||
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
|
||||
)
|
||||
|
||||
const isSomeSelected = computed(
|
||||
() => selectedTypes.value.size > 0 && !isAllSelected.value
|
||||
)
|
||||
|
||||
function toggleNode(label: string) {
|
||||
if (replacedTypes.value.has(label)) return
|
||||
const next = new Set(selectedTypes.value)
|
||||
if (next.has(label)) {
|
||||
next.delete(label)
|
||||
} else {
|
||||
next.add(label)
|
||||
}
|
||||
selectedTypes.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedTypes.value = new Set()
|
||||
} else {
|
||||
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceSelected() {
|
||||
const selected = missingNodeTypes.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
return selectedTypes.value.has(type)
|
||||
})
|
||||
|
||||
const result = replaceNodesInPlace(selected)
|
||||
const nextReplaced = new Set(replacedTypes.value)
|
||||
const nextSelected = new Set(selectedTypes.value)
|
||||
for (const type of result) {
|
||||
nextReplaced.add(type)
|
||||
nextSelected.delete(type)
|
||||
}
|
||||
replacedTypes.value = nextReplaced
|
||||
selectedTypes.value = nextSelected
|
||||
|
||||
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
|
||||
const allReplaced = replaceableNodes.value.every((n) =>
|
||||
nextReplaced.has(n.label)
|
||||
)
|
||||
if (allReplaced && nonReplaceableNodes.value.length === 0) {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,18 +30,8 @@
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
|
||||
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">
|
||||
{{ $t('nodeReplacement.skipForNow') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div
|
||||
v-else-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2"
|
||||
>
|
||||
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@@ -58,9 +48,9 @@
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Manager buttons -->
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
||||
<Button variant="textonly" @click="handleOpenManager">{{
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
@@ -90,19 +80,14 @@ 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 { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -118,21 +103,12 @@ const handleGotItClick = () => {
|
||||
|
||||
function openShowMissingNodesSetting() {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingNodesWarning'
|
||||
)
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
function handleOpenManager() {
|
||||
managerState.openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
|
||||
// Check if any of the missing packs are currently being installed
|
||||
const isInstalling = computed(() => {
|
||||
@@ -152,29 +128,15 @@ const showInstallAllButton = computed(() => {
|
||||
return managerState.shouldShowInstallButton.value
|
||||
})
|
||||
|
||||
const hasNonReplaceableNodes = computed(
|
||||
() =>
|
||||
missingNodeTypes?.some(
|
||||
(n) =>
|
||||
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
|
||||
) ?? false
|
||||
)
|
||||
const openManager = async () => {
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
|
||||
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
|
||||
const hadMissingPacks = ref(false)
|
||||
|
||||
watch(
|
||||
missingNodePacks,
|
||||
(packs) => {
|
||||
if (packs && packs.length > 0) hadMissingPacks.value = true
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Only consider "all installed" when packs transitioned from non-empty to empty
|
||||
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
|
||||
// Computed to check if all missing nodes have been installed
|
||||
const allMissingNodesInstalled = computed(() => {
|
||||
if (!hadMissingPacks.value) return false
|
||||
return (
|
||||
!isLoading.value &&
|
||||
!isInstalling.value &&
|
||||
|
||||
@@ -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 { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
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 dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
: 'credits'
|
||||
dialogService.showSettingsDialog(settingsPanel)
|
||||
settingsDialog.show(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 { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
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 dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
||||
})
|
||||
await fetchBalance()
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
settingsDialog.show('workspace')
|
||||
} else if (response.status === 'pending') {
|
||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<PanelTemplate
|
||||
value="About"
|
||||
class="about-container"
|
||||
data-testid="about-panel"
|
||||
>
|
||||
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('g.about') }}
|
||||
</h2>
|
||||
@@ -32,7 +28,7 @@
|
||||
v-if="systemStatsStore.systemStats"
|
||||
:stats="systemStatsStore.systemStats"
|
||||
/>
|
||||
</PanelTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -43,8 +39,6 @@ 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,13 +1,9 @@
|
||||
<template>
|
||||
<PanelTemplate value="Keybinding" class="keybinding-panel">
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
@@ -135,7 +131,7 @@
|
||||
<i class="pi pi-replay" />
|
||||
{{ $t('g.resetAll') }}
|
||||
</Button>
|
||||
</PanelTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -159,7 +155,6 @@ 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>
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<div 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>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -110,7 +110,6 @@ 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'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
<TabPanel value="User" class="user-settings-container h-full">
|
||||
<div 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,13 +95,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</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'
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<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">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<header class="mb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
@@ -8,44 +8,38 @@
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</div>
|
||||
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
|
||||
</header>
|
||||
<TabsRoot v-model="activeTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList unstyled class="flex w-full gap-2">
|
||||
<Tab
|
||||
<TabsList class="flex items-center gap-2 pb-1">
|
||||
<TabsTrigger
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'plan' && 'text-base-foreground no-underline'
|
||||
tabTriggerBase,
|
||||
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</Tab>
|
||||
<Tab
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'members' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'members' && 'text-base-foreground no-underline',
|
||||
'ml-2'
|
||||
tabTriggerBase,
|
||||
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.tabs.membersCount', {
|
||||
count: isInPersonalWorkspace ? 1 : members.length
|
||||
count: members.length
|
||||
})
|
||||
}}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
v-tooltip="
|
||||
@@ -64,15 +58,13 @@
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus ml-1 text-sm" />
|
||||
<i class="pi pi-plus text-sm" />
|
||||
</Button>
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
class="ml-2"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
@@ -80,17 +72,21 @@
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
<button
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
|
||||
]"
|
||||
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'
|
||||
)
|
||||
"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
@@ -100,46 +96,47 @@
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels unstyled>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabPanel>
|
||||
<TabPanel value="members">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</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 { buttonVariants } from '@/components/ui/button/button.variants'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
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
|
||||
@@ -164,16 +161,12 @@ const isSingleSeatPlan = computed(() => {
|
||||
return getMaxSeats(tierKey) <= 1
|
||||
})
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
members,
|
||||
isInviteLimitReached,
|
||||
isWorkspaceSubscribed,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
|
||||
storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
|
||||
useWorkspaceUI()
|
||||
|
||||
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
@@ -253,7 +246,6 @@ const menuItems = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setActiveTab(defaultTab)
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<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,31 +70,17 @@
|
||||
@click="onSelectLink"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-4 top-2 cursor-pointer"
|
||||
class="absolute right-3 top-2.5 cursor-pointer"
|
||||
@click="onCopyLink"
|
||||
>
|
||||
<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>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi size-4',
|
||||
justCopied ? 'pi-check text-green-500' : 'pi-copy'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +104,7 @@ 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'
|
||||
|
||||
@@ -130,6 +117,7 @@ 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@]+$/
|
||||
@@ -161,6 +149,10 @@ 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,32 +0,0 @@
|
||||
<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,11 +14,13 @@ 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'
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
@@ -87,10 +90,25 @@ 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: () =>
|
||||
@@ -271,6 +289,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
</nav>
|
||||
@@ -288,6 +307,7 @@ 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"
|
||||
|
||||
30
src/components/rightSidePanel/TabError.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<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,10 +83,7 @@ const favoriteNode = computed(() =>
|
||||
)
|
||||
|
||||
const widgetValue = computed({
|
||||
get: () => {
|
||||
widget.vueTrack?.()
|
||||
return widget.value
|
||||
},
|
||||
get: () => 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 { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
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 dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
|
||||
// NODES settings
|
||||
const showAdvancedParameters = computed({
|
||||
@@ -92,7 +92,7 @@ function updateGridSpacingFromInput(value: number | null | undefined) {
|
||||
}
|
||||
|
||||
function openFullSettings() {
|
||||
dialogService.showSettingsDialog()
|
||||
settingsDialog.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -108,15 +108,14 @@ 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'
|
||||
@@ -129,7 +128,7 @@ const commandStore = useCommandStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const dialogStore = useDialogStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const managerState = useManagerState()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -166,15 +165,8 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
|
||||
}
|
||||
}
|
||||
|
||||
const showSettings = (defaultPanel?: string) => {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent,
|
||||
props: {
|
||||
defaultPanel
|
||||
}
|
||||
})
|
||||
const showSettings = (defaultPanel?: SettingPanelType) => {
|
||||
settingsDialog.show(defaultPanel)
|
||||
}
|
||||
|
||||
const showManageExtensions = async () => {
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
<template #before-label="{ node: treeNode }">
|
||||
<span
|
||||
v-if="
|
||||
treeNode.data?.isModified || !treeNode.data?.isPersisted
|
||||
(treeNode.data as ComfyWorkflow)?.isModified ||
|
||||
!(treeNode.data as ComfyWorkflow)?.isPersisted
|
||||
"
|
||||
>*</span
|
||||
>
|
||||
|
||||
@@ -215,7 +215,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
}
|
||||
)
|
||||
|
||||
const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null)
|
||||
interface TreeExplorerExposed {
|
||||
addFolderCommand: (targetNodeKey: string) => void
|
||||
}
|
||||
|
||||
const treeExplorerRef = ref<TreeExplorerExposed | null>(null)
|
||||
defineExpose({
|
||||
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
const expandedKeys = inject(InjectKeyExpandedKeys)
|
||||
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
|
||||
const handleItemDrop = (node: RenderedTreeExplorerNode<ComfyNodeDefImpl>) => {
|
||||
if (!expandedKeys) return
|
||||
expandedKeys.value[node.key] = true
|
||||
}
|
||||
|
||||
@@ -31,6 +31,15 @@ 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(() => {
|
||||
@@ -64,7 +73,6 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showSettingsDialog: mockShowSettingsDialog,
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -152,6 +152,7 @@ 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'
|
||||
|
||||
@@ -165,6 +166,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
@@ -198,7 +200,7 @@ const canUpgrade = computed(() => {
|
||||
})
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
settingsDialog.show('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -209,9 +211,9 @@ const handleOpenPlansAndPricing = () => {
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('subscription')
|
||||
settingsDialog.show('subscription')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
settingsDialog.show('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
|
||||
@@ -55,63 +55,61 @@
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Credits Section -->
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
</template>
|
||||
<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" />
|
||||
|
||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||
<div
|
||||
@@ -222,16 +220,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,
|
||||
isWorkspaceSubscribed
|
||||
isInPersonalWorkspace: isPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -242,6 +240,7 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
|
||||
useBillingContext()
|
||||
@@ -275,22 +274,24 @@ const canUpgrade = computed(() => {
|
||||
})
|
||||
|
||||
const showPlansAndPricing = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
() => permissions.value.canManageSubscription
|
||||
)
|
||||
const showManagePlan = computed(
|
||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
||||
() => permissions.value.canManageSubscription && isActiveSubscription.value
|
||||
)
|
||||
const showCreditsSection = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
const showSubscribeAction = computed(
|
||||
() =>
|
||||
permissions.value.canManageSubscription &&
|
||||
(!isActiveSubscription.value || isCancelled.value)
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
settingsDialog.show('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceSettings = () => {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
settingsDialog.show('workspace')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -301,9 +302,9 @@ const handleOpenPlansAndPricing = () => {
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
settingsDialog.show('workspace')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
settingsDialog.show('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="base-widget-layout rounded-2xl overflow-hidden relative"
|
||||
:class="cn('rounded-2xl overflow-hidden relative', sizeClasses)"
|
||||
@keydown.esc.capture="handleEscape"
|
||||
>
|
||||
<div
|
||||
@@ -141,14 +141,31 @@ 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 { contentTitle, rightPanelTitle } = defineProps<{
|
||||
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<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
|
||||
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||
default: false
|
||||
})
|
||||
@@ -215,17 +232,3 @@ 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,49 +1,76 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
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
|
||||
}
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
it('should trigger on callback', async () => {
|
||||
const [node, , onReactivityUpdate] = createTestGraph()
|
||||
|
||||
node.widgets![0].callback!(2)
|
||||
await nextTick()
|
||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('should remain reactive after a connection is made', async () => {
|
||||
const [node, graph, 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
|
||||
await nextTick()
|
||||
|
||||
expect(widgetValue.value).toBe(42)
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('widget values remain reactive after a connection is made', async () => {
|
||||
const { node, graph } = createTestGraph()
|
||||
const store = useWidgetValueStore()
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: '1',
|
||||
nodeId: String(node.id),
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
await nextTick()
|
||||
onReactivityUpdate.mockClear()
|
||||
|
||||
node.widgets![0].callback!(2)
|
||||
const widgetValue = computed(
|
||||
() => store.getWidget(node.id, 'testnum')?.value
|
||||
)
|
||||
watch(widgetValue, onValueChange)
|
||||
|
||||
node.widgets![0].value = 99
|
||||
await nextTick()
|
||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(widgetValue.value).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { customRef, reactive, shallowReactive } from 'vue'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { IBaseWidget } 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'
|
||||
@@ -40,18 +38,37 @@ 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
|
||||
value: WidgetValue
|
||||
borderStyle?: string
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
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
|
||||
label?: string
|
||||
options?: IWidgetOptions
|
||||
/** Node type (for subgraph promoted widgets) */
|
||||
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 */
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
|
||||
@@ -93,23 +110,6 @@ 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'
|
||||
@@ -121,28 +121,28 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
||||
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
|
||||
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
|
||||
return subNode?.type
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
/** Border style for promoted/advanced widgets */
|
||||
borderStyle?: string
|
||||
/** Widget label */
|
||||
label?: string
|
||||
/** Widget options */
|
||||
options?: IWidgetOptions
|
||||
/** Node type (for subgraph promoted widgets) */
|
||||
nodeType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts common widget enhancements shared across different rendering contexts.
|
||||
* This function centralizes the logic for extracting metadata and reactive values
|
||||
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
|
||||
* This function centralizes the logic for extracting metadata from widgets.
|
||||
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export function getSharedWidgetEnhancements(
|
||||
node: LGraphNode,
|
||||
@@ -151,16 +151,9 @@ export function getSharedWidgetEnhancements(
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
return {
|
||||
value: useReactiveWidgetValue(widget),
|
||||
controlWidget: getControlWidget(widget),
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
||||
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
|
||||
nodeType: getNodeType(node, widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +194,7 @@ function safeWidgetMapper(
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
return function (widget) {
|
||||
try {
|
||||
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
@@ -217,20 +210,41 @@ 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 {
|
||||
name: widget.name,
|
||||
nodeId,
|
||||
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',
|
||||
value: undefined
|
||||
type: widget.type || 'text'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ export const usePriceBadge = () => {
|
||||
return badges
|
||||
}
|
||||
|
||||
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
|
||||
function isCreditsBadge(
|
||||
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
|
||||
): boolean {
|
||||
const badgeInstance = typeof badge === 'function' ? badge() : badge
|
||||
return badgeInstance.icon?.image === componentIconSvg
|
||||
}
|
||||
@@ -61,6 +63,7 @@ export const usePriceBadge = () => {
|
||||
}
|
||||
return {
|
||||
getCreditsBadge,
|
||||
isCreditsBadge,
|
||||
updateSubgraphCredits
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { ref } from 'vue'
|
||||
import { shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
@@ -8,12 +8,14 @@ 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(
|
||||
expandNode: (node: RenderedTreeExplorerNode) => void
|
||||
export function useTreeFolderOperations<T>(
|
||||
expandNode: (node: RenderedTreeExplorerNode<T>) => void
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const newFolderNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const addFolderTargetNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const newFolderNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||
const addFolderTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// Generate a unique temporary key for the new folder
|
||||
const generateTempKey = (parentKey: string) => {
|
||||
@@ -37,7 +39,7 @@ export function useTreeFolderOperations(
|
||||
* 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) => {
|
||||
const addFolderCommand = (targetNode: RenderedTreeExplorerNode<T>) => {
|
||||
expandNode(targetNode)
|
||||
newFolderNode.value = {
|
||||
key: generateTempKey(targetNode.key),
|
||||
@@ -49,13 +51,13 @@ export function useTreeFolderOperations(
|
||||
totalLeaves: 0,
|
||||
badgeText: '',
|
||||
isEditingLabel: true
|
||||
}
|
||||
} as RenderedTreeExplorerNode<T>
|
||||
addFolderTargetNode.value = targetNode
|
||||
}
|
||||
|
||||
// Generate the "Add Folder" menu item
|
||||
const getAddFolderMenuItem = (
|
||||
targetNode: RenderedTreeExplorerNode | null
|
||||
targetNode: RenderedTreeExplorerNode<T> | null
|
||||
): MenuItem => {
|
||||
return {
|
||||
label: t('g.newFolder'),
|
||||
|
||||
@@ -35,6 +35,7 @@ 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'
|
||||
@@ -73,6 +74,7 @@ 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()
|
||||
@@ -582,7 +584,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
versionAdded: '1.3.7',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
void dialogService.showSettingsDialog()
|
||||
settingsDialog.show()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -831,7 +833,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'About ComfyUI',
|
||||
versionAdded: '1.6.4',
|
||||
function: () => {
|
||||
void dialogService.showSettingsDialog('about')
|
||||
settingsDialog.showAbout()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,8 +20,7 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements'
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,9 +96,6 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.user_secrets_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get nodeReplacementsEnabled() {
|
||||
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -155,18 +155,11 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
const getInputImageUrl = (): string | null => {
|
||||
if (!node.value) return null
|
||||
|
||||
let sourceNode = node.value.getInputNode(0)
|
||||
if (!sourceNode) return null
|
||||
const inputNode = node.value.getInputNode(0)
|
||||
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const link = node.value.getInputLink(0)
|
||||
if (!link) return null
|
||||
const resolved = sourceNode.resolveSubgraphOutputLink(link.origin_slot)
|
||||
sourceNode = resolved?.outputNode ?? null
|
||||
if (!sourceNode) return null
|
||||
}
|
||||
if (!inputNode) return null
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(sourceNode)
|
||||
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
|
||||
|
||||
if (urls?.length) {
|
||||
return urls[0]
|
||||
@@ -569,10 +562,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
|
||||
const initialize = () => {
|
||||
if (nodeId != null) {
|
||||
node.value =
|
||||
app.canvas?.graph?.getNodeById(nodeId) ||
|
||||
app.rootGraph?.getNodeById(nodeId) ||
|
||||
null
|
||||
node.value = app.rootGraph?.getNodeById(nodeId) || null
|
||||
}
|
||||
|
||||
updateImageUrl()
|
||||
|
||||
@@ -7,8 +7,13 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { isImageNode } from '@/utils/litegraphUtil'
|
||||
import { pasteImageNode, usePaste } from './usePaste'
|
||||
import { createNode, isImageNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
cloneDataTransfer,
|
||||
pasteImageNode,
|
||||
pasteImageNodes,
|
||||
usePaste
|
||||
} from './usePaste'
|
||||
|
||||
function createMockNode() {
|
||||
return {
|
||||
@@ -86,6 +91,7 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
createNode: vi.fn(),
|
||||
isAudioNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isVideoNode: vi.fn()
|
||||
@@ -99,34 +105,32 @@ describe('pasteImageNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should create new LoadImage node when no image node provided', () => {
|
||||
it('should create new LoadImage node when no image node provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items
|
||||
)
|
||||
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pos).toEqual([100, 200])
|
||||
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.graph!.change).toHaveBeenCalled()
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing image node when provided', () => {
|
||||
it('should use existing image node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
pasteImageNode(
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -136,13 +140,13 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
|
||||
})
|
||||
|
||||
it('should handle multiple image files', () => {
|
||||
it('should handle multiple image files', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const dataTransfer = createDataTransfer([file1, file2])
|
||||
|
||||
pasteImageNode(
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -152,11 +156,11 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
|
||||
})
|
||||
|
||||
it('should do nothing when no image files present', () => {
|
||||
it('should do nothing when no image files present', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
pasteImageNode(
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -166,13 +170,13 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter non-image items', () => {
|
||||
it('should filter non-image items', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const imageFile = createImageFile()
|
||||
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
||||
const dataTransfer = createDataTransfer([textFile, imageFile])
|
||||
|
||||
pasteImageNode(
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -183,21 +187,61 @@ 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) => node as LGraphNode
|
||||
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle image paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
|
||||
|
||||
usePaste()
|
||||
|
||||
@@ -207,7 +251,7 @@ describe('usePaste', () => {
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
@@ -312,3 +356,62 @@ 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,9 +6,41 @@ 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 { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
createNode,
|
||||
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]
|
||||
@@ -48,27 +80,37 @@ function pasteItemsOnNode(
|
||||
)
|
||||
}
|
||||
|
||||
export function pasteImageNode(
|
||||
export async function pasteImageNode(
|
||||
canvas: LGraphCanvas,
|
||||
items: DataTransferItemList,
|
||||
imageNode: LGraphNode | null = null
|
||||
): void {
|
||||
const {
|
||||
graph,
|
||||
graph_mouse: [posX, posY]
|
||||
} = canvas
|
||||
|
||||
): Promise<LGraphNode | null> {
|
||||
// No image node selected: add a new one
|
||||
if (!imageNode) {
|
||||
// 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()
|
||||
imageNode = await createNode(canvas, 'LoadImage')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +135,7 @@ 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
|
||||
|
||||
@@ -114,7 +157,7 @@ export const usePaste = () => {
|
||||
// Look for image paste data
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
return
|
||||
} else if (item.type.startsWith('video/')) {
|
||||
if (!videoNode) {
|
||||
|
||||
@@ -32,15 +32,6 @@ 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;'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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 | true | null | undefined
|
||||
export type ServerConfigValue = string | number | boolean | 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: ServerConfig<any>[] = [
|
||||
export const SERVER_CONFIG_ITEMS = [
|
||||
// Network settings
|
||||
{
|
||||
id: 'listen',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -43,6 +45,10 @@ 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', () => {})
|
||||
@@ -119,94 +125,6 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.widgets[0].computedHeight = 10
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
test('Proxy widget label shows widgetName, not "nodeId: widgetName"', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
expect(proxyWidget.label).toBe('seed')
|
||||
expect(proxyWidget.name).toBe('1: seed')
|
||||
})
|
||||
|
||||
test('Proxy widget label reflects linked widget label', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
expect(proxyWidget.label).toBe('seed')
|
||||
|
||||
innerNodes[0].widgets![0].label = 'My Inner Label'
|
||||
// Trigger re-resolve of linked widget
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('My Inner Label')
|
||||
})
|
||||
|
||||
test('Proxy widget user rename takes priority over linked widget label', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
proxyWidget.label = 'My Custom Seed'
|
||||
expect(proxyWidget.label).toBe('My Custom Seed')
|
||||
|
||||
innerNodes[0].widgets![0].label = 'Inner Override'
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('My Custom Seed')
|
||||
})
|
||||
|
||||
test('Proxy widget label resets to linked widget on undefined', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
proxyWidget.label = 'Custom'
|
||||
expect(proxyWidget.label).toBe('Custom')
|
||||
|
||||
proxyWidget.label = undefined
|
||||
innerNodes[0].widgets![0].label = 'Inner Label'
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('Inner Label')
|
||||
})
|
||||
|
||||
test('Proxy widget labels are correct when loaded from serialized data', () => {
|
||||
// Intentionally constructs SubgraphNode via constructor (not setupSubgraph)
|
||||
// to exercise the deserialization/onConfigure path from blueprint JSON.
|
||||
const subgraph = createTestSubgraph()
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(innerNode)
|
||||
innerNode.addWidget('text', 'seed', 'value', () => {})
|
||||
innerNode.addWidget('text', 'steps', 'value', () => {})
|
||||
|
||||
const parentGraph = new LGraph()
|
||||
const subgraphNode = new SubgraphNode(parentGraph, subgraph, {
|
||||
id: 1,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
['1', 'seed'],
|
||||
['1', 'steps']
|
||||
]
|
||||
},
|
||||
flags: {},
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets[0].label).toBe('seed')
|
||||
expect(subgraphNode.widgets[0].name).toBe('1: seed')
|
||||
expect(subgraphNode.widgets[1].label).toBe('steps')
|
||||
expect(subgraphNode.widgets[1].name).toBe('1: steps')
|
||||
})
|
||||
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
@@ -115,9 +115,11 @@ const onConfigure = function (
|
||||
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
|
||||
return [w]
|
||||
})
|
||||
this.widgets = this.widgets.filter(
|
||||
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
|
||||
)
|
||||
this.widgets = this.widgets.filter((w) => {
|
||||
if (isProxyWidget(w)) return false
|
||||
const widgetName = w.name
|
||||
return !parsed.some(([, name]) => widgetName === name)
|
||||
})
|
||||
this.widgets.push(...newWidgets)
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
@@ -152,6 +154,7 @@ function newProxyWidget(
|
||||
computedHeight: undefined,
|
||||
isProxyWidget: true,
|
||||
last_y: undefined,
|
||||
label: name,
|
||||
name,
|
||||
node: subgraphNode,
|
||||
onRemove: undefined,
|
||||
@@ -199,15 +202,12 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
* and the value used as 'this' if property is a get/set method
|
||||
* @param {unknown} value - only used on set calls. The thing being assigned
|
||||
*/
|
||||
let userLabel: string | undefined
|
||||
const handler = {
|
||||
get(_t: IBaseWidget, property: string, receiver: object) {
|
||||
let redirectedTarget: object = backingWidget
|
||||
let redirectedReceiver = receiver
|
||||
if (property == '_overlay') return overlay
|
||||
else if (property == 'value') redirectedReceiver = backingWidget
|
||||
else if (property == 'label')
|
||||
return userLabel ?? linkedWidget?.label ?? overlay.widgetName
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
redirectedReceiver = overlay
|
||||
@@ -215,10 +215,6 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
return Reflect.get(redirectedTarget, property, redirectedReceiver)
|
||||
},
|
||||
set(_t: IBaseWidget, property: string, value: unknown) {
|
||||
if (property == 'label') {
|
||||
userLabel = value as string | undefined
|
||||
return true
|
||||
}
|
||||
let redirectedTarget: object = backingWidget
|
||||
if (property == 'computedHeight') {
|
||||
if (overlay.widgetName.startsWith('$$') && linkedNode) {
|
||||
|
||||
@@ -57,15 +57,20 @@ 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 && w.name === widgetName
|
||||
return ([n, w]: WidgetItem) =>
|
||||
n.id == nodeId && getWidgetName(w) === widgetName
|
||||
}
|
||||
export function matchesPropertyItem([n, w]: WidgetItem) {
|
||||
return ([nodeId, widgetName]: [string, string]) =>
|
||||
n.id == nodeId && w.name === widgetName
|
||||
n.id == nodeId && getWidgetName(w) === widgetName
|
||||
}
|
||||
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
|
||||
return [`${n.id}`, w.name]
|
||||
return [`${n.id}`, getWidgetName(w)]
|
||||
}
|
||||
|
||||
function getParentNodes(): SubgraphNode[] {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
const toastStore = useToastStore()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
|
||||
const onChangeRestartApp = (newValue: string, oldValue: string) => {
|
||||
const onChangeRestartApp = (newValue: unknown, oldValue: unknown) => {
|
||||
// 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.type = 'hidden'
|
||||
widget.hidden = true
|
||||
widget.computeSize = () => [0, -4]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ useExtensionService().registerExtension({
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'ImageCropV2') return
|
||||
|
||||
node.hideOutputImages = true
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)])
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@ useExtensionService().registerExtension({
|
||||
|
||||
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
||||
this,
|
||||
'preview',
|
||||
'preview_markdown',
|
||||
['MARKDOWN', {}],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
const showValueWidgetPlain = ComfyWidgets['STRING'](
|
||||
this,
|
||||
'preview',
|
||||
'preview_text',
|
||||
['STRING', { multiline: true }],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
@@ -48,16 +48,20 @@ useExtensionService().registerExtension({
|
||||
showValueWidgetPlain.options.hidden = value
|
||||
}
|
||||
|
||||
showValueWidget.label = 'Preview'
|
||||
showValueWidget.hidden = true
|
||||
showValueWidget.options.hidden = true
|
||||
showValueWidget.options.read_only = true
|
||||
showValueWidget.element.readOnly = true
|
||||
showValueWidget.element.disabled = true
|
||||
showValueWidget.serialize = false
|
||||
|
||||
showValueWidgetPlain.label = 'Preview'
|
||||
showValueWidgetPlain.hidden = false
|
||||
showValueWidgetPlain.options.hidden = false
|
||||
showValueWidgetPlain.options.read_only = true
|
||||
showValueWidgetPlain.element.readOnly = true
|
||||
showValueWidgetPlain.element.disabled = true
|
||||
showValueWidgetPlain.serialize = false
|
||||
}
|
||||
|
||||
@@ -69,7 +73,7 @@ useExtensionService().registerExtension({
|
||||
: onExecuted.apply(this, [message])
|
||||
|
||||
const previewWidgets =
|
||||
this.widgets?.filter((w) => w.name === 'preview') ?? []
|
||||
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
|
||||
|
||||
for (const previewWidget of previewWidgets) {
|
||||
const text = message.text ?? ''
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { app } from '../../scripts/app'
|
||||
@@ -5,10 +6,17 @@ 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() {
|
||||
init(this: SlotDefaultsExtension) {
|
||||
LiteGraph.search_filter_enabled = true
|
||||
LiteGraph.middle_click_slot_add_default_node = true
|
||||
this.suggestionsNumber = app.ui.settings.addSetting({
|
||||
@@ -24,13 +32,13 @@ app.registerExtension({
|
||||
},
|
||||
defaultValue: 5,
|
||||
onChange: (newVal) => {
|
||||
this.setDefaults(newVal)
|
||||
this.setDefaults(newVal as number)
|
||||
}
|
||||
})
|
||||
},
|
||||
slot_types_default_out: {},
|
||||
slot_types_default_in: {},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
async beforeRegisterNodeDef(this: SlotDefaultsExtension, 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) {
|
||||
@@ -83,22 +91,23 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
var maxNum = this.suggestionsNumber.value
|
||||
var maxNum = this.suggestionsNumber?.value
|
||||
this.setDefaults(maxNum)
|
||||
},
|
||||
setDefaults(maxNum?: number | null) {
|
||||
setDefaults(this: SlotDefaultsExtension, 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, maxNum)
|
||||
].slice(0, max)
|
||||
}
|
||||
for (const type in this.slot_types_default_in) {
|
||||
LiteGraph.slot_types_default_in[type] = this.slot_types_default_in[
|
||||
type
|
||||
].slice(0, maxNum)
|
||||
].slice(0, max)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ 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>,
|
||||
@@ -138,9 +139,16 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
let value = ''
|
||||
audioUIWidget.options.getValue = () => value
|
||||
audioUIWidget.options.setValue = (v) => (value = v)
|
||||
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
|
||||
}
|
||||
|
||||
return { widget: audioUIWidget }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type {
|
||||
ContextMenuDivElement,
|
||||
IContextMenuOptions,
|
||||
@@ -7,38 +5,6 @@ import type {
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
|
||||
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
|
||||
const ALLOWED_STYLE_PROPS = new Set([
|
||||
'display',
|
||||
'color',
|
||||
'background-color',
|
||||
'padding-left',
|
||||
'border-left'
|
||||
])
|
||||
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
if (data.attrName === 'style') {
|
||||
const sanitizedStyle = data.attrValue
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => {
|
||||
const colonIdx = s.indexOf(':')
|
||||
if (colonIdx === -1) return false
|
||||
const prop = s.slice(0, colonIdx).trim().toLowerCase()
|
||||
return ALLOWED_STYLE_PROPS.has(prop)
|
||||
})
|
||||
.join('; ')
|
||||
data.attrValue = sanitizedStyle
|
||||
}
|
||||
})
|
||||
|
||||
function sanitizeMenuHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ['style']
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Replace this pattern with something more modern.
|
||||
export interface ContextMenu<TValue = unknown> {
|
||||
constructor: new (
|
||||
@@ -157,7 +123,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (options.title) {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'litemenu-title'
|
||||
element.textContent = options.title
|
||||
element.innerHTML = options.title
|
||||
root.append(element)
|
||||
}
|
||||
|
||||
@@ -252,18 +218,11 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (value === null) {
|
||||
element.classList.add('separator')
|
||||
} else {
|
||||
const label = name === null ? '' : String(name)
|
||||
const innerHtml = name === null ? '' : String(name)
|
||||
if (typeof value === 'string') {
|
||||
element.textContent = label
|
||||
element.innerHTML = innerHtml
|
||||
} else {
|
||||
// Use innerHTML for content that contains HTML tags, textContent otherwise
|
||||
const hasHtmlContent =
|
||||
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
|
||||
if (hasHtmlContent) {
|
||||
element.innerHTML = sanitizeMenuHTML(value.content!)
|
||||
} else {
|
||||
element.textContent = value?.title ?? label
|
||||
}
|
||||
element.innerHTML = value?.title ?? innerHtml
|
||||
|
||||
if (value.disabled) {
|
||||
disabled = true
|
||||
|
||||
@@ -48,6 +48,17 @@ 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,6 +4,7 @@ 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'
|
||||
@@ -89,12 +90,13 @@ export interface LGraphState {
|
||||
lastRerouteId: number
|
||||
}
|
||||
|
||||
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]>
|
||||
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>>
|
||||
|
||||
/** Configuration used by {@link LGraph} `config`. */
|
||||
export interface LGraphConfig {
|
||||
@@ -158,7 +160,6 @@ 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([
|
||||
@@ -775,8 +776,10 @@ export class LGraph
|
||||
let max_size = 100
|
||||
let y = margin + LiteGraph.NODE_TITLE_HEIGHT
|
||||
for (const node of column) {
|
||||
node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x
|
||||
node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y
|
||||
node.setPos(
|
||||
layout == LiteGraph.VERTICAL_LAYOUT ? y : x,
|
||||
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]
|
||||
@@ -895,7 +898,7 @@ export class LGraph
|
||||
* @deprecated Use options object instead
|
||||
*/
|
||||
add(
|
||||
node: LGraphNode | LGraphGroup,
|
||||
node: LGraphNode | LGraphGroup | null,
|
||||
skipComputeOrder?: boolean
|
||||
): LGraphNode | null | undefined
|
||||
add(
|
||||
@@ -962,6 +965,14 @@ 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++
|
||||
|
||||
@@ -1750,7 +1761,10 @@ export class LGraph
|
||||
)
|
||||
|
||||
//Correct for title height. It's included in bounding box, but not _posSize
|
||||
subgraphNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
subgraphNode.setPos(
|
||||
subgraphNode.pos[0],
|
||||
subgraphNode.pos[1] + LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
)
|
||||
|
||||
// Add the subgraph node to the graph
|
||||
this.add(subgraphNode)
|
||||
@@ -1917,8 +1931,7 @@ export class LGraph
|
||||
|
||||
this.add(node, true)
|
||||
node.configure(n_info)
|
||||
node.pos[0] += offsetX
|
||||
node.pos[1] += offsetY
|
||||
node.setPos(node.pos[0] + offsetX, node.pos[1] + offsetY)
|
||||
for (const input of node.inputs) {
|
||||
input.link = null
|
||||
}
|
||||
@@ -2437,7 +2450,7 @@ export class LGraph
|
||||
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
||||
}
|
||||
|
||||
if (this.isRootGraph && LGraph.deduplicateSubgraphIds) {
|
||||
if (this.isRootGraph) {
|
||||
const reservedNodeIds = nodesData
|
||||
?.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
@@ -13,7 +13,9 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn()
|
||||
getSlotLayout: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
batchUpdateNodeBounds: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ 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'
|
||||
@@ -2396,8 +2394,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const cloned = items?.created[0] as LGraphNode | undefined
|
||||
if (!cloned) return
|
||||
|
||||
cloned.pos[0] += 5
|
||||
cloned.pos[1] += 5
|
||||
cloned.setPos(cloned.pos[0] + 5, cloned.pos[1] + 5)
|
||||
|
||||
if (this.allow_dragnodes) {
|
||||
pointer.onDragStart = (pointer) => {
|
||||
@@ -3581,19 +3578,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (dragEvent) {
|
||||
this.adjustMouseEvent(dragEvent)
|
||||
const e = dragEvent as CanvasPointerEvent
|
||||
node.pos[0] = e.canvasX - node.size[0] / 2
|
||||
node.pos[1] = e.canvasY + 10
|
||||
node.setPos(e.canvasX - node.size[0] / 2, e.canvasY + 10)
|
||||
// Update last_mouse to prevent jump on first drag move
|
||||
this.last_mouse = [e.clientX, e.clientY]
|
||||
} else {
|
||||
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] })
|
||||
node.setPos(
|
||||
this.graph_mouse[0] - node.size[0] / 2,
|
||||
this.graph_mouse[1] + 10
|
||||
)
|
||||
}
|
||||
|
||||
this.state.ghostNodeId = node.id
|
||||
@@ -4162,31 +4154,30 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust positions
|
||||
// Adjust positions - use move/setPos to ensure layout store is updated
|
||||
const dx = position[0] - offsetX
|
||||
const dy = position[1] - offsetY
|
||||
for (const item of created) {
|
||||
item.pos[0] += position[0] - offsetX
|
||||
item.pos[1] += position[1] - offsetY
|
||||
if (item instanceof LGraphNode) {
|
||||
item.setPos(item.pos[0] + dx, item.pos[1] + dy)
|
||||
} else if (item instanceof Reroute) {
|
||||
item.move(dx, dy)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created
|
||||
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
||||
.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
|
||||
}
|
||||
.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
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
@@ -5060,7 +5051,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
octx.save()
|
||||
|
||||
const scale = window.devicePixelRatio
|
||||
const scale = overlayCanvas.width / (overlayCanvas.clientWidth || 1)
|
||||
octx.setTransform(scale, 0, 0, scale, 0, 0)
|
||||
|
||||
this.ds.toCanvasContext(octx)
|
||||
@@ -6407,7 +6398,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
options
|
||||
)
|
||||
) {
|
||||
node.pos[0] -= node.size[0] * 0.5
|
||||
node.setPos(node.pos[0] - node.size[0] * 0.5, node.pos[1])
|
||||
}
|
||||
})
|
||||
break
|
||||
@@ -8347,7 +8338,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
callback: LGraphCanvas.onMenuNodeCollapse
|
||||
})
|
||||
}
|
||||
if (node.widgets?.some((w) => w.advanced)) {
|
||||
if (node.hasAdvancedWidgets()) {
|
||||
options.push({
|
||||
content: node.showAdvanced ? 'Hide Advanced' : 'Show Advanced',
|
||||
callback: LGraphCanvas.onMenuToggleAdvanced
|
||||
@@ -8695,27 +8686,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* Apply batched node position updates
|
||||
*/
|
||||
private applyNodePositionUpdates(
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>,
|
||||
mutations: ReturnType<typeof useLayoutMutations>
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
|
||||
): void {
|
||||
for (const { node, newPos } of nodesToMove) {
|
||||
// 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)
|
||||
// setPos automatically syncs to layout store
|
||||
node.setPos(newPos.x, newPos.y)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -8763,7 +8741,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
) {
|
||||
const mutations = this.initLayoutMutations()
|
||||
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
|
||||
const nodesToMove: NewNodePosition[] = []
|
||||
|
||||
@@ -8789,12 +8766,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
// Now apply all the node moves at once
|
||||
this.applyNodePositionUpdates(nodesToMove, mutations)
|
||||
this.applyNodePositionUpdates(nodesToMove)
|
||||
}
|
||||
|
||||
repositionNodesVueMode(nodesToReposition: NewNodePosition[]) {
|
||||
const mutations = this.initLayoutMutations()
|
||||
this.applyNodePositionUpdates(nodesToReposition, mutations)
|
||||
this.applyNodePositionUpdates(nodesToReposition)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
@@ -42,6 +44,7 @@ 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,7 +10,11 @@ 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, toClass } from '@/lib/litegraph/src/utils/type'
|
||||
import {
|
||||
commonType,
|
||||
isNodeBindable,
|
||||
toClass
|
||||
} from '@/lib/litegraph/src/utils/type'
|
||||
|
||||
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type { DragAndScale } from './DragAndScale'
|
||||
@@ -486,6 +490,17 @@ 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() {
|
||||
@@ -497,6 +512,13 @@ 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]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1957,6 +1979,14 @@ 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
|
||||
}
|
||||
|
||||
@@ -2020,8 +2050,7 @@ export class LGraphNode
|
||||
return
|
||||
}
|
||||
|
||||
this.pos[0] += deltaX
|
||||
this.pos[1] += deltaY
|
||||
this.pos = [this._pos[0] + deltaX, this._pos[1] + deltaY]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3499,7 +3528,7 @@ export class LGraphNode
|
||||
* Toggles advanced mode of the node, showing advanced widgets
|
||||
*/
|
||||
toggleAdvanced() {
|
||||
if (!this.widgets?.some((w) => w.advanced)) return
|
||||
if (!this.hasAdvancedWidgets()) return
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
this.graph._version++
|
||||
this.showAdvanced = !this.showAdvanced
|
||||
@@ -3877,6 +3906,21 @@ 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)
|
||||
@@ -4087,7 +4131,7 @@ export class LGraphNode
|
||||
w: IBaseWidget
|
||||
}[] = []
|
||||
|
||||
const visibleWidgets = this.widgets.filter((w) => !w.hidden)
|
||||
const visibleWidgets = this.getLayoutWidgets()
|
||||
|
||||
for (const w of visibleWidgets) {
|
||||
if (w.computeSize) {
|
||||
|
||||