Compare commits

..

21 Commits

Author SHA1 Message Date
Terry Jia
cb3c415065 [3d] flash preview screen board if reach out limitation 2025-02-19 15:30:38 -05:00
Terry Jia
d3ab23a532 [3d] flash preview screen board if reach out limitation 2025-02-19 15:27:22 -05:00
filtered
08a6867c00 [Desktop] Offer Troubleshoot page instead of Reinstall on start error (#2623)
Co-authored-by: github-actions <github-actions@github.com>
2025-02-19 10:30:23 -05:00
filtered
dbbe67dfcd [Desktop] Fix missing git logo in troubleshooting (#2633) 2025-02-19 10:29:48 -05:00
bymyself
40fa1d37bc Fix pasting image that was copied from browser (#2630) 2025-02-19 10:27:58 -05:00
filtered
0d6bc669f5 [Desktop] Fix invalid type assertion in API (#2631) 2025-02-19 21:59:17 +11:00
Chenlei Hu
e4444d4074 1.10.6 (#2628) 2025-02-18 20:33:58 -05:00
Chenlei Hu
cbf5dff633 Update litegraph 0.8.87 (#2625)
Co-authored-by: github-actions <github-actions@github.com>
2025-02-18 20:25:17 -05:00
Chenlei Hu
9de8450deb Update test expectations (#2627)
Co-authored-by: github-actions <github-actions@github.com>
2025-02-18 20:25:06 -05:00
Chenlei Hu
3b0e3d635b [BugFix] Fix node color for custom light themes (#2621)
Co-authored-by: github-actions <github-actions@github.com>
2025-02-18 19:08:34 -05:00
Chenlei Hu
d1a682bc01 [Refactor] Extract color selector as component (#2620) 2025-02-18 15:28:17 -05:00
Terry Jia
01ffc9e4eb [3d] allow using mouse wheel to adjust preview screen size (#2619) 2025-02-18 14:59:43 -05:00
Chenlei Hu
54e42178f7 1.10.5 (#2617) 2025-02-18 12:26:27 -05:00
Chenlei Hu
25e5ab3a36 Add bypass action to selection toolbox (#2616) 2025-02-18 12:25:49 -05:00
Chenlei Hu
28dd6a2702 Update litegraph 0.8.85 (#2615) 2025-02-18 11:51:36 -05:00
bymyself
3b3df250cd Add refresh button to selecton toolbox (#2612) 2025-02-18 11:39:43 -05:00
bymyself
6441a86619 [Style] Update toolbox style (#2614) 2025-02-18 11:34:06 -05:00
Chenlei Hu
79db202925 [New Feature] Selection Toolbox (#2608)
Co-authored-by: github-actions <github-actions@github.com>
2025-02-17 19:07:49 -05:00
Chenlei Hu
f7556e0015 Add DeleteSelectedItems command (#2606) 2025-02-17 17:16:12 -05:00
bymyself
141e64354c Support batch image upload (#2597) 2025-02-17 13:56:21 -05:00
bymyself
79452ce267 Fix extraneous values in template workflows (#2605) 2025-02-17 13:55:29 -05:00
75 changed files with 1032 additions and 721 deletions

View File

@@ -0,0 +1,63 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "UNKNOWN NODE",
"pos": [
48,
86
],
"size": {
"0": 358.80780029296875,
"1": 314.7989501953125
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null,
"slot_index": 0
},
{
"name": "foo",
"type": "STRING",
"link": null,
"slot_index": 1,
"widget": {
"name": "foo"
}
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [],
"slot_index": 0,
"shape": 6
}
],
"properties": {
"Node name for S&R": "UNKNOWN NODE"
},
"widgets_values": [
"wd-v1-4-moat-tagger-v2",
0.35,
0.85,
false,
false,
""
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -152,9 +152,10 @@ test.describe('Color Palette', () => {
// doesn't update the store immediately.
await comfyPage.setup()
await comfyPage.loadWorkflow('every_node_color')
await comfyPage.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
'custom-color-palette-obsidian-dark-all-colors.png'
)
await comfyPage.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.nextFrame()
@@ -232,7 +233,7 @@ test.describe('Node Color Adjustments', () => {
const workflow = await comfyPage.page.evaluate(() => {
return localStorage.getItem('workflow')
})
for (const node of JSON.parse(workflow).nodes) {
for (const node of JSON.parse(workflow ?? '{}').nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 135 KiB

View File

@@ -1,3 +1,4 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test as base } from '@playwright/test'
@@ -646,6 +647,18 @@ export class ComfyPage {
await this.nextFrame()
}
async selectNodes(nodeTitles: string[]) {
await this.page.keyboard.down('Control')
for (const nodeTitle of nodeTitles) {
const nodes = await this.getNodeRefsByTitle(nodeTitle)
for (const node of nodes) {
await node.click('title')
}
}
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async select2Nodes() {
// Select 2 CLIP nodes.
await this.page.keyboard.down('Control')
@@ -835,12 +848,24 @@ export class ComfyPage {
(
await this.page.evaluate((type) => {
return window['app'].graph.nodes
.filter((n) => n.type === type)
.map((n) => n.id)
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
}, type)
).map((id: NodeId) => this.getNodeRefById(id))
)
}
async getNodeRefsByTitle(title: string): Promise<NodeReference[]> {
return Promise.all(
(
await this.page.evaluate((title) => {
return window['app'].graph.nodes
.filter((n: LGraphNode) => n.title === title)
.map((n: LGraphNode) => n.id)
}, title)
).map((id: NodeId) => this.getNodeRefById(id))
)
}
async getFirstNodeRef(): Promise<NodeReference | null> {
const id = await this.page.evaluate(() => {
return window['app'].graph.nodes[0]?.id
@@ -896,9 +921,10 @@ export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
try {
await comfyPage.setupSettings({
'Comfy.UseNewMenu': 'Disabled',
// Hide canvas menu/info by default.
// Hide canvas menu/info/selection toolbox by default.
'Comfy.Graph.CanvasInfo': false,
'Comfy.Graph.CanvasMenu': false,
'Comfy.Canvas.SelectionToolbox': false,
// Hide all badges by default.
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,

View File

@@ -53,4 +53,11 @@ test.describe('Optional input', () => {
await comfyPage.loadWorkflow('simple_slider')
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
})
test('unknown converted widget', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Workflow.ShowMissingNodesWarning', false)
await comfyPage.loadWorkflow('missing_nodes_converted_widget')
await expect(comfyPage.canvas).toHaveScreenshot(
'missing_nodes_converted_widget.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -176,6 +176,23 @@ test.describe('Remote COMBO Widget', () => {
})
test.describe('Refresh Behavior', () => {
test('refresh button is visible in selection toolbar when node is selected', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
const nodeName = 'Remote Widget Node'
await addRemoteWidgetNode(comfyPage, nodeName)
await waitForWidgetUpdate(comfyPage)
// Select remote widget node
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator('.selection-toolbox .pi-refresh')
).toBeVisible()
})
test('refreshes options when TTL expires', async ({ comfyPage }) => {
// Fulfill each request with a unique timestamp
await comfyPage.page.route(

View File

@@ -0,0 +1,94 @@
import { expect } from '@playwright/test'
import { comfyPageFixture } from './fixtures/ComfyPage'
const test = comfyPageFixture
test.describe('Selection Toolbox', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
})
test('shows selection toolbox', async ({ comfyPage }) => {
// By default, selection toolbox should be enabled
expect(
await comfyPage.page.locator('.selection-overlay-container').isVisible()
).toBe(false)
// Select multiple nodes
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
// Selection toolbox should be visible with multiple nodes selected
await expect(
comfyPage.page.locator('.selection-overlay-container')
).toBeVisible()
await expect(
comfyPage.page.locator('.selection-overlay-container.show-border')
).toBeVisible()
})
test('shows border only with multiple selections', async ({ comfyPage }) => {
// Select single node
await comfyPage.selectNodes(['KSampler'])
// Selection overlay should be visible but without border
await expect(
comfyPage.page.locator('.selection-overlay-container')
).toBeVisible()
await expect(
comfyPage.page.locator('.selection-overlay-container.show-border')
).not.toBeVisible()
// Select multiple nodes
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
// Selection overlay should show border with multiple selections
await expect(
comfyPage.page.locator('.selection-overlay-container.show-border')
).toBeVisible()
// Deselect to single node
await comfyPage.selectNodes(['CLIP Text Encode (Prompt)'])
// Border should be hidden again
await expect(
comfyPage.page.locator('.selection-overlay-container.show-border')
).not.toBeVisible()
})
test('displays refresh button in toolbox when all nodes are selected', async ({
comfyPage
}) => {
// Select all nodes
await comfyPage.page.focus('canvas')
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator('.selection-toolbox .pi-refresh')
).toBeVisible()
})
test('displays bypass button in toolbox when nodes are selected', async ({
comfyPage
}) => {
// A group + a KSampler node
await comfyPage.loadWorkflow('single_group')
// Select group + node should show bypass button
await comfyPage.page.focus('canvas')
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeVisible()
// Deselect node (Only group is selected) should hide bypass button
await comfyPage.selectNodes(['KSampler'])
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).not.toBeVisible()
})
})

20
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.10.4",
"version": "1.10.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.10.4",
"version": "1.10.6",
"license": "GPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.16",
"@comfyorg/litegraph": "^0.8.83",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.8.87",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -1938,15 +1938,15 @@
"dev": true
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.16.tgz",
"integrity": "sha512-AKy4WLVAuDka/Xjv8zrKwfU/wfRSQpFVE5DgxoLfvroCI0sw+rV1JqdL6xFVrYIoeprzbfKhQiyqlAWU+QgHyg==",
"version": "0.4.20",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.20.tgz",
"integrity": "sha512-JFKGk9wSx7CcYh9MRNo7bqTLJwQzVc+1Xg8V2Ghn9BS3RzpmkfktaWHi+waU7/CRQMzvjF+mnDPP58xk1xbVhA==",
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.83",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.83.tgz",
"integrity": "sha512-4ZfRk0mBcCStY2yRERCrguwFf5v6WajD/6/JEmycD3HnF4OwYgyAspMYrscJcQ/R2MXfnedGe1gi8WXQ955vEQ==",
"version": "0.8.87",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.87.tgz",
"integrity": "sha512-hEBe8Cc8C3PkWLfUxxhuO7zitYYCq3dO9mX8DfoK6On8EBE+1UijugVKfTWHuB/Yii4rN8yck/CI9yOYvCuD7Q==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.10.4",
"version": "1.10.6",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -83,8 +83,8 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.16",
"@comfyorg/litegraph": "^0.8.83",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.8.87",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -455,11 +455,6 @@
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors?download=true",
"directory": "text_encoders"
},
{
"name": "flux1-dev-fp8.safetensors",
"url": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors?download=true",
"directory": "checkpoints"
},
{
"name": "ae.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/ae.safetensors?download=true",

View File

@@ -430,11 +430,6 @@
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors?download=true",
"directory": "text_encoders"
},
{
"name": "flux1-dev-fp8.safetensors",
"url": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors?download=true",
"directory": "checkpoints"
},
{
"name": "ae.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/ae.safetensors?download=true",

View File

@@ -463,7 +463,7 @@
"properties": {
"Node name for S&R": "UNETLoader"
},
"widgets_values": ["flux1-dev-fp8.safetensors", "default"],
"widgets_values": ["flux1-dev.safetensors", "default"],
"color": "#223",
"bgcolor": "#335"
},
@@ -542,7 +542,7 @@
"text": ""
},
"widgets_values": [
"If you get an error in any of the nodes above make sure the files are in the correct directories.\n\nSee the top of the examples page for the links : https://comfyanonymous.github.io/ComfyUI_examples/flux/\n\nflux1-dev-fp8.safetensors goes in: ComfyUI/models/unet/\n\nt5xxl_fp16.safetensors and clip_l.safetensors go in: ComfyUI/models/clip/\n\nae.safetensors goes in: ComfyUI/models/vae/\n\n\nTip: You can set the weight_dtype above to one of the fp8 types if you have memory issues."
"If you get an error in any of the nodes above make sure the files are in the correct directories.\n\nSee the top of the examples page for the links : https://comfyanonymous.github.io/ComfyUI_examples/flux/\n\nflux1-dev.safetensors goes in: ComfyUI/models/unet/\n\nt5xxl_fp16.safetensors and clip_l.safetensors go in: ComfyUI/models/clip/\n\nae.safetensors goes in: ComfyUI/models/vae/\n\n\nTip: You can set the weight_dtype above to one of the fp8 types if you have memory issues."
],
"color": "#432",
"bgcolor": "#653"
@@ -763,8 +763,8 @@
"directory": "vae"
},
{
"name": "flux1-dev-fp8.safetensors",
"url": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors?download=true",
"name": "flux1-dev.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/flux1-dev.safetensors?download=true",
"directory": "diffusion_models"
}
]

View File

@@ -473,7 +473,7 @@
"text": ""
},
"widgets_values": [
"If you get an error in any of the nodes above make sure the files are in the correct directories.\n\nSee the top of the examples page for the links : https://comfyanonymous.github.io/ComfyUI_examples/flux/\n\nflux1-dev.safetensors goes in: ComfyUI/models/unet/\n\nt5xxl_fp16.safetensors and clip_l.safetensors go in: ComfyUI/models/clip/\n\nae.safetensors goes in: ComfyUI/models/vae/\n\n\nTip: You can set the weight_dtype above to one of the fp8 types if you have memory issues."
"If you get an error in any of the nodes above make sure the files are in the correct directories.\n\nSee the top of the examples page for the links : https://comfyanonymous.github.io/ComfyUI_examples/flux/\n\nflux1-dev.safetensors goes in: ComfyUI/models/diffusion_models/\n\nt5xxl_fp16.safetensors and clip_l.safetensors go in: ComfyUI/models/text_encoders/\n\nae.safetensors goes in: ComfyUI/models/vae/\n\n\nTip: You can set the weight_dtype above to one of the fp8 types if you have memory issues."
],
"color": "#432",
"bgcolor": "#653"
@@ -923,7 +923,7 @@
"directory": "text_encoders"
},
{
"name": "flux1-dev-fp8.safetensors",
"name": "flux1-dev.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/flux1-dev.safetensors?download=true",
"directory": "diffusion_models"
},
@@ -932,11 +932,6 @@
"url": "https://huggingface.co/Comfy-Org/sigclip_vision_384/resolve/main/sigclip_vision_patch14_384.safetensors?download=true",
"directory": "clip_vision"
},
{
"name": "flux1-dev-fp8.safetensors",
"url": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors?download=true",
"directory": "checkpoints"
},
{
"name": "ae.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/ae.safetensors?download=true",
@@ -944,7 +939,7 @@
},
{
"name": "flux1-redux-dev.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-Redux-dev/resolve/main/flux1-redux-dev.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-Redux-dev/resolve/main/flux1-redux-dev.safetensors?download=true",
"directory": "style_models"
},
{

View File

@@ -481,11 +481,6 @@
"url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors?download=true",
"directory": "checkpoints"
},
{
"name": "sd_xl_refiner_1.0.safetensors",
"url": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors?download=true",
"directory": "checkpoints"
},
{
"name": "clip_vision_g.safetensors",
"url": "https://huggingface.co/comfyanonymous/clip_vision_g/resolve/main/clip_vision_g.safetensors?download=true",

View File

@@ -485,11 +485,6 @@
"url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors?download=true",
"directory": "checkpoints"
},
{
"name": "sd_xl_refiner_1.0.safetensors",
"url": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors?download=true",
"directory": "checkpoints"
},
{
"name": "clip_vision_g.safetensors",
"url": "https://huggingface.co/comfyanonymous/clip_vision_g/resolve/main/clip_vision_g.safetensors?download=true",

View File

@@ -1,425 +0,0 @@
{
"last_node_id": 17,
"last_link_id": 23,
"nodes": [
{
"id": 8,
"type": "VAEDecode",
"pos": [1235.7215576171875, 577.1878662109375],
"size": [210, 46],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "samples",
"localized_name": "samples",
"type": "LATENT",
"link": 7
},
{ "name": "vae", "localized_name": "vae", "type": "VAE", "link": 21 }
],
"outputs": [
{
"name": "IMAGE",
"localized_name": "IMAGE",
"type": "IMAGE",
"links": [9],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "VAEDecode" },
"widgets_values": []
},
{
"id": 10,
"type": "LatentUpscale",
"pos": [1238, 170],
"size": [315, 130],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "samples",
"localized_name": "samples",
"type": "LATENT",
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"localized_name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": { "Node name for S&R": "LatentUpscale" },
"widgets_values": ["nearest-exact", 1152, 1152, "disabled"]
},
{
"id": 13,
"type": "VAEDecode",
"pos": [1961, 125],
"size": [210, 46],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "samples",
"localized_name": "samples",
"type": "LATENT",
"link": 15
},
{ "name": "vae", "localized_name": "vae", "type": "VAE", "link": 22 }
],
"outputs": [
{
"name": "IMAGE",
"localized_name": "IMAGE",
"type": "IMAGE",
"links": [17],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "VAEDecode" },
"widgets_values": []
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [374, 171],
"size": [422.84503173828125, 164.31304931640625],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{ "name": "clip", "localized_name": "clip", "type": "CLIP", "link": 19 }
],
"outputs": [
{
"name": "CONDITIONING",
"localized_name": "CONDITIONING",
"type": "CONDITIONING",
"links": [4, 12],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "CLIPTextEncode" },
"widgets_values": [
"masterpiece HDR victorian portrait painting of woman, blonde hair, mountain nature, blue sky\n"
]
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [377, 381],
"size": [425.27801513671875, 180.6060791015625],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{ "name": "clip", "localized_name": "clip", "type": "CLIP", "link": 20 }
],
"outputs": [
{
"name": "CONDITIONING",
"localized_name": "CONDITIONING",
"type": "CONDITIONING",
"links": [6, 13],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "CLIPTextEncode" },
"widgets_values": ["bad hands, text, watermark\n"]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [435, 600],
"size": [315, 106],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"localized_name": "LATENT",
"type": "LATENT",
"links": [2],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "EmptyLatentImage" },
"widgets_values": [768, 768, 1]
},
{
"id": 11,
"type": "KSampler",
"pos": [1585, 114],
"size": [315, 262],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "model",
"localized_name": "model",
"type": "MODEL",
"link": 23,
"slot_index": 0
},
{
"name": "positive",
"localized_name": "positive",
"type": "CONDITIONING",
"link": 12,
"slot_index": 1
},
{
"name": "negative",
"localized_name": "negative",
"type": "CONDITIONING",
"link": 13,
"slot_index": 2
},
{
"name": "latent_image",
"localized_name": "latent_image",
"type": "LATENT",
"link": 14,
"slot_index": 3
}
],
"outputs": [
{
"name": "LATENT",
"localized_name": "LATENT",
"type": "LATENT",
"links": [15],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [
469771404043268,
"randomize",
14,
8,
"dpmpp_2m",
"simple",
0.5
]
},
{
"id": 12,
"type": "SaveImage",
"pos": [2203, 123],
"size": [407.53717041015625, 468.13226318359375],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "images",
"localized_name": "images",
"type": "IMAGE",
"link": 17
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 3,
"type": "KSampler",
"pos": [845, 172],
"size": [315, 262],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "model",
"localized_name": "model",
"type": "MODEL",
"link": 18
},
{
"name": "positive",
"localized_name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"localized_name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"localized_name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"localized_name": "LATENT",
"type": "LATENT",
"links": [7, 10],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [
89848141647836,
"randomize",
12,
8,
"dpmpp_sde",
"normal",
1
]
},
{
"id": 16,
"type": "CheckpointLoaderSimple",
"pos": [24, 315],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"localized_name": "MODEL",
"type": "MODEL",
"links": [18, 23],
"slot_index": 0
},
{
"name": "CLIP",
"localized_name": "CLIP",
"type": "CLIP",
"links": [19, 20],
"slot_index": 1
},
{
"name": "VAE",
"localized_name": "VAE",
"type": "VAE",
"links": [21, 22],
"slot_index": 2
}
],
"properties": { "Node name for S&R": "CheckpointLoaderSimple" },
"widgets_values": ["v2-1_768-ema-pruned.safetensors"]
},
{
"id": 9,
"type": "SaveImage",
"pos": [1495.7215576171875, 576.1878662109375],
"size": [232.94032287597656, 282.4336242675781],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "images",
"localized_name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 17,
"type": "MarkdownNote",
"pos": [0, 795],
"size": [225, 60],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"🛈 [Learn more about this workflow](https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/)"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[2, 5, 0, 3, 3, "LATENT"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 3, 0, 10, 0, "LATENT"],
[12, 6, 0, 11, 1, "CONDITIONING"],
[13, 7, 0, 11, 2, "CONDITIONING"],
[14, 10, 0, 11, 3, "LATENT"],
[15, 11, 0, 13, 0, "LATENT"],
[17, 13, 0, 12, 0, "IMAGE"],
[18, 16, 0, 3, 0, "MODEL"],
[19, 16, 1, 6, 0, "CLIP"],
[20, 16, 1, 7, 0, "CLIP"],
[21, 16, 2, 8, 1, "VAE"],
[22, 16, 2, 13, 1, "VAE"],
[23, 16, 0, 11, 0, "MODEL"]
],
"groups": [
{
"id": 1,
"title": "Txt2Img",
"bounding": [0, 30, 1211, 708],
"color": "#a1309b",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Save Intermediate Image",
"bounding": [1230, 495, 516, 196],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Hires Fix",
"bounding": [1230, 30, 710, 464],
"color": "#b58b2a",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "Save Final Image",
"bounding": [1950, 30, 483, 199],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.8037574853834974,
"offset": [540.0834501660246, 269.28523360433144]
}
},
"version": 0.4
}

View File

@@ -13,7 +13,7 @@
:aria-label="$t('menu.showMenu')"
aria-live="assertive"
@click="exitFocusMode"
@contextmenu="showNativeMenu"
@contextmenu="showNativeSystemMenu"
/>
<div v-show="menuSetting !== 'Bottom'" class="window-actions-spacer" />
</div>
@@ -26,7 +26,7 @@ import { CSSProperties, computed, watchEffect } from 'vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { showNativeMenu } from '@/utils/envUtil'
import { showNativeSystemMenu } from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()

View File

@@ -0,0 +1,83 @@
<template>
<div
class="color-customization-selector-container flex flex-row items-center gap-2"
>
<SelectButton
v-model="selectedColorOption"
:options="colorOptionsWithCustom"
optionLabel="name"
dataKey="value"
:allow-empty="false"
>
<template #option="slotProps">
<div
v-if="slotProps.option.name !== '_custom'"
:style="{
width: '20px',
height: '20px',
backgroundColor: slotProps.option.value,
borderRadius: '50%'
}"
></div>
<i v-else class="pi pi-palette text-lg"></i>
</template>
</SelectButton>
<ColorPicker
v-if="selectedColorOption.name === '_custom'"
v-model="customColorValue"
/>
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import SelectButton from 'primevue/selectbutton'
import { computed, onMounted, ref, watch } from 'vue'
const { modelValue, colorOptions } = defineProps<{
modelValue: string | null
colorOptions: { name: Exclude<string, '_custom'>; value: string }[]
}>()
const customColorOption = { name: '_custom', value: '' }
const colorOptionsWithCustom = computed(() => [
...colorOptions,
customColorOption
])
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const selectedColorOption = ref(customColorOption)
const customColorValue = ref('')
// Initialize the component with the provided modelValue
onMounted(() => {
if (modelValue) {
const predefinedColor = colorOptions.find((opt) => opt.value === modelValue)
if (predefinedColor) {
selectedColorOption.value = predefinedColor
} else {
selectedColorOption.value = customColorOption
customColorValue.value = modelValue.replace('#', '')
}
}
})
// Watch for changes in selection and emit updates
watch(selectedColorOption, (newOption, oldOption) => {
if (newOption.name === '_custom') {
// Inherit the color from previous selection
customColorValue.value = oldOption.value.replace('#', '')
} else {
emit('update:modelValue', newOption.value)
}
})
watch(customColorValue, (newValue) => {
if (selectedColorOption.value.name === '_custom') {
emit('update:modelValue', newValue ? `#${newValue}` : null)
}
})
</script>

View File

@@ -20,37 +20,10 @@
<Divider />
<div class="field color-field">
<label for="color">{{ $t('g.color') }}</label>
<div class="color-picker-container">
<SelectButton
v-model="selectedColor"
:options="colorOptions"
optionLabel="name"
dataKey="value"
:allow-empty="false"
>
<template #option="slotProps">
<div
v-if="slotProps.option.value !== 'custom'"
:style="{
width: '20px',
height: '20px',
backgroundColor: slotProps.option.value,
borderRadius: '50%'
}"
></div>
<i
v-else
class="pi pi-palette"
:style="{ fontSize: '1.2rem' }"
v-tooltip="$t('color.custom')"
></i>
</template>
</SelectButton>
<ColorPicker
v-if="selectedColor.value === 'custom'"
v-model="customColor"
/>
</div>
<ColorCustomizationSelector
v-model="finalColor"
:color-options="colorOptions"
/>
</div>
</div>
<template #footer>
@@ -72,13 +45,13 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ColorPicker from 'primevue/colorpicker'
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorCustomizationSelector from '@/components/common/ColorCustomizationSelector.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const { t } = useI18n()
@@ -118,29 +91,24 @@ const colorOptions = [
{ name: t('color.green'), value: '#28a745' },
{ name: t('color.red'), value: '#dc3545' },
{ name: t('color.pink'), value: '#e83e8c' },
{ name: t('color.yellow'), value: '#ffc107' },
{ name: t('color.custom'), value: 'custom' }
{ name: t('color.yellow'), value: '#ffc107' }
]
const defaultIcon = iconOptions.find(
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
)
const defaultColor = colorOptions.find(
(option) => option.value === nodeBookmarkStore.defaultBookmarkColor
)
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const selectedColor = ref<{ name: string; value: string }>(defaultColor)
const finalColor = computed(() =>
selectedColor.value.value === 'custom'
? `#${customColor.value}`
: selectedColor.value.value
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const customColor = ref('000000')
const closeDialog = () => {
visible.value = false
const resetCustomization = () => {
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
}
const confirmCustomization = () => {
@@ -148,21 +116,8 @@ const confirmCustomization = () => {
closeDialog()
}
const resetCustomization = () => {
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon
const colorOption = colorOptions.find(
(option) => option.value === props.initialColor
)
if (!props.initialColor) {
selectedColor.value = defaultColor
} else if (!colorOption) {
customColor.value = props.initialColor.replace('#', '')
selectedColor.value = { name: t('color.custom'), value: 'custom' }
} else {
selectedColor.value = colorOption
}
const closeDialog = () => {
visible.value = false
}
watch(
@@ -190,10 +145,4 @@ watch(
flex-direction: column;
gap: 0.5rem;
}
.color-picker-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,129 @@
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import SelectButton from 'primevue/selectbutton'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import ColorCustomizationSelector from '../ColorCustomizationSelector.vue'
describe('ColorCustomizationSelector', () => {
const colorOptions = [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
]
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions,
...props
}
})
}
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(colorOptions.length + 1)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
})
})

View File

@@ -51,7 +51,8 @@ const allowedSources = [
const allowedSuffixes = ['.safetensors', '.sft']
// Models that fail above conditions but are still allowed
const whiteListedUrls = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt'
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true'
])
interface ModelInfo {

View File

@@ -28,9 +28,8 @@
class="w-full h-full touch-none"
/>
<NodeSearchboxPopover />
<SelectionOverlay>
<!-- Placeholder for selection overlay testing. -->
<!-- <div class="w-full h-full bg-red-500"></div> -->
<SelectionOverlay v-if="selectionToolboxEnabled">
<SelectionToolbox />
</SelectionOverlay>
<NodeTooltip v-if="tooltipEnabled" />
<NodeBadge />
@@ -45,10 +44,12 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeBadge from '@/components/graph/NodeBadge.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
@@ -87,6 +88,9 @@ const canvasMenuEnabled = computed(() =>
settingStore.get('Comfy.Graph.CanvasMenu')
)
const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -192,6 +196,11 @@ onMounted(async () => {
comfyAppReady.value = true
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()
)
// Load color palette
colorPaletteStore.customPalettes = settingStore.get(
'Comfy.CustomColorPalettes'

View File

@@ -1,7 +1,10 @@
<!-- This component is used to bound the selected items on the canvas. -->
<template>
<div
class="selection-overlay-container pointer-events-none"
class="selection-overlay-container pointer-events-none z-40"
:class="{
'show-border': showBorder
}"
:style="style"
v-show="visible"
>
@@ -22,9 +25,12 @@ const canvasStore = useCanvasStore()
const { style, updatePosition } = useAbsolutePosition()
const visible = ref(false)
const showBorder = ref(false)
const positionSelectionOverlay = (canvas: LGraphCanvas) => {
const selectedItems = canvas.selectedItems
showBorder.value = selectedItems.size > 1
if (!selectedItems.size) {
visible.value = false
return
@@ -82,4 +88,8 @@ watch(
.selection-overlay-container > * {
pointer-events: auto;
}
.show-border {
@apply border-dashed rounded-md border-2 border-[var(--border-color)];
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<Panel
class="selection-toolbox absolute left-1/2 rounded-lg"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
}"
>
<Button
v-if="nodeSelected"
severity="secondary"
text
@click="
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
"
data-testid="bypass-button"
>
<template #icon>
<i-game-icons:detour />
</template>
</Button>
<Button
severity="secondary"
text
icon="pi pi-thumbtack"
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
/>
<Button
severity="danger"
text
icon="pi pi-trash"
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
/>
<Button
v-if="isRefreshable"
severity="info"
text
icon="pi pi-refresh"
@click="refreshSelected"
/>
</Panel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Panel from 'primevue/panel'
import { computed } from 'vue'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { isRefreshable, refreshSelected } = useRefreshableSelection()
const nodeSelected = computed(() =>
canvasStore.selectedItems.some(isLGraphNode)
)
</script>
<style scoped>
.selection-toolbox {
transform: translateX(-50%) translateY(-120%);
}
</style>

View File

@@ -21,7 +21,7 @@
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
:aria-label="$t('menu.hideMenu')"
@click="workspaceState.focusMode = true"
@contextmenu="showNativeMenu"
@contextmenu="showNativeSystemMenu"
/>
<div
v-show="menuSetting !== 'Bottom'"
@@ -52,7 +52,7 @@ import {
electronAPI,
isElectron,
isNativeWindow,
showNativeMenu
showNativeSystemMenu
} from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()

View File

@@ -366,6 +366,7 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.11',
function: () => {
toggleSelectedNodesMode(LGraphEventMode.NEVER)
app.canvas.setDirty(true, true)
}
},
{
@@ -375,6 +376,7 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.11',
function: () => {
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true)
}
},
{
@@ -386,6 +388,7 @@ export function useCoreCommands(): ComfyCommand[] {
getSelectedNodes().forEach((node) => {
node.pin(!node.pinned)
})
app.canvas.setDirty(true, true)
}
},
{
@@ -399,6 +402,7 @@ export function useCoreCommands(): ComfyCommand[] {
item.pin(!item.pinned)
}
}
app.canvas.setDirty(true, true)
}
},
{
@@ -410,6 +414,7 @@ export function useCoreCommands(): ComfyCommand[] {
getSelectedNodes().forEach((node) => {
node.collapse()
})
app.canvas.setDirty(true, true)
}
},
{
@@ -566,6 +571,16 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
window.open('https://forum.comfy.org/', '_blank')
}
},
{
id: 'Comfy.Canvas.DeleteSelectedItems',
icon: 'pi pi-trash',
label: 'Delete Selected Items',
versionAdded: '1.10.5',
function: () => {
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}
}
]
}

View File

@@ -0,0 +1,45 @@
interface FileInputOptions {
accept?: string
allow_batch?: boolean
fileFilter?: (file: File) => boolean
onSelect: (files: File[]) => void
}
/**
* Creates a file input for a node.
*/
export function useNodeFileInput(options: FileInputOptions) {
const {
accept,
allow_batch = false,
fileFilter = () => true,
onSelect
} = options
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = accept ?? '*'
fileInput.multiple = allow_batch
fileInput.style.visibility = 'hidden'
fileInput.onchange = () => {
if (fileInput.files?.length) {
const files = Array.from(fileInput.files).filter(fileFilter)
if (files.length) onSelect(files)
}
}
document.body.append(fileInput)
/**
* Shows the system file picker dialog for selecting files.
*/
function openFileSelection() {
fileInput.click()
}
return {
fileInput,
openFileSelection
}
}

View File

@@ -2,25 +2,16 @@ import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
interface NodeImageOptions {
allowBatch?: boolean
}
/**
* Attaches a preview image to a node.
*/
export const useNodeImage = (node: LGraphNode, options: NodeImageOptions) => {
const { allowBatch = false } = options
export const useNodeImage = (node: LGraphNode) => {
const nodeOutputStore = useNodeOutputStore()
/** Displays output image(s) on the node. */
function showImage(output: string | string[]) {
if (!output) return
if (allowBatch || typeof output === 'string') {
nodeOutputStore.setNodeOutputs(node, output)
} else {
nodeOutputStore.setNodeOutputs(node, output[0])
}
nodeOutputStore.setNodeOutputs(node, output)
node.setSizeForImage?.()
node.graph?.setDirtyCanvas(true)
}

View File

@@ -1,21 +1,14 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeDragAndDrop } from '@/composables/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/useNodeFileInput'
import { useNodePaste } from '@/composables/useNodePaste'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
import { useNodeDragAndDrop } from './useNodeDragAndDrop'
import { useNodePaste } from './useNodePaste'
const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp'
const PASTED_IMAGE_EXPIRY_MS = 2000
const createFileInput = () => {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = ACCEPTED_IMAGE_TYPES
return fileInput
}
const uploadFile = async (file: File, isPasted: boolean) => {
const body = new FormData()
body.append('image', file)
@@ -38,13 +31,17 @@ const uploadFile = async (file: File, isPasted: boolean) => {
interface ImageUploadOptions {
fileFilter?: (file: File) => boolean
onUploadComplete: (paths: string[]) => void
allow_batch?: boolean
}
/**
* Adds image upload to a node via drag & drop, paste, and file input.
*/
export const useNodeImageUpload = (
node: LGraphNode,
options: ImageUploadOptions
) => {
const { fileFilter = () => true, onUploadComplete } = options
const { fileFilter, onUploadComplete, allow_batch } = options
const isPastedFile = (file: File): boolean =>
file.name === 'image.png' &&
@@ -60,48 +57,33 @@ export const useNodeImageUpload = (
}
}
const handleUploadBatch = async (files: File[]) => {
const paths = await Promise.all(files.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) onUploadComplete(validPaths)
return validPaths
}
// Handle drag & drop
useNodeDragAndDrop(node, {
fileFilter,
onDrop: async (files) => {
const paths = await Promise.all(files.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) {
onUploadComplete(validPaths)
}
return validPaths
}
onDrop: handleUploadBatch
})
// Handle paste
useNodePaste(node, {
fileFilter,
onPaste: async (file) => {
const path = await handleUpload(file)
if (path) {
onUploadComplete([path])
}
return path
}
allow_batch,
onPaste: handleUploadBatch
})
// Handle file input
const fileInput = createFileInput()
fileInput.onchange = async () => {
if (fileInput.files?.length) {
const paths = await Promise.all(
Array.from(fileInput.files).filter(fileFilter).map(handleUpload)
)
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) {
onUploadComplete(validPaths)
}
}
}
document.body.append(fileInput)
const { openFileSelection } = useNodeFileInput({
fileFilter,
allow_batch,
accept: ACCEPTED_IMAGE_TYPES,
onSelect: handleUploadBatch
})
return {
fileInput,
handleUpload
}
return { openFileSelection, handleUpload }
}

View File

@@ -1,10 +1,11 @@
import type { LGraphNode } from '@comfyorg/litegraph'
type PasteHandler<T> = (file: File) => Promise<T>
type PasteHandler<T> = (files: File[]) => Promise<T>
interface NodePasteOptions<T> {
onPaste: PasteHandler<T>
fileFilter?: (file: File) => boolean
allow_batch?: boolean
}
/**
@@ -14,12 +15,15 @@ export const useNodePaste = <T>(
node: LGraphNode,
options: NodePasteOptions<T>
) => {
const { onPaste, fileFilter = () => true } = options
const { onPaste, fileFilter = () => true, allow_batch = false } = options
node.pasteFile = function (file: File) {
if (!fileFilter(file)) return false
node.pasteFiles = function (files: File[]) {
const filteredFiles = Array.from(files).filter(fileFilter)
if (!filteredFiles.length) return false
onPaste(file).then((result) => {
const paste = allow_batch ? filteredFiles : filteredFiles.slice(0, 1)
onPaste(paste).then((result) => {
if (!result) return
})
return true

View File

@@ -28,7 +28,7 @@ export const usePaste = () => {
// Did you mean 'Clipboard'?ts(2551)
// TODO: Not sure what the code wants to do.
let data = e.clipboardData || window.clipboardData
const items = data.items
const items: DataTransferItemList = data.items
// Look for image paste data
for (const item of items) {
@@ -54,7 +54,13 @@ export const usePaste = () => {
graph.change()
}
const blob = item.getAsFile()
imageNode?.pasteFile?.(blob)
if (blob) imageNode?.pasteFile?.(blob)
imageNode?.pasteFiles?.(
Array.from(items)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
return
}
}

View File

@@ -0,0 +1,57 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IWidget } from '@comfyorg/litegraph'
import { computed, ref, watchEffect } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
interface RefreshableItem {
refresh: () => Promise<void> | void
}
type RefreshableWidget = IWidget & RefreshableItem
const isRefreshableWidget = (widget: IWidget): widget is RefreshableWidget =>
'refresh' in widget && typeof widget.refresh === 'function'
/**
* Tracks selected nodes and their refreshable widgets
*/
export const useRefreshableSelection = () => {
const graphStore = useCanvasStore()
const commandStore = useCommandStore()
const selectedNodes = ref<LGraphNode[]>([])
const isAllNodesSelected = ref(false)
watchEffect(() => {
selectedNodes.value = graphStore.selectedItems.filter(isLGraphNode)
isAllNodesSelected.value =
graphStore.canvas?.graph?.nodes?.every((node) => !!node.selected) ?? false
})
const refreshableWidgets = computed(() =>
selectedNodes.value.flatMap(
(node) => node.widgets?.filter(isRefreshableWidget) ?? []
)
)
const isRefreshable = computed(
() => refreshableWidgets.value.length > 0 || isAllNodesSelected.value
)
async function refreshSelected() {
if (!isRefreshable.value) return
if (isAllNodesSelected.value) {
commandStore.execute('Comfy.RefreshNodeDefinitions')
} else {
await Promise.all(refreshableWidgets.value.map((item) => item.refresh()))
}
}
return {
isRefreshable,
refreshSelected
}
}

View File

@@ -0,0 +1,31 @@
/**
* Creates a getter/setter pair that transforms values on access if they have changed.
* Does not observe deep changes.
*
* @example
* const { get, set } = useValueTransform<ResultItem[], string[]>(
* items => items.map(formatPath)
* )
*
* Object.defineProperty(obj, 'value', { get, set })
*/
export function useValueTransform<Internal, External>(
transform: (value: Internal) => External,
initialValue: Internal
) {
let internalValue: Internal = initialValue
let cachedValue: External = transform(initialValue)
let isChanged = false
return {
get: () => {
if (!isChanged) return cachedValue
cachedValue = transform(internalValue)
return cachedValue
},
set: (value: Internal) => {
isChanged = true
internalValue = value
}
}
}

View File

@@ -3,25 +3,23 @@ import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useNodeImage } from '@/composables/useNodeImage'
import { useNodeImageUpload } from '@/composables/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyApp } from '@/types'
import type { InputSpec, ResultItem } from '@/types/apiTypes'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
type InternalFile = string | ResultItem
type InternalValue = InternalFile | InternalFile[]
type ExposedValue = string | string[]
const isImageFile = (file: File) => file.type.startsWith('image/')
const findFileComboWidget = (node: LGraphNode, inputData: InputSpec) =>
node.widgets?.find(
(w) => w.name === (inputData[1]?.widget ?? 'image') && w.type === 'combo'
) as IComboWidget & { value: string }
const addToComboValues = (widget: IComboWidget, path: string) => {
if (!widget.options) widget.options = { values: [] }
if (!widget.options.values) widget.options.values = []
if (!widget.options.values.includes(path)) {
widget.options.values.push(path)
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
value: ExposedValue
}
}
export const useImageUploadWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
@@ -30,43 +28,46 @@ export const useImageUploadWidget = () => {
inputData: InputSpec,
app: ComfyApp
) => {
// TODO: specify upload widget via input spec rather than input name
const fileComboWidget = findFileComboWidget(node, inputData)
const { allow_batch, image_folder = 'input' } = inputData[1] ?? {}
const inputOptions = inputData[1] ?? {}
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const { showImage } = useNodeImage(node)
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const { showImage } = useNodeImage(node, { allowBatch: allow_batch })
const formatPath = (value: InternalFile) =>
createAnnotatedPath(value, { rootFolder: image_folder })
let internalValue: string | ResultItem = initialFile
const transform = (internalValue: InternalValue): ExposedValue => {
if (!internalValue) return initialFile
if (Array.isArray(internalValue))
return allow_batch
? internalValue.map(formatPath)
: formatPath(internalValue[0])
return formatPath(internalValue)
}
// Setup getter/setter that transforms from `ResultItem` to string and formats paths
Object.defineProperty(fileComboWidget, 'value', {
set: function (value: string | ResultItem) {
internalValue = value
},
get: function () {
if (!internalValue) return initialFile
if (typeof internalValue === 'string')
return createAnnotatedPath(internalValue, {
rootFolder: image_folder
})
if (!internalValue.filename) return initialFile
return createAnnotatedPath(internalValue)
}
})
Object.defineProperty(
fileComboWidget,
'value',
useValueTransform(transform, initialFile)
)
// Setup file upload handling
const { fileInput } = useNodeImageUpload(node, {
const { openFileSelection } = useNodeImageUpload(node, {
allow_batch,
fileFilter: isImageFile,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
fileComboWidget.value = output[0]
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = output
fileComboWidget.callback?.(output)
}
})
// Create the button widget for selecting the files
const uploadWidget = node.addWidget('button', inputName, 'image', () =>
fileInput.click()
openFileSelection()
)
uploadWidget.label = 'choose file to upload'
// @ts-expect-error serialize is not typed

View File

@@ -78,6 +78,7 @@ export function useRemoteWidget<
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(config)
let isLoaded = false
let refreshQueued = false
const setSuccess = (entry: CacheEntry<T>, data: T) => {
entry.retryCount = 0
@@ -183,12 +184,15 @@ export function useRemoteWidget<
/**
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
* Starts the fetch process then returns the cached value immediately.
* @param onFulfilled - Optional callback to be called when the fetch is resolved.
* @returns the most recent value of the widget.
*/
function getValue(onFulfilled?: () => void) {
fetchValue().then((data) => {
if (isFirstLoad()) onFirstLoad(data)
if (refreshQueued && data !== defaultValue) {
onRefresh()
refreshQueued = false
}
onFulfilled?.()
})
return getCachedValue() ?? defaultValue
@@ -197,22 +201,23 @@ export function useRemoteWidget<
/**
* Force the widget to refresh its value
*/
function refreshValue() {
widget.refresh = function () {
refreshQueued = true
clearCachedValue()
getValue(onRefresh)
getValue()
}
/**
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', refreshValue)
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
return {
getCachedValue,
getValue,
refreshValue,
refreshValue: widget.refresh,
addRefreshButton,
getCacheEntry: () => dataCache.get(cacheKey),

View File

@@ -747,5 +747,13 @@ export const CORE_SETTINGS: SettingParams[] = [
},
defaultValue: 0.6,
versionAdded: '1.9.1'
},
{
id: 'Comfy.Canvas.SelectionToolbox',
category: ['LiteGraph', 'Canvas', 'SelectionToolbox'],
name: 'Show selection toolbox',
type: 'boolean',
defaultValue: true,
versionAdded: '1.10.5'
}
]

View File

@@ -165,7 +165,7 @@ export const CORE_TEMPLATES = [
mediaType: 'image',
mediaSubtype: 'png',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/'
'https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/'
},
{
name: 'esrgan_example',
@@ -174,13 +174,6 @@ export const CORE_TEMPLATES = [
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/'
},
{
name: 'hiresfix_latent_workflow',
mediaType: 'image',
mediaSubtype: 'png',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/'
},
{
name: 'hiresfix_esrgan_workflow',
mediaType: 'image',

View File

@@ -27,7 +27,7 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
},
{
id: 'git',
headerImg: '/assets/images/Git-Logo-White.svg',
headerImg: 'assets/images/Git-Logo-White.svg',
execute: () => openUrl('https://git-scm.com/downloads/'),
name: 'Download git',
shortDescription: 'Open the git download page.',

View File

@@ -53,6 +53,7 @@ class Load3d {
targetWidth: number = 1024
targetHeight: number = 1024
showPreview: boolean = true
previewWidth: number = 120
node: LGraphNode = {} as LGraphNode
private listeners: { [key: string]: Function[] } = {}
@@ -197,19 +198,61 @@ class Load3d {
this.previewContainer = document.createElement('div')
this.previewContainer.style.cssText = `
position: absolute;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.2);
display: block;
`
position: absolute;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.2);
display: block;
transition: border-color 0.1s ease;
`
this.previewContainer.appendChild(this.previewRenderer.domElement)
const MIN_PREVIEW_WIDTH = 120
const MAX_PREVIEW_WIDTH = 240
this.previewContainer.addEventListener('wheel', (event) => {
event.preventDefault()
event.stopPropagation()
const delta = event.deltaY
const oldWidth = this.previewWidth
if (delta > 0) {
this.previewWidth = Math.max(MIN_PREVIEW_WIDTH, this.previewWidth - 10)
} else {
this.previewWidth = Math.min(MAX_PREVIEW_WIDTH, this.previewWidth + 10)
}
if (
oldWidth !== this.previewWidth &&
(this.previewWidth === MIN_PREVIEW_WIDTH ||
this.previewWidth === MAX_PREVIEW_WIDTH)
) {
this.flashPreviewBorder()
}
this.updatePreviewSize()
this.updatePreviewRender()
})
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
container.appendChild(this.previewContainer)
}
flashPreviewBorder() {
const originalBorder = this.previewContainer.style.border
const originalBoxShadow = this.previewContainer.style.boxShadow
this.previewContainer.style.border = '2px solid rgba(255, 255, 255, 0.8)'
this.previewContainer.style.boxShadow = '0 0 8px rgba(255, 255, 255, 0.5)'
setTimeout(() => {
this.previewContainer.style.border = originalBorder
this.previewContainer.style.boxShadow = originalBoxShadow
}, 100)
}
updatePreviewRender() {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
return
@@ -254,19 +297,19 @@ class Load3d {
this.previewCamera.lookAt(this.controls.target)
const previewWidth = 120
const previewHeight = (previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer.setSize(previewWidth, previewHeight, false)
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
this.previewRenderer.render(this.scene, this.previewCamera)
}
updatePreviewSize() {
if (!this.previewContainer) return
const previewWidth = 120
const previewHeight = (previewWidth * this.targetHeight) / this.targetWidth
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer?.setSize(previewWidth, previewHeight, false)
this.previewRenderer?.setSize(this.previewWidth, previewHeight, false)
}
setTargetSize(width: number, height: number) {

View File

@@ -1,22 +1,41 @@
import { ComfyNodeDef, InputSpec } from '@/types/apiTypes'
import { ComfyNodeDef, InputSpec, isComboInputSpecV1 } from '@/types/apiTypes'
import { app } from '../../scripts/app'
// Adds an upload button to the nodes
const isImageComboInput = (inputSpec: InputSpec) => {
const [inputName, inputOptions] = inputSpec
if (!inputOptions || inputOptions['image_upload'] !== true) return false
return isComboInputSpecV1(inputSpec) || inputName === 'COMBO'
}
const createUploadInput = (
imageInputName: string,
imageInputOptions: InputSpec
): InputSpec => [
'IMAGEUPLOAD',
{
...imageInputOptions[1],
imageInputName
}
]
app.registerExtension({
name: 'Comfy.UploadImage',
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef) {
// Check if there is a required input named 'image' in the nodeData
const imageInputSpec: InputSpec | undefined =
nodeData?.input?.required?.image
const { input } = nodeData ?? {}
const { required } = input ?? {}
if (!required) return
// Get the config from the image input spec if it exists
const config = imageInputSpec?.[1] ?? {}
const { image_upload = false, image_folder = 'input' } = config
const found = Object.entries(required).find(([_, input]) =>
isImageComboInput(input)
)
if (image_upload && nodeData?.input?.required) {
nodeData.input.required.upload = ['IMAGEUPLOAD', { image_folder }]
// If image combo input found, attach upload input
if (found) {
const [inputName, inputSpec] = found
required.upload = createUploadInput(inputName, inputSpec)
}
}
})

View File

@@ -35,6 +35,9 @@
"Comfy_BrowseTemplates": {
"label": "Browse Templates"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Delete Selected Items"
},
"Comfy_Canvas_FitView": {
"label": "Fit view to selected nodes"
},

View File

@@ -264,7 +264,7 @@
"updateConsent": "You previously opted in to reporting crashes. We are now tracking event-based metrics to help identify bugs and improve the app. No personal identifiable information is collected."
},
"serverStart": {
"reinstall": "Reinstall",
"troubleshoot": "Troubleshoot",
"reportIssue": "Report Issue",
"openLogs": "Open Logs",
"showTerminal": "Show Terminal",
@@ -473,6 +473,7 @@
"Reinstall": "Reinstall",
"Restart": "Restart",
"Browse Templates": "Browse Templates",
"Delete Selected Items": "Delete Selected Items",
"Fit view to selected nodes": "Fit view to selected nodes",
"Reset View": "Reset View",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",

View File

@@ -25,6 +25,9 @@
"custom": "custom"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Show selection toolbox"
},
"Comfy_ConfirmClear": {
"name": "Require confirmation when clearing workflow"
},

View File

@@ -35,6 +35,9 @@
"Comfy_BrowseTemplates": {
"label": "Parcourir les modèles"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Supprimer les éléments sélectionnés"
},
"Comfy_Canvas_FitView": {
"label": "Ajuster la vue aux nœuds sélectionnés"
},

View File

@@ -378,6 +378,7 @@
"ComfyUI Forum": "Forum ComfyUI",
"ComfyUI Issues": "Problèmes de ComfyUI",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
"Edit": "Éditer",
@@ -653,9 +654,9 @@
"ready": "Finalisation...",
"starting-server": "Démarrage du serveur ComfyUI..."
},
"reinstall": "Réinstaller",
"reportIssue": "Signaler un problème",
"showTerminal": "Afficher le terminal"
"showTerminal": "Afficher le terminal",
"troubleshoot": "Dépannage"
},
"settingsCategories": {
"About": "À Propos",

View File

@@ -25,6 +25,9 @@
},
"tooltip": "Choisissez l'option personnalisée pour masquer la barre de titre du système"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Afficher la boîte à outils de sélection"
},
"Comfy_ConfirmClear": {
"name": "Demander une confirmation lors de l'effacement du flux de travail"
},

View File

@@ -35,6 +35,9 @@
"Comfy_BrowseTemplates": {
"label": "テンプレートを参照"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "選択したアイテムを削除"
},
"Comfy_Canvas_FitView": {
"label": "選択したノードにビューを合わせる"
},

View File

@@ -378,6 +378,7 @@
"ComfyUI Forum": "ComfyUI フォーラム",
"ComfyUI Issues": "ComfyUIの問題",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
"Edit": "編集",
@@ -653,9 +654,9 @@
"ready": "完了中...",
"starting-server": "ComfyUIサーバーを起動中..."
},
"reinstall": "再インストール",
"reportIssue": "問題を報告",
"showTerminal": "ターミナルを表示"
"showTerminal": "ターミナルを表示",
"troubleshoot": "トラブルシューティング"
},
"settingsCategories": {
"About": "情報",

View File

@@ -25,6 +25,9 @@
},
"tooltip": "システムタイトルバーを非表示にするにはカスタムオプションを選択してください"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "選択ツールボックスを表示"
},
"Comfy_ConfirmClear": {
"name": "ワークフローをクリアする際に確認を要求する"
},

View File

@@ -35,6 +35,9 @@
"Comfy_BrowseTemplates": {
"label": "템플릿 탐색"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "선택한 항목 삭제"
},
"Comfy_Canvas_FitView": {
"label": "선택한 노드에 뷰 맞추기"
},

View File

@@ -378,6 +378,7 @@
"ComfyUI Forum": "ComfyUI 포럼",
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
"Edit": "편집",
@@ -653,9 +654,9 @@
"ready": "마무리 중...",
"starting-server": "ComfyUI 서버 시작 중..."
},
"reinstall": "재설치",
"reportIssue": "문제 보고",
"showTerminal": "터미널 보기"
"showTerminal": "터미널 보기",
"troubleshoot": "문제 해결"
},
"settingsCategories": {
"About": "정보",

View File

@@ -25,6 +25,9 @@
},
"tooltip": "시스템 제목 표시 줄을 숨기려면 사용자 정의 옵션을 선택하세요"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "선택 도구 상자 표시"
},
"Comfy_ConfirmClear": {
"name": "워크플로 비우기 시 확인 요구"
},

View File

@@ -35,6 +35,9 @@
"Comfy_BrowseTemplates": {
"label": "Просмотр шаблонов"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Удалить выбранные элементы"
},
"Comfy_Canvas_FitView": {
"label": "Подогнать вид к выбранным нодам"
},

View File

@@ -378,6 +378,7 @@
"ComfyUI Forum": "Форум ComfyUI",
"ComfyUI Issues": "Проблемы ComfyUI",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
"Edit": "Редактировать",
@@ -653,9 +654,9 @@
"ready": "Завершение…",
"starting-server": "Запуск сервера ComfyUI…"
},
"reinstall": "Переустановить",
"reportIssue": "Сообщить о проблеме",
"showTerminal": "Показать терминал"
"showTerminal": "Показать терминал",
"troubleshoot": "Устранение неполадок"
},
"settingsCategories": {
"About": "О программе",

View File

@@ -25,6 +25,9 @@
},
"tooltip": "Выберите пользовательский вариант, чтобы скрыть системную строку заголовка"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Показать панель инструментов выбора"
},
"Comfy_ConfirmClear": {
"name": "Требовать подтверждение при очистке рабочего процесса"
},

View File

@@ -35,6 +35,9 @@
"Comfy_BrowseTemplates": {
"label": "浏览模板"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "删除选定的项目"
},
"Comfy_Canvas_FitView": {
"label": "适应视图到选中节点"
},

View File

@@ -378,6 +378,7 @@
"ComfyUI Forum": "ComfyUI 论坛",
"ComfyUI Issues": "ComfyUI 问题",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
"Edit": "编辑",
@@ -653,9 +654,9 @@
"ready": "完成中...",
"starting-server": "正在启动 ComfyUI 服务器..."
},
"reinstall": "重新安装",
"reportIssue": "报告问题",
"showTerminal": "显示终端"
"showTerminal": "显示终端",
"troubleshoot": "故障排除"
},
"settingsCategories": {
"About": "关于",

View File

@@ -25,6 +25,9 @@
},
"tooltip": "选择自定义选项以隐藏系统标题栏"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "显示选择工具箱"
},
"Comfy_ConfirmClear": {
"name": "清除工作流时需要确认"
},

View File

@@ -27,6 +27,7 @@ import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { ComfyNodeDef } from '@/types/apiTypes'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
@@ -650,7 +651,7 @@ export class ComfyApp {
const opacity = useSettingStore().get('Comfy.Node.Opacity')
if (opacity) adjustments.opacity = opacity
if (useSettingStore().get('Comfy.ColorPalette') === 'light') {
if (useColorPaletteStore().completedActivePalette.light_theme) {
adjustments.lightness = 0.5
// Lighten title bar of colored nodes on light theme

View File

@@ -1,6 +1,7 @@
import { LGraphCanvas, LGraphGroup, LGraphNode } from '@comfyorg/litegraph'
import type { LGraphCanvas, LGraphGroup, LGraphNode } from '@comfyorg/litegraph'
import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
import { markRaw, ref, shallowRef } from 'vue'
export const useTitleEditorStore = defineStore('titleEditor', () => {
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
@@ -17,8 +18,18 @@ export const useCanvasStore = defineStore('canvas', () => {
* The root LGraphCanvas object is shallow reactive.
*/
const canvas = shallowRef<LGraphCanvas | null>(null)
/**
* The selected items on the canvas. All stored items are raw.
*/
const selectedItems = ref<Positionable[]>([])
const updateSelectedItems = () => {
const items = Array.from(canvas.value?.selectedItems ?? [])
selectedItems.value = items.map((item) => markRaw(item))
}
return {
canvas
canvas,
selectedItems,
updateSelectedItems
}
})

View File

@@ -3,15 +3,14 @@ import { defineStore } from 'pinia'
import { api } from '@/scripts/api'
import { ExecutedWsMessage, ResultItem } from '@/types/apiTypes'
import { parseFilePath } from '@/utils/formatUtil'
const toOutputs = (
filenames: string[],
type: string
): ExecutedWsMessage['output'] => {
return {
images: filenames.map((image) => {
return { filename: image, subfolder: '', type }
})
images: filenames.map((image) => ({ type, ...parseFilePath(image) }))
}
}

View File

@@ -349,6 +349,7 @@ const zComboInputProps = zBaseInputSpecValue.extend({
control_after_generate: z.boolean().optional(),
image_upload: z.boolean().optional(),
image_folder: z.enum(['input', 'output', 'temp']).optional(),
allow_batch: z.boolean().optional(),
remote: zRemoteWidgetConfig.optional()
})
@@ -590,7 +591,8 @@ const zSettings = z.record(z.any()).and(
'LiteGraph.Canvas.MaximumFps': z.number(),
'Comfy.Workflow.ConfirmDelete': z.boolean(),
'Comfy.RerouteBeta': z.boolean(),
'LiteGraph.Canvas.LowQualityRenderingZoomThreshold': z.number()
'LiteGraph.Canvas.LowQualityRenderingZoomThreshold': z.number(),
'Comfy.Canvas.SelectionToolbox': z.boolean()
})
.optional()
)

View File

@@ -22,6 +22,11 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
index: number
) => Promise<unknown> | unknown
/**
* Refreshes the widget's value or options from its remote source.
*/
refresh?: () => unknown
/**
* If the widget supports dynamic prompts, this will be set to true.
* See extensions/core/dynamicPrompts.ts
@@ -122,6 +127,8 @@ declare module '@comfyorg/litegraph' {
imageOffset?: number
/** Callback for pasting an image file into the node */
pasteFile?(file: File): void
/** Callback for pasting multiple files into the node */
pasteFiles?(files: File[]): void
}
}

View File

@@ -1,7 +1,4 @@
import {
ElectronAPI,
ElectronContextMenuOptions
} from '@comfyorg/comfyui-electron-types'
import { ElectronAPI } from '@comfyorg/comfyui-electron-types'
export function isElectron() {
return 'electronAPI' in window && window.electronAPI !== undefined
@@ -11,8 +8,8 @@ export function electronAPI() {
return (window as any).electronAPI as ElectronAPI
}
export function showNativeMenu(event: MouseEvent) {
electronAPI()?.showContextMenu(event as ElectronContextMenuOptions)
export function showNativeSystemMenu() {
electronAPI()?.showContextMenu()
}
export function isNativeWindow() {

View File

@@ -232,3 +232,41 @@ export function createAnnotatedPath(
return `${createPath(item, subfolder)}${createAnnotation(rootFolder)}`
return `${createPath(item.filename ?? '', item.subfolder)}${createAnnotation(item.type)}`
}
/**
* Parses a filepath into its filename and subfolder components.
*
* @example
* parseFilePath('folder/file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
* parseFilePath('/folder/file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
* parseFilePath('file.txt') // → { filename: 'file.txt', subfolder: '' }
* parseFilePath('folder//file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
*
* @param filepath The filepath to parse
* @returns Object containing filename and subfolder
*/
export function parseFilePath(filepath: string): {
filename: string
subfolder: string
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
const normalizedPath = filepath
.replace(/[\\/]+/g, '/') // Normalize path separators
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash
const lastSlashIndex = normalizedPath.lastIndexOf('/')
if (lastSlashIndex === -1) {
return {
filename: normalizedPath,
subfolder: ''
}
}
return {
filename: normalizedPath.slice(lastSlashIndex + 1),
subfolder: normalizedPath.slice(0, lastSlashIndex)
}
}

View File

@@ -1,4 +1,5 @@
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
export function isImageNode(node: LGraphNode) {
return (
@@ -8,3 +9,16 @@ export function isImageNode(node: LGraphNode) {
node.widgets.findIndex((obj: IWidget) => obj.name === 'image') >= 0)
)
}
export function addToComboValues(widget: IComboWidget, value: string) {
if (!widget.options) widget.options = { values: [] }
if (!widget.options.values) widget.options.values = []
if (!widget.options.values.includes(value)) {
widget.options.values.push(value)
}
}
export const isLGraphNode = (item: unknown): item is LGraphNode => {
const name = item?.constructor?.name
return name === 'ComfyNode' || name === 'LGraphNode'
}

View File

@@ -135,12 +135,12 @@ const filterOptions = ref([
])
/** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[1])
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
/** If valid, leave the validation window. */
const completeValidation = async (alertOnFail = true) => {
const completeValidation = async () => {
const isValid = await electron.Validation.complete()
if (alertOnFail && !isValid) {
if (!isValid) {
toast.add({
severity: 'error',
summary: t('g.error'),
@@ -162,18 +162,13 @@ watch(
}
)
// If we're running a fix that may resolve all issues, auto-recheck and continue if everything is OK
watch(
() => taskStore.isRunningInstallationFix,
(value, oldValue) => {
if (!value && oldValue) completeValidation(false)
}
)
onMounted(async () => {
electron.Validation.onUpdate(processUpdate)
const update = await electron.Validation.getStatus()
if (Object.values(update).some((x) => x === 'error')) {
filter.value = filterOptions.value[1]
}
processUpdate(update)
})

View File

@@ -25,9 +25,9 @@
@click="openLogs"
/>
<Button
icon="pi pi-refresh"
:label="t('serverStart.reinstall')"
@click="reinstall"
icon="pi pi-wrench"
:label="t('serverStart.troubleshoot')"
@click="troubleshoot"
/>
</div>
<Button
@@ -88,7 +88,7 @@ const terminalCreated = (
terminal.options.cursorInactiveStyle = 'block'
}
const reinstall = () => electron.reinstall()
const troubleshoot = () => electron.startTroubleshooting()
const reportIssue = () => {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}