Compare commits

..

91 Commits

Author SHA1 Message Date
Terry Jia
d4fd64ef38 don't merge, test for PrimeVue Select 2025-03-12 19:48:17 -04:00
Chenlei Hu
e1e8fcf62c [TS] Type various WidgetOption props (#3010) 2025-03-12 14:44:50 -04:00
Christian Byrne
d133aeb614 Add Comfy Manager store (#3007) 2025-03-12 12:44:57 -04:00
Christian Byrne
e371880a4e [Style] Remove unused classes (#3005) 2025-03-12 09:36:44 -07:00
Chenlei Hu
bcd76ba49b Vue component multi-select widget (#2987) 2025-03-12 12:03:21 -04:00
dependabot[bot]
aad7ded636 Bump @babel/runtime from 7.24.7 to 7.26.10 (#3002)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-12 11:00:25 -04:00
Christian Byrne
3e8ef33cbc Fetch model metadata for Civitai models embedded in workflows (#2994) 2025-03-12 10:43:15 -04:00
Christian Byrne
0facb0458b Whitelist RealESRGAN_x4Plus model (#2995) 2025-03-12 10:42:19 -04:00
Christian Byrne
fd6aae141a Rename 'ControlNet' template to 'Scribble ControlNet' (#3000) 2025-03-12 10:42:04 -04:00
Christian Byrne
9be090f929 [Style] Improve error message in missing models dialog (#2997) 2025-03-12 10:41:31 -04:00
Chenlei Hu
313f32b094 Wrap widget value in API format workflow (#2989) 2025-03-12 10:00:05 -04:00
Comfy Org PR Bot
d76391b682 1.13.3 (#2990)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-11 21:44:29 -04:00
Chenlei Hu
3c68a1da2c [CI] Directly publish release for main branch release (#2991) 2025-03-11 21:44:14 -04:00
Christian Byrne
3de1ef993e Fix image not shown on PreviewImage (#2988) 2025-03-11 19:30:16 -04:00
Comfy Org PR Bot
29df14f477 [chore] Update litegraph to 0.10.2 (#2984)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-12 04:14:55 +11:00
Comfy Org PR Bot
e4e7d53fcc [chore] Update litegraph to 0.10.1 (#2983)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-12 03:48:42 +11:00
filtered
8fa970ffba Standardise widgets - always initialize y to 0 (#2982) 2025-03-11 11:31:03 -04:00
Chenlei Hu
d4c3685326 [TS] Type Clipspace.widgets (#2981) 2025-03-11 10:49:50 -04:00
Chenlei Hu
ebb030c401 [Bug] Fix mask editor saving (#2979) 2025-03-11 10:13:18 -04:00
Chenlei Hu
db6a25a092 [Bug] Fix overwriting of hideOnZoom widget option (#2978) 2025-03-11 09:57:57 -04:00
Christian Byrne
a90b6519b5 [Style] Remove underline hover effect in custom nodes manager (#2977) 2025-03-11 09:49:20 -04:00
Christian Byrne
c2006412de Fix custom nodes license parsing logic (#2975) 2025-03-11 09:49:09 -04:00
Christian Byrne
a046e00bc3 Add node pack version selector dropdown (#2973)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-11 09:48:25 -04:00
Christian Byrne
b347693f4d Fix manager info panel metadata text (#2971) 2025-03-11 09:47:47 -04:00
Christian Byrne
c4ca694d1d Add composable to manage ComfyUI-Manager task queue (#2970) 2025-03-11 09:47:29 -04:00
Christian Byrne
cd7e2d7b91 Add Comfy Manager service (#2968) 2025-03-11 09:46:21 -04:00
Christian Byrne
46029a5ce3 Add ComfyUI-Manager API types (#2967) 2025-03-11 09:45:43 -04:00
Christian Byrne
4123cb611c [Style] Fix registry search filter dropdown style (#2974) 2025-03-11 06:24:56 -07:00
filtered
03bf81cc1b [Cleanup] Remove redundant reroute opt-in setting (#2972)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-11 23:25:16 +11:00
Comfy Org PR Bot
710bc2bb2e 1.13.2 (#2965)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-10 21:54:08 -04:00
Comfy Org PR Bot
e6ef4c8e6d [chore] Update litegraph to 0.10.0 (#2963)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
Co-authored-by: Chenlei Hu <hcl@comfy.org>
2025-03-10 15:06:01 -04:00
Chenlei Hu
62528fde2e [Refactor] Move DOM widget event listeners to vue (#2960) 2025-03-10 13:07:23 -04:00
Chenlei Hu
abe4754904 Use relative url path for reverse proxy hosting on template thumbnails (#2959) 2025-03-10 10:13:41 -04:00
Christian Byrne
3eb036b5e3 Fix dark mode card surface in workflow templates dialog (#2942) 2025-03-10 09:57:19 -04:00
Tristan Sommer
e29a5d3047 Maskeditor: Added opacity controls for the color select tool and the paint bucket tool (#2955) 2025-03-10 14:05:21 +01:00
Chenlei Hu
9cea1fe0d3 Only update widget state when visible (#2954) 2025-03-09 23:27:48 -04:00
Comfy Org PR Bot
61cf07be88 1.13.1 (#2953)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-09 22:02:05 -04:00
Chenlei Hu
ec013cc511 Draw canvas image preview in a widget (#2952) 2025-03-09 21:56:50 -04:00
dependabot[bot]
60022134ed Bump vue-i18n from 9.13.1 to 9.14.3 (#2951)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 20:25:41 -04:00
Chenlei Hu
1558a5bb19 [Refactor] Add useNodeAnimatedImage composable (#2950) 2025-03-09 18:08:03 -04:00
Terry Jia
445be93d50 [3d] extract controls classes (#2948) 2025-03-09 17:32:49 -04:00
dependabot[bot]
4b05d5a8fa Bump axios from 1.7.4 to 1.8.2 (#2949)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 17:32:40 -04:00
Chenlei Hu
131229f02f Use uuid for dom widget id (#2947) 2025-03-09 17:22:18 -04:00
Chenlei Hu
3a0b337d0c Manage style of DOM widgets in Vue (#2946) 2025-03-09 16:51:42 -04:00
Christian Byrne
97d9f90374 [Manager] Use iI8n for date strings (#2938) 2025-03-09 13:48:53 -04:00
Christian Byrne
496abfde53 Fix settings content not visible on large font sizes (#2940) 2025-03-09 13:48:27 -04:00
Christian Byrne
72994621a6 Translate template categories (#2937)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-09 13:47:49 -04:00
Christian Byrne
83f9240587 [Manager] Show progress spinner on initial load (#2936) 2025-03-09 13:47:14 -04:00
filtered
ba8c1ee823 Remove legacy code (follow-up on #2925) (#2930) 2025-03-09 13:44:55 -04:00
Terry Jia
3d6fe41ee9 [3d] add edge threshold support (#2939)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-09 13:44:30 -04:00
Comfy Org PR Bot
96b84761f3 1.13.0 (#2929)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-08 21:29:34 -05:00
Comfy Org PR Bot
d32ff0b018 [chore] Update litegraph to 0.10.0-0 (#2928)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-08 21:25:26 -05:00
Chenlei Hu
6e378c68f9 [Bug] Fix load audio node (#2927)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-08 20:17:19 -05:00
Chenlei Hu
65573f106b [Cleanup] Use WidgetOptions.step2 (#2926) 2025-03-08 19:10:33 -05:00
Christian Byrne
d8721760f1 Add custom nodes manager UI (#2923)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-08 18:54:08 -05:00
filtered
f53c04834f Add support for Litegraph LinkConnector (#2925) 2025-03-09 09:37:47 +11:00
Comfy Org PR Bot
50ecefadc1 1.12.8 (#2922)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-07 21:27:31 -05:00
Chenlei Hu
fdc899a7d9 Add __version__ to pypi package (#2919) 2025-03-07 18:32:15 -05:00
Chenlei Hu
fb0a134278 [Bug] Fix widget remove when node is deleted (#2918) 2025-03-07 17:48:37 -05:00
Christian Byrne
082f0061f6 Refactor responsive sidebar to composable (#2917) 2025-03-07 15:46:43 -05:00
MohammadAboulEla
d10dad85c6 Update formatUtil.ts (#2906)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-03-07 15:06:26 -05:00
Chenlei Hu
e4659a3930 Move i18n related content to src/locales/README.md (#2916) 2025-03-07 15:04:04 -05:00
Chenlei Hu
91388e8b16 [Test] Add playwright test on connect to dom widget (#2912)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-07 14:24:55 -05:00
Comfy Org PR Bot
d24bbe2d7f [chore] Update litegraph to 0.9.9 (#2914)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-07 13:26:12 -05:00
Chenlei Hu
87c21b45d7 [nit] format fix (#2913) 2025-03-07 13:21:54 -05:00
nmvjhd
8718e20693 Use Built-in VAE for Workflow Generation from Images (#2910)
Co-authored-by: matengfei <matengfei@qiyi.com>
2025-03-07 09:43:31 -05:00
Comfy Org PR Bot
37ae12eb41 [chore] Update Comfy Registry API types from comfy-api@a3897fc (#2907)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-03-07 09:39:04 -05:00
filtered
b546131efb [chore] Update litegraph accessor: isNodeOverInput (#2909) 2025-03-07 21:53:53 +11:00
Comfy Org PR Bot
48fe30a977 [chore] Update litegraph to 0.9.8 (#2908)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-07 21:33:16 +11:00
Comfy Org PR Bot
278555f984 1.12.7 (#2904)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-06 21:41:09 -05:00
Terry Jia
e51dcb9860 [3d] improve lineart (#2903) 2025-03-06 21:35:46 -05:00
Chenlei Hu
5f149ceb30 [BugFix] Sync dom widget store state on widget remove (#2902) 2025-03-06 18:29:30 -05:00
Comfy Org PR Bot
af31937e54 [chore] Update litegraph to 0.9.7 (#2901)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-06 15:53:22 -05:00
Chenlei Hu
d74f47db0b Directly use node.collapsed state in dom widget (#2900) 2025-03-06 13:50:45 -05:00
Chenlei Hu
f7be9157e0 Dom widget store (#2899) 2025-03-06 13:23:58 -05:00
Christian Byrne
caaf050728 Fix animated webp output preview (#2897) 2025-03-06 11:19:48 -05:00
Christian Byrne
367a1c63a5 Fix template dialog double scrollbar (#2896) 2025-03-06 11:17:53 -05:00
Christian Byrne
f0ba48ea22 Trim whitespace in URL form items (#2884) 2025-03-06 11:17:24 -05:00
Comfy Org PR Bot
49964b1c2f 1.12.6 (#2885)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-05 21:32:55 -05:00
Chenlei Hu
aca419e1fb [nit] Remove duplicated declaration on DOMWidget interface (#2883) 2025-03-05 17:34:48 -05:00
Chenlei Hu
e7863676dd [BugFix] Fire resize callback for all dom widgets under the same node (#2882) 2025-03-05 16:59:16 -05:00
Chenlei Hu
a6d54de2a7 Rename 'queue' button to 'run' button (#2881)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-05 14:56:05 -05:00
Chenlei Hu
15e396b4cc [Refactor] Remove app.getWidgetType (#2880) 2025-03-05 14:02:13 -05:00
Chenlei Hu
ba4e4ed0b8 Deprecate widgetType:widgetName key in widgets map (#2879) 2025-03-05 13:32:47 -05:00
Chenlei Hu
35e6cabfe7 Use v2 input spec for combo widget (#2878) 2025-03-05 13:12:51 -05:00
Chenlei Hu
8a479979b1 Use v2 input spec in string widgets (#2877) 2025-03-05 12:48:23 -05:00
Chenlei Hu
1882a9af6f Fix node min size specified by widget constructor (#2876) 2025-03-05 12:09:29 -05:00
Chenlei Hu
c3a984a293 [Test] Add test on load image widget (#2875)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-05 12:09:14 -05:00
Comfy Org PR Bot
4c31d04573 1.12.5 (#2874)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-05 11:20:01 -05:00
Chenlei Hu
621568f99e Fix extension node def manipulation (#2873) 2025-03-05 11:18:58 -05:00
Comfy Org PR Bot
b80c991299 1.12.4 (#2865)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-04 19:46:11 -05:00
140 changed files with 6748 additions and 1742 deletions

View File

@@ -61,7 +61,7 @@ jobs:
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: ${{ github.event.pull_request.base.ref == 'main' }}
draft: true
draft: ${{ github.event.pull_request.base.ref != 'main' }}
prerelease: false
generate_release_notes: true

View File

@@ -10,7 +10,7 @@ module.exports = defineConfig({
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade.
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, controlnet, lora.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
`

View File

@@ -517,7 +517,7 @@ The selection toolbox will display the command button when items are selected:
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [Litegraph](https://github.com/Comfy-Org/litegraph.js) for node editor
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
@@ -572,92 +572,17 @@ Component test verifies Vue components in `src/components/`.
Playwright test verifies the whole app. See <https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/README.md> for details.
### LiteGraph
### litegraph.js
This repo is using litegraph package hosted on <https://github.com/Comfy-Org/litegraph.js>. Any changes to litegraph should be submitted in that repo instead.
### Test litegraph changes
#### Test litegraph.js changes
- Run `npm link` in the local litegraph repo.
- Run `npm link @comfyorg/litegraph` in this repo.
This will replace the litegraph package in this repo with the local litegraph repo.
## Internationalization (i18n)
### i18n
Our project supports multiple languages using `vue-i18n`. This allows users around the world to use the application in their preferred language.
### Supported Languages
- en (English)
- zh (中文)
- ru (Русский)
- ja (日本語)
- ko (한국어)
- fr (Français)
### How to Add a New Language
We welcome the addition of new languages. You can add a new language by following these steps:
#### 1. Generate language files
We use [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/blob/master/packages/lobe-i18n/README.md) as our translation tool, which integrates with LLM for efficient localization.
Update the configuration file to include the new language(s) you wish to add:
```javascript
const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
entry: 'src/locales/en.json', // Base language file
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja'], // Add the new language(s) here
});
```
Set your OpenAI API Key by running the following command:
```sh
npx lobe-i18n --option
```
Once configured, generate the translation files with:
```sh
npx lobe-i18n locale
```
This will create the language files for the specified languages in the configuration.
#### 2. Update i18n Configuration
Import the newly generated locale file(s) in the `src/i18n.ts` file to include them in the application's i18n setup.
#### 3. Enable Selection of the New Language
Add the newly added language to the following item in `src/constants/coreSettings.ts`:
```typescript
{
id: 'Comfy.Locale',
name: 'Locale',
type: 'combo',
// Add the new language(s) here
options: [
{ value: 'en', text: 'English' },
{ value: 'zh', text: '中文' },
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' }
],
defaultValue: navigator.language.split('-')[0] || 'en'
},
```
This will make the new language selectable in the application's settings.
#### 4. Test the Translations
Start the development server, switch to the new language, and verify the translations.
You can switch languages by opening the ComfyUI Settings and selecting from the `ComfyUI > Locale` dropdown box.
See [locales/README.md](src/locales/README.md) for details.

View File

@@ -0,0 +1,59 @@
{
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "PrimitiveNode",
"pos": [14, 43],
"size": [203.1999969482422, 40.36840057373047],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "connect to widget input",
"type": "*",
"links": [],
"slot_index": 0
}
],
"properties": {
"Run widget replace on values": false
}
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [306.2463684082031, 45.30042266845703],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,32 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadAudio",
"pos": [41.5296516418457, 16.930862426757812],
"size": [315, 82],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadAudio"
},
"widgets_values": [null, ""]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,46 @@
{
"last_node_id": 10,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [
50,
50
],
"size": [
315,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"example.png",
"image"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -21,4 +21,14 @@ test.describe('Primitive Node', () => {
'primitive_node_connected.png'
)
})
test('Can connect to dom widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node_unconnected_dom_widget')
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const clipEncoderNode: NodeReference = await comfyPage.getNodeRefById(2)
await primitiveNode.connectWidget(0, clipEncoderNode, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'primitive_node_connected_dom_widget.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -127,3 +127,17 @@ test.describe('Dynamic widget manipulation', () => {
await expect(comfyPage.canvas).toHaveScreenshot('ksampler_widget_added.png')
})
})
test.describe('Load image widget', () => {
test('Can load image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_image_widget')
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png')
})
})
test.describe('Load audio widget', () => {
test('Can load audio', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_audio_widget')
await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -0,0 +1,6 @@
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("comfyui_frontend_package")
except PackageNotFoundError:
__version__ = "unknown"

69
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.12.3",
"version": "1.13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.12.3",
"version": "1.13.3",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "5.2.0",
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.9.6",
"@comfyorg/litegraph": "^0.10.2",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -27,7 +27,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.4",
"axios": "^1.8.2",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
@@ -39,7 +39,7 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
"vue-i18n": "^9.13.1",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
@@ -223,9 +223,10 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz",
"integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==",
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -297,9 +298,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.9.6.tgz",
"integrity": "sha512-eK8to71hbVVQ3jjRqnUHuXFvpMBqsC1czIE8LGyjX6c7YGoYP5mKrme9688XIr9VNe34ReI7jD1xS/7iWq7tWg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.10.2.tgz",
"integrity": "sha512-+7P+SeEAEervFOe0RSmWZGB+WamUH0zgOLHjypWpBqxQNEf4Z2JL8dLUOi8IIw+Le/PT38bD2bp/7KAggsI9WA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -953,12 +954,13 @@
}
},
"node_modules/@intlify/core-base": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.13.1.tgz",
"integrity": "sha512-+bcQRkJO9pcX8d0gel9ZNfrzU22sZFSA0WVhfXrf5jdJOS24a+Bp8pozuS9sBI9Hk/tGz83pgKfmqcn/Ci7/8w==",
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.3.tgz",
"integrity": "sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "9.13.1",
"@intlify/shared": "9.13.1"
"@intlify/message-compiler": "9.14.3",
"@intlify/shared": "9.14.3"
},
"engines": {
"node": ">= 16"
@@ -968,11 +970,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.13.1.tgz",
"integrity": "sha512-SKsVa4ajYGBVm7sHMXd5qX70O2XXjm55zdZB3VeMFCvQyvLew/dLvq3MqnaIsTMF1VkkOb9Ttr6tHcMlyPDL9w==",
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.3.tgz",
"integrity": "sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "9.13.1",
"@intlify/shared": "9.14.3",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -983,9 +986,10 @@
}
},
"node_modules/@intlify/shared": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.13.1.tgz",
"integrity": "sha512-u3b6BKGhE6j/JeRU6C/RL2FgyJfy6LakbtfeVF8fJXURpZZTzfh3e05J0bu0XPw447Q6/WUp3C4ajv4TMS4YsQ==",
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.3.tgz",
"integrity": "sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
@@ -4051,9 +4055,9 @@
}
},
"node_modules/axios": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
"integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -13404,12 +13408,13 @@
}
},
"node_modules/vue-i18n": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.13.1.tgz",
"integrity": "sha512-mh0GIxx0wPtPlcB1q4k277y0iKgo25xmDPWioVVYanjPufDBpvu5ySTjP5wOrSvlYQ2m1xI+CFhGdauv/61uQg==",
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.3.tgz",
"integrity": "sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "9.13.1",
"@intlify/shared": "9.13.1",
"@intlify/core-base": "9.14.3",
"@intlify/shared": "9.14.3",
"@vue/devtools-api": "^6.5.0"
},
"engines": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.12.3",
"version": "1.13.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -73,7 +73,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.9.6",
"@comfyorg/litegraph": "^0.10.2",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -88,7 +88,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.4",
"axios": "^1.8.2",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
@@ -100,7 +100,7 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
"vue-i18n": "^9.13.1",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -10,8 +10,8 @@
data-testid="queue-button"
v-tooltip.bottom="{
value: workspaceStore.shiftDown
? $t('menu.queueWorkflowFront')
: $t('menu.queueWorkflow'),
? $t('menu.runWorkflowFront')
: $t('menu.runWorkflow'),
showDelay: 600
}"
>
@@ -98,7 +98,7 @@ const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => ({
disabled: {
key: 'disabled',
label: t('menu.queue'),
label: t('menu.run'),
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
@@ -106,7 +106,7 @@ const queueModeMenuItemLookup = computed(() => ({
},
instant: {
key: 'instant',
label: `${t('menu.queue')} (${t('menu.instant')})`,
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
queueMode.value = 'instant'
@@ -114,7 +114,7 @@ const queueModeMenuItemLookup = computed(() => ({
},
change: {
key: 'change',
label: `${t('menu.queue')} (${t('menu.onChange')})`,
label: `${t('menu.run')} (${t('menu.onChange')})`,
tooltip: t('menu.onChangeTooltip'),
command: () => {
queueMode.value = 'change'

View File

@@ -0,0 +1,31 @@
<template>
<hr
:class="{
'm-0': true,
'border-t': orientation === 'horizontal',
'border-l': orientation === 'vertical',
'h-full': orientation === 'vertical',
'w-full': orientation === 'horizontal'
}"
:style="{
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
borderWidth: `${width}px !important`
}"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const colorPaletteStore = useColorPaletteStore()
const { orientation = 'horizontal', width = 0.3 } = defineProps<{
orientation?: 'horizontal' | 'vertical'
width?: number
}>()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
</script>

View File

@@ -1,17 +1,27 @@
<!-- A file download button with a label and a size hint -->
<template>
<div class="flex flex-row items-center gap-2">
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
<div>
<div>
<span :title="hint">{{ label }}</span>
</div>
<div v-if="props.error" class="file-error">
<Message
v-if="props.error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="h-min my-2 px-1 max-w-xs"
:title="props.error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ props.error }}
</div>
</Message>
</div>
<div class="file-action">
<div>
<Button
class="file-action-button"
:label="$t('g.download') + ' (' + fileSize + ')'"
size="small"
outlined
@@ -25,6 +35,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useDownload } from '@/composables/useDownload'

View File

@@ -44,14 +44,17 @@ const emit = defineEmits<{
const validationState = ref<ValidationState>(ValidationState.IDLE)
const cleanInput = (value: string): string =>
value ? value.replace(/\s+/g, '') : ''
// Add internal value state
const internalValue = ref(props.modelValue)
const internalValue = ref(cleanInput(props.modelValue))
// Watch for external modelValue changes
watch(
() => props.modelValue,
async (newValue: string) => {
internalValue.value = newValue
internalValue.value = cleanInput(newValue)
await validateUrl(newValue)
}
)
@@ -67,14 +70,24 @@ onMounted(async () => {
const handleInput = (value: string) => {
// Update internal value without emitting
internalValue.value = value
internalValue.value = cleanInput(value)
// Reset validation state when user types
validationState.value = ValidationState.IDLE
}
const handleBlur = async () => {
const input = cleanInput(internalValue.value)
let normalizedUrl = input
try {
const url = new URL(input)
normalizedUrl = url.toString()
} catch {
// If URL parsing fails, just use the cleaned input
}
// Emit the update only on blur
emit('update:modelValue', internalValue.value)
emit('update:modelValue', normalizedUrl)
}
// Default validation implementation
@@ -90,7 +103,7 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
const validateUrl = async (value: string) => {
if (validationState.value === ValidationState.LOADING) return
const url = value.trim()
const url = cleanInput(value)
// Reset state
validationState.value = ValidationState.IDLE

View File

@@ -42,11 +42,11 @@ describe('UrlInput', () => {
})
const input = wrapper.find('input')
await input.setValue('https://test.com')
await input.setValue('https://test.com/')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([
'https://test.com'
'https://test.com/'
])
})
@@ -155,4 +155,56 @@ describe('UrlInput', () => {
expect(validationCount).toBe(1) // Only the initial validation should occur
})
describe('input cleaning functionality', () => {
it('trims whitespace when user types', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
// Test leading whitespace
await input.setValue(' https://leading-space.com')
await input.trigger('input')
await nextTick()
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
// Test trailing whitespace
await input.setValue('https://trailing-space.com ')
await input.trigger('input')
await nextTick()
expect(wrapper.vm.internalValue).toBe('https://trailing-space.com')
// Test both leading and trailing whitespace
await input.setValue(' https://both-spaces.com ')
await input.trigger('input')
await nextTick()
expect(wrapper.vm.internalValue).toBe('https://both-spaces.com')
// Test whitespace in the middle of the URL
await input.setValue('https:// middle-space.com')
await input.trigger('input')
await nextTick()
expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
})
it('trims whitespace when value set externally', async () => {
const wrapper = mountComponent({
modelValue: ' https://initial-value.com ',
placeholder: 'Enter URL'
})
// Check initial value is trimmed
expect(wrapper.vm.internalValue).toBe('https://initial-value.com')
// Update props with whitespace
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
await nextTick()
// Check updated value is trimmed
expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
})
})
})

View File

@@ -52,7 +52,8 @@ 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/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true'
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
interface ModelInfo {

View File

@@ -289,7 +289,7 @@ watch(activeCategory, (_, oldValue) => {
display: flex;
height: 70vh;
width: 60vw;
max-width: 1024px;
max-width: 64rem;
overflow: hidden;
}

View File

@@ -0,0 +1,207 @@
<template>
<div
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
:aria-label="$t('manager.title')"
>
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
:tabs="tabs"
:selected-tab="selectedTab"
@update:selected-tab="handleTabSelection"
/>
<div
class="flex-1 overflow-auto"
:class="{
'transition-all duration-300': isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen,
'pr-80': showInfoPanel
}"
>
<div class="px-6 pt-6 flex flex-col h-full">
<RegistrySearchBar
v-if="!hideSearchBar"
v-model:searchQuery="searchQuery"
:searchResults="searchResults"
@update:sortBy="handleSortChange"
@update:filterBy="handleFilterChange"
/>
<div class="flex-1 overflow-auto">
<div
v-if="isLoading || isInitialLoad"
class="flex justify-center items-center h-full"
>
<ProgressSpinner />
</div>
<NoResultsPlaceholder
v-else-if="error || searchResults.length === 0"
:title="
error
? $t('manager.errorConnecting')
: $t('manager.noResultsFound')
"
:message="
error
? $t('manager.tryAgainLater')
: $t('manager.tryDifferentSearch')
"
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
:items="resultsWithKeys"
:defaultItemSize="DEFAULT_CARD_SIZE"
class="p-0 m-0 max-w-full"
:buffer-rows="2"
:gridStyle="{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${DEFAULT_CARD_SIZE}px, 1fr))`,
padding: '0.5rem',
gap: '1.125rem 1.25rem',
justifyContent: 'stretch'
}"
>
<template #item="{ item }">
<div
class="relative w-full aspect-square cursor-pointer"
@click.stop="(event) => selectNodePack(item, event)"
>
<PackCard
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
/>
</div>
</template>
</VirtualGrid>
</div>
</div>
</div>
</div>
<div
v-if="showInfoPanel"
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
>
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ManagerNavSidebar from '@/components/dialog/content/manager/ManagerNavSidebar.vue'
import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.vue'
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import type { PackField, TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_CARD_SIZE = 512
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const hideSearchBar = computed(() => isSmallScreen.value && showInfoPanel.value)
const tabs = ref<TabItem[]>([
{ id: 'all', label: 'All', icon: 'pi-list' },
{ id: 'community', label: 'Community', icon: 'pi-globe' },
{ id: 'installed', label: 'Installed', icon: 'pi-box' }
])
const selectedTab = ref<TabItem>(tabs.value[0])
const handleTabSelection = (tab: TabItem) => {
selectedTab.value = tab
}
const { searchQuery, pageNumber, sortField, isLoading, error, searchResults } =
useRegistrySearch()
pageNumber.value = 1
const isInitialLoad = computed(
() => searchResults.value.length === 0 && searchQuery.value === ''
)
const resultsWithKeys = computed(() =>
searchResults.value.map((item) => ({
...item,
key: item.id || item.name
}))
)
const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
) => {
// Handle multi-select with Shift or Ctrl/Cmd key
if (event.shiftKey || event.ctrlKey || event.metaKey) {
const index = selectedNodePacks.value.findIndex(
(pack) => pack.id === nodePack.id
)
if (index === -1) {
// Add to selection if not already selected
selectedNodePacks.value.push(nodePack)
} else {
// Remove from selection if already selected
selectedNodePacks.value.splice(index, 1)
}
} else {
// Single select behavior
selectedNodePacks.value = [nodePack]
}
}
const unSelectItems = () => {
selectedNodePacks.value = []
}
const handleGridContainerClick = (event: MouseEvent) => {
const targetElement = event.target as HTMLElement
if (targetElement && !targetElement.closest('[data-virtual-grid-item]')) {
unSelectItems()
}
}
const showInfoPanel = computed(() => selectedNodePacks.value.length > 0)
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
const currentFilterBy = ref('all')
const handleSortChange = (sortBy: PackField) => {
sortField.value = sortBy
}
const handleFilterChange = (filterBy: PackField) => {
currentFilterBy.value = filterBy
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="w-full">
<div class="px-6 py-4">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
<ContentDivider :width="0.3" />
</div>
</template>
<script setup lang="ts">
import ContentDivider from '@/components/common/ContentDivider.vue'
</script>

View File

@@ -0,0 +1,50 @@
<template>
<aside
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
>
<ScrollPanel class="w-80 mt-7">
<Listbox
:model-value="selectedTab"
:options="tabs"
optionLabel="label"
listStyle="max-height:unset"
:pt="{
root: { class: 'w-full border-0 bg-transparent' },
list: { class: 'p-5' },
option: { class: 'px-8 py-3 text-lg rounded-xl' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
@update:model-value="handleTabSelection"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'mr-3']"></i>
<span class="text-lg">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" />
</aside>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { TabItem } from '@/types/comfyManagerTypes'
defineProps<{
tabs: TabItem[]
selectedTab: TabItem
}>()
const emit = defineEmits<{
'update:selectedTab': [value: TabItem]
}>()
const handleTabSelection = (tab: TabItem) => {
emit('update:selectedTab', tab)
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<Button
outlined
class="m-0 p-0 rounded-lg border-neutral-700"
severity="secondary"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
>
<span class="py-2.5 px-3">
{{ multi ? $t('manager.installSelected') : $t('g.install') }}
</span>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
defineProps<{
fullWidth?: boolean
multi?: boolean
}>()
</script>

View File

@@ -0,0 +1,82 @@
<template>
<Message
:severity="statusSeverity"
class="p-0 flex items-center rounded-xl break-words w-fit"
:pt="{
text: { class: 'text-xs' },
content: { class: 'px-2 py-0.5' }
}"
>
<i
class="pi pi-circle-fill mr-1.5 text-[0.6rem] p-0"
:style="{ opacity: 0.8 }"
></i>
{{ $t(`manager.status.${statusLabel}`) }}
</Message>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { components } from '@/types/comfyRegistryTypes'
import { VueSeverity } from '@/types/primeVueTypes'
const { t } = useI18n()
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
type Status = PackVersionStatus | PackStatus
type StatusProps = {
label: string
severity: VueSeverity
}
const { statusType } = defineProps<{
statusType: Status
}>()
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
severity: 'success'
},
NodeStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeStatusBanned: {
label: 'banned',
severity: 'danger'
},
NodeVersionStatusActive: {
label: 'active',
severity: 'success'
},
NodeVersionStatusPending: {
label: 'pending',
severity: 'warn'
},
NodeVersionStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeVersionStatusFlagged: {
label: 'flagged',
severity: 'danger'
},
NodeVersionStatusBanned: {
label: 'banned',
severity: 'danger'
}
}
const statusLabel = computed(
() => statusPropsMap[statusType]?.label || 'unknown'
)
const statusSeverity = computed(
() => statusPropsMap[statusType]?.severity || 'secondary'
)
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="relative">
<Button
v-if="displayVersion"
:label="displayVersion"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter p-0"
:pt="{
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
aria-haspopup="true"
@click="toggleVersionSelector"
/>
<Popover
ref="popoverRef"
:pt="{
content: { class: 'px-0' }
}"
>
<PackVersionSelectorPopover
:selected-version="selectedVersion"
:node-pack="nodePack"
@select="onSelect"
@cancel="closeVersionSelector"
@apply="applyVersionSelection"
/>
</Popover>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack, version = SelectedVersion.NIGHTLY } = defineProps<{
nodePack: components['schemas']['Node']
version?: string
}>()
const emit = defineEmits<{
'update:version': [version: string]
}>()
const popoverRef = ref()
const selectedVersion = ref<string>(version)
const displayVersion = computed(() => {
if (selectedVersion.value === SelectedVersion.LATEST) {
// If there is no version, treat as unclaimed GitHub pack and use nightly
return nodePack?.latest_version?.version || SelectedVersion.NIGHTLY
}
return selectedVersion.value
})
const toggleVersionSelector = (event: Event) => {
popoverRef.value.toggle(event)
}
const closeVersionSelector = () => {
popoverRef.value.hide()
}
const onSelect = (newVersion: string) => {
selectedVersion.value = newVersion
}
const applyVersionSelection = (newVersion: string) => {
selectedVersion.value = newVersion
emit('update:version', newVersion)
// TODO: after manager store added, install the pack here
closeVersionSelector()
}
whenever(() => version, onSelect)
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="w-64 mt-2">
<span class="pl-3 text-muted text-md font-semibold opacity-70">
{{ $t('manager.selectVersion') }}
</span>
<div
v-if="isLoading"
class="text-center text-muted py-4 flex flex-col items-center"
>
<ProgressSpinner class="w-8 h-8 mb-2" />
{{ $t('manager.loadingVersions') }}
</div>
<div v-else-if="allVersionOptions.length === 0" class="py-2">
<NoResultsPlaceholder
:title="$t('g.noResultsFound')"
:message="$t('manager.tryAgainLater')"
icon="pi pi-exclamation-circle"
class="p-0"
/>
</div>
<Listbox
v-else
v-model="currentSelection"
option-label="label"
option-value="value"
:options="allVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<span>{{ slotProps.option.label }}</span>
<i
v-if="currentSelection === slotProps.option.value"
class="pi pi-check text-highlight"
></i>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 p-1 px-3">
<Button
text
severity="secondary"
:label="$t('g.cancel')"
@click="emit('cancel')"
/>
<Button
severity="secondary"
:label="$t('g.install')"
@click="emit('apply', currentSelection ?? SelectedVersion.LATEST)"
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack, selectedVersion = SelectedVersion.NIGHTLY } = defineProps<{
nodePack: components['schemas']['Node']
selectedVersion?: string
}>()
const emit = defineEmits<{
cancel: []
apply: [version: string]
}>()
const { t } = useI18n()
const registryService = useComfyRegistryService()
const currentSelection = ref<string>(selectedVersion)
const fetchVersions = async () => {
if (!nodePack?.id) return []
return (await registryService.getPackVersions(nodePack.id)) || []
}
const {
isLoading,
state: versions,
execute: startFetchVersions
} = useAsyncState(fetchVersions, [])
const specialOptions = computed(() => [
{
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
},
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
}
])
const versionOptions = computed(() =>
versions.value.map((version) => ({
value: version.version,
label: version.version
}))
)
const allVersionOptions = computed(() => [
...specialOptions.value,
...versionOptions.value
])
watch(
() => nodePack,
() => startFetchVersions(),
{ deep: true }
)
</script>

View File

@@ -0,0 +1,159 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Popover from 'primevue/popover'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionBadge from '../PackVersionBadge.vue'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0'
}
}
describe('PackVersionBadge', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Popover,
PackVersionSelectorPopover
}
}
})
}
it('renders with default version (NIGHTLY)', () => {
const wrapper = mountComponent()
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
it('renders with provided version', () => {
const version = '2.0.0'
const wrapper = mountComponent({ props: { version } })
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(version)
})
it('shows actual latest (semantic) version prop when version is set to latest', () => {
const wrapper = mountComponent({
props: { version: SelectedVersion.LATEST }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(mockNodePack.latest_version.version)
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Spy on the toggle method
const popoverToggleSpy = vi.fn()
const popover = wrapper.findComponent(Popover)
popover.vm.toggle = popoverToggleSpy
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Verify that the toggle method was called
expect(popoverToggleSpy).toHaveBeenCalled()
})
it('emits update:version event when version is selected', async () => {
const wrapper = mountComponent()
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Simulate the popover emitting an apply event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('apply', '3.0.0')
await nextTick()
// Check if the update:version event was emitted with the correct value
expect(wrapper.emitted('update:version')).toBeTruthy()
expect(wrapper.emitted('update:version')![0]).toEqual(['3.0.0'])
})
it('closes the popover when cancel is clicked', async () => {
const wrapper = mountComponent()
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Simulate the popover emitting a cancel event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('cancel')
await nextTick()
// Check if the popover is hidden
expect(wrapper.findComponent(Popover).isVisible()).toBe(false)
})
it('updates displayed version when version prop changes', async () => {
const wrapper = mountComponent({ props: { version: '1.0.0' } })
expect(wrapper.findComponent(Button).props('label')).toBe('1.0.0')
// Update the version prop
await wrapper.setProps({ version: '2.0.0' })
// Check if the displayed version was updated
expect(wrapper.findComponent(Button).props('label')).toBe('2.0.0')
})
it('handles null or undefined nodePack', async () => {
const wrapper = mountComponent({ props: { nodePack: null } })
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
// Should not crash when clicking the button
await button.trigger('click')
expect(wrapper.findComponent(Popover).isVisible()).toBe(false)
})
it('handles missing latest_version (unclaimed pack) by falling back to NIGHTLY', async () => {
const incompleteNodePack = { id: 'test-pack', name: 'Test Pack' }
const wrapper = mountComponent({
props: {
nodePack: incompleteNodePack,
version: SelectedVersion.LATEST
}
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
// Should fallback to nightly string when latest_version is missing
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
})

View File

@@ -0,0 +1,190 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
const mockVersions = [
{ version: '1.0.0', createdAt: '2023-01-01' },
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' }
}
const mockGetPackVersions = vi.fn().mockResolvedValue(mockVersions)
vi.mock('@/services/comfyRegistryService', () => ({
useComfyRegistryService: vi.fn(() => ({
getPackVersions: mockGetPackVersions
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
}
describe('PackVersionSelectorPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetPackVersions.mockClear()
mockGetPackVersions.mockResolvedValue(mockVersions)
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionSelectorPopover, {
props: {
nodePack: mockNodePack,
selectedVersion: SelectedVersion.NIGHTLY,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox
}
}
})
}
it('fetches versions on mount', async () => {
mountComponent()
await waitForPromises()
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) => setTimeout(() => resolve(mockVersions), 1000))
)
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Loading versions...')
})
it('displays special options and version options in the listbox', async () => {
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
const options = listbox.props('options')!
expect(options.length).toBe(5) // 2 special options + 3 version options
// Check special options
expect(options[0].value).toBe(SelectedVersion.NIGHTLY)
expect(options[1].value).toBe(SelectedVersion.LATEST)
// Check version options
expect(options[2].value).toBe('1.0.0')
expect(options[3].value).toBe('0.9.0')
expect(options[4].value).toBe('0.8.0')
})
it('initializes with the provided selectedVersion prop', async () => {
const selectedVersion = '0.9.0'
const wrapper = mountComponent({ props: { selectedVersion } })
await waitForPromises()
// Check that the listbox has the correct model value
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(selectedVersion)
})
it('emits cancel event when cancel button is clicked', async () => {
const wrapper = mountComponent()
await waitForPromises()
const cancelButton = wrapper.findAllComponents(Button)[0]
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('emits apply event with current selection when apply button is clicked', async () => {
const selectedVersion = '0.9.0'
const wrapper = mountComponent({ props: { selectedVersion } })
await waitForPromises()
const applyButton = wrapper.findAllComponents(Button)[1]
await applyButton.trigger('click')
expect(wrapper.emitted('apply')).toBeTruthy()
expect(wrapper.emitted('apply')![0]).toEqual([selectedVersion])
})
it('emits apply event with LATEST when no selection and apply button is clicked', async () => {
const wrapper = mountComponent({ props: { selectedVersion: null } })
await waitForPromises()
const applyButton = wrapper.findAllComponents(Button)[1]
await applyButton.trigger('click')
expect(wrapper.emitted('apply')).toBeTruthy()
expect(wrapper.emitted('apply')![0]).toEqual([SelectedVersion.LATEST])
})
it('is reactive to nodePack prop changes', async () => {
const wrapper = mountComponent()
await waitForPromises()
// Clear mock calls to check if getPackVersions is called again
mockGetPackVersions.mockClear()
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when comfy-api returns null when fetching versions', async () => {
mockGetPackVersions.mockResolvedValueOnce(null)
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
it('falls back to nightly when component mounts with no versions (unclaimed pack)', async () => {
mockGetPackVersions.mockResolvedValueOnce([])
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
})
})

View File

@@ -0,0 +1,114 @@
<template>
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
<div class="p-6 flex-1 overflow-hidden text-sm">
<PackCardHeader
:node-pack="nodePack"
:install-button-full-width="false"
/>
<div class="mb-6">
<MetadataRow
v-for="item in infoItems"
v-show="item.value !== undefined && item.value !== null"
:key="item.key"
:label="item.label"
:value="item.value"
/>
<MetadataRow :label="t('g.status')">
<PackStatusMessage
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge
:node-pack="nodePack"
:version="selectedVersion"
@update:version="updateSelectedVersion"
/>
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs :node-pack="nodePack" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
interface InfoItem {
key: string
label: string
value: string | number | undefined
}
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { t, d } = useI18n()
const packCardHeaderRef = ref(null)
const selectedVersion = ref<string>(
nodePack.latest_version?.version || SelectedVersion.NIGHTLY
)
const updateSelectedVersion = (version: string) => {
selectedVersion.value = version
if (packCardHeaderRef.value) {
packCardHeaderRef.value.updateVersion?.(version)
}
}
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
label: t('manager.createdBy'),
// TODO: handle all Comfy Registry publisher types dynamically (e.g., organizations, multiple authors)
value: nodePack.publisher?.name
},
{
key: 'downloads',
label: t('manager.downloads'),
value: nodePack.downloads ? formatNumber(nodePack.downloads) : undefined
},
{
key: 'lastUpdated',
label: t('manager.lastUpdated'),
value: nodePack.latest_version?.createdAt
? d(nodePack.latest_version.createdAt, {
dateStyle: 'medium'
})
: undefined
}
])
</script>
<style scoped>
.hidden-scrollbar {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<PackCardHeader>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
{{ nodePacks.length }}
{{ $t('manager.packsSelected') }}
</template>
<template #install-button>
<PackInstallButton :full-width="true" :multi="true" />
</template>
</PackCardHeader>
<div class="mb-6">
<MetadataRow :label="$t('g.status')">
<PackStatusMessage status-type="NodeVersionStatusActive" />
</MetadataRow>
<MetadataRow
:label="$t('manager.totalNodes')"
:value="totalNodesCount"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const comfyRegistryService = useComfyRegistryService()
const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!comfyRegistryService.packNodesAvailable(pack)) return []
return comfyRegistryService.getNodeDefs({
packId: pack.id,
versionId: pack.latest_version.id
})
}
const { state: allNodeDefs } = useAsyncState(
() => Promise.all(nodePacks.map(getPackNodes)),
[],
{
immediate: true
}
)
const totalNodesCount = computed(() =>
allNodeDefs.value.reduce(
(total, nodeDefs) => total + (nodeDefs?.length || 0),
0
)
)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">{{ $t('g.description') }}</Tab>
<Tab value="nodes">{{ $t('g.nodes') }}</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
<TabPanel value="nodes">
<NodesTabPanel :node-pack="nodePack" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
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 { ref } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack: components['schemas']['Node']
}>()
const activeTab = ref('description')
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-1">{{ section.title }}</div>
<div class="text-muted break-words">
<a
v-if="section.isUrl"
:href="section.text"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2"
>
<i
v-if="isGitHubLink(section.text)"
class="pi pi-github text-base"
></i>
<span class="break-all">{{ section.text }}</span>
</a>
<MarkdownText v-else :text="section.text" class="text-muted" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownText from '@/components/dialog/content/manager/infoPanel/MarkdownText.vue'
export interface TextSection {
title: string
text: string
isUrl?: boolean
}
defineProps<{
sections: TextSection[]
}>()
const isGitHubLink = (url: string): boolean => url.includes('github.com')
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div>
<div v-if="!hasMarkdown" v-text="text" class="break-words"></div>
<div v-else class="break-words">
<template v-for="(segment, index) in parsedSegments" :key="index">
<a
v-if="segment.type === 'link' && 'url' in segment"
:href="segment.url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
>
<span class="text-blue-600">{{ segment.text }}</span>
</a>
<strong v-else-if="segment.type === 'bold'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="bg-surface-100 px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { text } = defineProps<{
text: string
}>()
type MarkdownSegment = {
type: 'text' | 'link' | 'bold' | 'italic' | 'code'
text: string
url?: string
}
const hasMarkdown = computed(() => {
const hasMarkdown =
/(\[.*?\]\(.*?\)|(\*\*|__)(.*?)(\*\*|__)|(\*|_)(.*?)(\*|_)|`(.*?)`)/.test(
text
)
return hasMarkdown
})
const parsedSegments = computed(() => {
if (!hasMarkdown.value) return [{ type: 'text', text }]
const segments: MarkdownSegment[] = []
const remainingText = text
let lastIndex: number = 0
const linkRegex = /\[(.*?)\]\((.*?)\)/g
let linkMatch: RegExpExecArray | null
while ((linkMatch = linkRegex.exec(remainingText)) !== null) {
// Add text before the match
if (linkMatch.index > lastIndex) {
segments.push({
type: 'text',
text: remainingText.substring(lastIndex, linkMatch.index)
})
}
// Add the link
segments.push({
type: 'link',
text: linkMatch[1],
url: linkMatch[2]
})
lastIndex = linkMatch.index + linkMatch[0].length
}
// Add remaining text after all links
if (lastIndex < remainingText.length) {
let rest = remainingText.substring(lastIndex)
// Process bold text
rest = rest.replace(/(\*\*|__)(.*?)(\*\*|__)/g, (_, __, p2) => {
segments.push({ type: 'bold', text: p2 })
return ''
})
// Process italic text
rest = rest.replace(/(\*|_)(.*?)(\*|_)/g, (_, __, p2) => {
segments.push({ type: 'italic', text: p2 })
return ''
})
// Process code
rest = rest.replace(/`(.*?)`/g, (_, p1) => {
segments.push({ type: 'code', text: p1 })
return ''
})
// Add any remaining text
if (rest) {
segments.push({ type: 'text', text: rest })
}
}
return segments
})
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}:</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
const { value = 'N/A', label = 'N/A' } = defineProps<{
label: string
value?: string | number
}>()
</script>

View File

@@ -0,0 +1,139 @@
<template>
<div class="mt-4 overflow-hidden">
<InfoTextSection
v-if="nodePack.description"
:sections="descriptionSections"
/>
<p v-else class="text-muted italic text-sm">
{{ $t('manager.noDescription') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import InfoTextSection, {
type TextSection
} from '@/components/dialog/content/manager/infoPanel/InfoTextSection.vue'
import { components } from '@/types/comfyRegistryTypes'
import { isValidUrl } from '@/utils/formatUtil'
const { t } = useI18n()
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isLicenseFile = (filename: string): boolean => {
// Match LICENSE, LICENSE.md, LICENSE.txt (case insensitive)
const licensePattern = /^license(\.md|\.txt)?$/i
return licensePattern.test(filename)
}
const extractBaseRepoUrl = (repoUrl: string): string => {
const githubRepoPattern = /^(https?:\/\/github\.com\/[^/]+\/[^/]+)/i
const match = repoUrl.match(githubRepoPattern)
return match ? match[1] : repoUrl
}
const createLicenseUrl = (filename: string, repoUrl: string): string => {
if (!repoUrl || !filename) return ''
const licenseFile = isLicenseFile(filename) ? filename : 'LICENSE'
const baseRepoUrl = extractBaseRepoUrl(repoUrl)
return `${baseRepoUrl}/blob/main/${licenseFile}`
}
const parseLicenseObject = (
licenseObj: any
): { text: string; isUrl: boolean } => {
const licenseFile = licenseObj.file || licenseObj.text
if (
typeof licenseFile === 'string' &&
isLicenseFile(licenseFile) &&
nodePack.repository
) {
const url = createLicenseUrl(licenseFile, nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
} else if (licenseObj.text) {
return {
text: licenseObj.text,
isUrl: false
}
} else if (typeof licenseFile === 'string') {
// Return the license file name if repository is missing
return {
text: licenseFile,
isUrl: false
}
}
return {
text: JSON.stringify(licenseObj),
isUrl: false
}
}
const formatLicense = (
license: string
): { text: string; isUrl: boolean } | null => {
// Treat "{}" JSON string as undefined
if (license === '{}') return null
try {
const licenseObj = JSON.parse(license)
// Handle empty object case
if (Object.keys(licenseObj).length === 0) {
return null
}
return parseLicenseObject(licenseObj)
} catch (e) {
if (isLicenseFile(license) && nodePack.repository) {
const url = createLicenseUrl(license, nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
}
return {
text: license,
isUrl: false
}
}
}
const descriptionSections = computed<TextSection[]>(() => {
const sections: TextSection[] = [
{
title: t('g.description'),
text: nodePack.description || t('manager.noDescription')
}
]
if (nodePack.repository) {
sections.push({
title: t('manager.repository'),
text: nodePack.repository,
isUrl: isValidUrl(nodePack.repository)
})
}
if (nodePack.license) {
const licenseInfo = formatLicense(nodePack.license)
if (licenseInfo && licenseInfo.text) {
sections.push({
title: t('manager.license'),
text: licenseInfo.text,
isUrl: licenseInfo.isUrl
})
}
}
return sections
})
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-4 mt-4 overflow-auto text-sm">
<div v-if="nodeDefs?.length">
<!-- TODO: when registry returns node defs, use them here -->
</div>
<div
v-else
v-for="i in 3"
:key="i"
class="border border-surface-border rounded-lg p-4"
>
<NodePreview :node-def="placeholderNodeDef" />
</div>
</div>
</template>
<script setup lang="ts">
import NodePreview from '@/components/node/NodePreview.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack: components['schemas']['Node']
nodeDefs?: components['schemas']['ComfyNode'][]
}>()
// TODO: when registry returns node defs, use them here
const placeholderNodeDef = {
display_name: 'Sample Node',
description: 'This is a sample node for preview purposes',
inputs: {
input1: { name: 'Input 1', type: 'IMAGE' },
input2: { name: 'Input 2', type: 'CONDITIONING' }
},
outputs: [
{ name: 'Output 1', type: 'IMAGE', index: 0 },
{ name: 'Output 2', type: 'MASK', index: 1 }
]
}
</script>

View File

@@ -0,0 +1,179 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { components } from '@/types/comfyRegistryTypes'
import DescriptionTabPanel from '../DescriptionTabPanel.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
const TRANSLATIONS = {
description: 'Description',
repository: 'Repository',
license: 'License',
noDescription: 'No description available'
}
describe('DescriptionTabPanel', () => {
const mountComponent = (props: {
nodePack: Partial<components['schemas']['Node']>
}) => {
return mount(DescriptionTabPanel, {
props,
global: {
plugins: [i18n]
}
})
}
const getSectionByTitle = (
wrapper: ReturnType<typeof mountComponent>,
title: string
) => {
const sections = wrapper
.findComponent({ name: 'InfoTextSection' })
.props('sections')
return sections.find((s: any) => s.title === title)
}
const createNodePack = (
overrides: Partial<components['schemas']['Node']> = {}
) => ({
description: 'Test description',
...overrides
})
const licenseTests = [
{
name: 'handles plain text license',
nodePack: createNodePack({
license: 'MIT License',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'MIT License',
isUrl: false
}
},
{
name: 'handles license file names',
nodePack: createNodePack({
license: 'LICENSE',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/LICENSE',
isUrl: true
}
},
{
name: 'handles license.md file names',
nodePack: createNodePack({
license: 'license.md',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/license.md',
isUrl: true
}
},
{
name: 'handles JSON license objects with text property',
nodePack: createNodePack({
license: JSON.stringify({ text: 'GPL-3.0' }),
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'GPL-3.0',
isUrl: false
}
},
{
name: 'handles JSON license objects with file property',
nodePack: createNodePack({
license: JSON.stringify({ file: 'LICENSE.md' }),
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/LICENSE.md',
isUrl: true
}
},
{
name: 'handles missing repository URL',
nodePack: createNodePack({
license: 'LICENSE'
}),
expected: {
text: 'LICENSE',
isUrl: false
}
},
{
name: 'handles non-GitHub repository URLs',
nodePack: createNodePack({
license: 'LICENSE',
repository: 'https://gitlab.com/user/repo'
}),
expected: {
text: 'https://gitlab.com/user/repo/blob/main/LICENSE',
isUrl: true
}
}
]
describe('license formatting', () => {
licenseTests.forEach((test) => {
it(test.name, () => {
const wrapper = mountComponent({ nodePack: test.nodePack })
const licenseSection = getSectionByTitle(wrapper, TRANSLATIONS.license)
expect(licenseSection).toBeDefined()
expect(licenseSection.text).toBe(test.expected.text)
expect(licenseSection.isUrl).toBe(test.expected.isUrl)
})
})
})
describe('description sections', () => {
it('shows description section', () => {
const wrapper = mountComponent({
nodePack: createNodePack()
})
const descriptionSection = getSectionByTitle(
wrapper,
TRANSLATIONS.description
)
expect(descriptionSection).toBeDefined()
expect(descriptionSection.text).toBe('Test description')
})
it('shows repository section when available', () => {
const wrapper = mountComponent({
nodePack: createNodePack({
repository: 'https://github.com/user/repo'
})
})
const repoSection = getSectionByTitle(wrapper, TRANSLATIONS.repository)
expect(repoSection).toBeDefined()
expect(repoSection.text).toBe('https://github.com/user/repo')
expect(repoSection.isUrl).toBe(true)
})
it('shows fallback text when description is missing', () => {
const wrapper = mountComponent({
nodePack: {
description: undefined
}
})
expect(wrapper.find('p').text()).toBe(TRANSLATIONS.noDescription)
})
})
})

View File

@@ -0,0 +1,116 @@
<template>
<Card
class="absolute inset-0 flex flex-col overflow-hidden rounded-2xl shadow-elevation-4 dark-theme:bg-dark-elevation-1 transition-all duration-200"
:class="{
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected
}"
:pt="{
body: { class: 'p-0 flex flex-col h-full rounded-2xl' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: { class: 'p-0 m-0' },
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<div class="flex justify-between p-5 pb-1 align-middle text-sm">
<span class="flex items-start mt-2">
<i
class="pi pi-box text-muted text-2xl ml-1 mr-5"
style="opacity: 0.5"
></i>
<span class="text-lg relative top-[.25rem]">{{
$t('manager.nodePack')
}}</span>
</span>
<div class="flex items-center gap-2.5">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2"></i>
{{ formatNumber(nodePack.downloads) }}
</div>
<PackInstallButton />
</div>
</div>
</template>
<template #content>
<ContentDivider />
<div class="flex flex-1 p-5 mt-3 cursor-pointer">
<div class="flex-shrink-0 mr-4">
<PackIcon :node-pack="nodePack" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<span
class="text-lg font-bold pb-4 truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
<div class="flex-1">
<p
v-if="nodePack.description"
class="text-sm text-color-secondary m-0 line-clamp-3 overflow-hidden"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>
</div>
</div>
</div>
</template>
<template #footer>
<ContentDivider :width="0.1" />
<div class="flex justify-between p-5 text-xs text-muted">
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="nodePack.publisher?.name">
{{ nodePack.publisher.name }}
</span>
<PackVersionBadge
v-if="isPackInstalled"
:node-pack="nodePack"
v-model:version="selectedVersion"
/>
<span v-else-if="nodePack.latest_version">
{{ nodePack.latest_version.version }}
</span>
</div>
<div
v-if="nodePack.latest_version"
class="flex items-center gap-2 truncate"
>
{{ $t('g.updated') }}
{{
$d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
}}
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import { computed, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import type { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
const { nodePack, isSelected = false } = defineProps<{
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const selectedVersion = ref<string | undefined>(undefined)
const isPackInstalled = computed(
() =>
// TODO: after manager store added
// managerStore.isPackInstalled(nodePack?.id)
true
)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col items-center mb-6">
<slot name="thumbnail">
<PackIcon :node-pack="nodePack" width="24" height="24" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">{{ nodePack?.name }}</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<slot name="install-button">
<PackInstallButton
:full-width="installButtonFullWidth"
:multi="multi"
/>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack?: components['schemas']['Node']
multi?: boolean
installButtonFullWidth?: boolean
}>()
</script>

View File

@@ -0,0 +1,41 @@
<template>
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '4.5rem',
height = '4.5rem'
} = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="relative w-24 h-24">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div
class="border border-surface-border bg-surface-card rounded-lg p-0.5"
>
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
</div>
</div>
<div
v-if="nodePacks.length > maxVisible"
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
>
+{{ nodePacks.length - maxVisible }}
</div>
</div>
</template>
<script setup lang="ts">
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
const {
nodePacks,
maxVisible = 3,
offset = 8
} = defineProps<{
nodePacks: components['schemas']['Node'][]
maxVisible?: number
offset?: number
}>()
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="relative w-full p-6">
<div class="flex items-center w-full">
<IconField class="w-5/12">
<InputIcon class="pi pi-search" />
<InputText
:model-value="searchQuery"
@update:model-value="$emit('update:searchQuery', $event)"
:placeholder="$t('manager.searchPlaceholder')"
class="w-full rounded-2xl"
autofocus
/>
</IconField>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model="currentFilter"
:options="filterOptions"
:label="$t('g.filter')"
@update:model-value="handleFilterChange"
/>
<SearchFilterDropdown
v-model="currentSort"
:options="sortOptions"
:label="$t('g.sort')"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center gap-4 ml-6">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults.length }) }}
</small>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import type { PackField, SearchOption } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_SORT: PackField = 'downloads'
const DEFAULT_FILTER = 'nodePack'
const props = defineProps<{
searchQuery: string
searchResults?: components['schemas']['Node'][]
}>()
const { t } = useI18n()
const currentSort = ref<PackField>(DEFAULT_SORT)
const currentFilter = ref<string>(DEFAULT_FILTER)
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:sortBy': [value: PackField]
'update:filterBy': [value: string]
}>()
const hasResults = computed(
() => props.searchQuery.trim() && props.searchResults?.length
)
const sortOptions: SearchOption<PackField>[] = [
{ id: 'downloads', label: t('manager.sort.downloads') },
{ id: 'name', label: t('g.name') },
{ id: 'rating', label: t('manager.sort.rating') },
{ id: 'category', label: t('g.category') }
]
const filterOptions: SearchOption<string>[] = [
{ id: 'nodePack', label: t('manager.filter.nodePack') },
{ id: 'node', label: t('g.nodes') }
]
const handleSortChange = () => {
// TODO: emit to Algolia service
emit('update:sortBy', currentSort.value)
}
const handleFilterChange = () => {
// TODO: emit to Algolia service
emit('update:filterBy', currentFilter.value)
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="flex items-center gap-1">
<span class="text-muted">{{ label }}:</span>
<Dropdown
v-model="selectedValue"
:options="options"
optionLabel="label"
optionValue="id"
class="min-w-[6rem] border-none bg-transparent shadow-none"
@change="handleChange"
:pt="{
input: { class: 'py-0 px-1 border-none' },
trigger: { class: 'hidden' },
panel: { class: 'shadow-md' },
item: { class: 'py-2 px-3 text-sm' }
}"
/>
</div>
</template>
<script setup lang="ts" generic="T">
import Dropdown from 'primevue/dropdown'
import { computed } from 'vue'
import type { SearchOption } from '@/types/comfyManagerTypes'
const { modelValue, options, label } = defineProps<{
modelValue: T
options: SearchOption<T>[]
label: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: T]
}>()
const selectedValue = computed({
get: () => modelValue,
set: (value) => emit('update:modelValue', value)
})
const handleChange = () => {
emit('update:modelValue', selectedValue.value)
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<!-- Create a new stacking context for widgets to avoid z-index issues -->
<div class="isolate">
<DomWidget
v-for="widget in widgets"
:key="widget.id"
:widget="widget"
:widget-state="domWidgetStore.widgetStates.get(widget.id)"
@update:widget-value="widget.value = $event"
/>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, watch } from 'vue'
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { BaseDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgets = computed(() =>
Array.from(
domWidgetStore.widgetInstances.values() as Iterable<
BaseDOMWidget<string | object>
>
)
)
const MARGIN = 10
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
for (const widget of domWidgetStore.widgetInstances.values()) {
const node = widget.node as LGraphNode
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) continue
const visible =
lgCanvas.isNodeVisible(node) &&
!(widget.options.hideOnZoom && lowQuality) &&
widget.isVisible()
widgetState.visible = visible
if (visible) {
widgetState.pos = [node.pos[0] + MARGIN, node.pos[1] + MARGIN + widget.y]
widgetState.size = [
(widget.width ?? node.width) - MARGIN * 2,
(widget.computedHeight ?? 50) - MARGIN * 2
]
// TODO: optimize this logic as it's O(n), where n is the number of nodes
widgetState.zIndex = lgCanvas.graph.nodes.indexOf(node)
widgetState.readonly = lgCanvas.read_only
}
}
}
const canvasStore = useCanvasStore()
watch(
() => canvasStore.canvas,
(lgCanvas) => {
if (!lgCanvas) return
lgCanvas.onDrawForeground = useChainCallback(
lgCanvas.onDrawForeground,
() => {
updateWidgets()
}
)
}
)
</script>

View File

@@ -3,12 +3,7 @@
<!-- If load immediately, the top-level splitter stateKey won't be correctly
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay
v-if="
comfyAppReady &&
betaMenuEnabled &&
!workspaceStore.focusMode &&
!webviewStore.hasActiveWebview
"
v-if="comfyAppReady && betaMenuEnabled && !workspaceStore.focusMode"
>
<template #side-bar-panel>
<SideToolbar />
@@ -38,6 +33,7 @@
</SelectionOverlay>
<NodeTooltip v-if="tooltipEnabled" />
<NodeBadge />
<DomWidgets />
</template>
<script setup lang="ts">
@@ -45,6 +41,7 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeBadge from '@/components/graph/NodeBadge.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
@@ -74,7 +71,6 @@ import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWebviewStore } from '@/stores/webviewStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -84,7 +80,6 @@ const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const webviewStore = useWebviewStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)

View File

@@ -1,6 +1,5 @@
<template>
<ButtonGroup
v-if="!webviewStore.hasActiveWebview"
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
>
<Button
@@ -70,13 +69,11 @@ import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWebviewStore } from '@/stores/webviewStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const webviewStore = useWebviewStore()
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK

View File

@@ -10,12 +10,17 @@
</template>
<script setup lang="ts">
import { LiteGraph } from '@comfyorg/litegraph'
import {
LiteGraph,
isOverNodeInput,
isOverNodeOutput
} from '@comfyorg/litegraph'
import { useEventListener } from '@vueuse/core'
import { nextTick, ref } from 'vue'
import { st } from '@/i18n'
import { app as comfyApp } from '@/scripts/app'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -66,7 +71,7 @@ const onIdle = () => {
if (node.flags?.collapsed) return
const inputSlot = canvas.isOverNodeInput(
const inputSlot = isOverNodeInput(
node,
canvas.graph_mouse[0],
canvas.graph_mouse[1],
@@ -81,7 +86,7 @@ const onIdle = () => {
return showTooltip(translatedTooltip)
}
const outputSlot = canvas.isOverNodeOutput(
const outputSlot = isOverNodeOutput(
node,
canvas.graph_mouse[0],
canvas.graph_mouse[1],
@@ -97,7 +102,7 @@ const onIdle = () => {
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !widget.element) {
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type)}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef.inputs[widget.name]?.tooltip

View File

@@ -0,0 +1,143 @@
<template>
<div
class="dom-widget"
:title="tooltip"
ref="widgetElement"
:style="style"
v-show="widgetState.visible"
>
<component
v-if="isComponentWidget(widget)"
:is="widget.component"
:modelValue="widget.value"
@update:modelValue="emit('update:widgetValue', $event)"
:widget="widget"
/>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { useEventListener } from '@vueuse/core'
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
import {
type BaseDOMWidget,
isComponentWidget,
isDOMWidget
} from '@/scripts/domWidget'
import { DomWidgetState } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
const { widget, widgetState } = defineProps<{
widget: BaseDOMWidget<string | object>
widgetState: DomWidgetState
}>()
const emit = defineEmits<{
(e: 'update:widgetValue', value: string | object): void
}>()
const widgetElement = ref<HTMLElement>()
const { style: positionStyle, updatePositionWithTransform } =
useAbsolutePosition()
const { style: clippingStyle, updateClipPath } = useDomClipping()
const style = computed<CSSProperties>(() => ({
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents: widgetState.readonly ? 'none' : 'auto'
}))
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const enableDomClipping = computed(() =>
settingStore.get('Comfy.DOMClippingEnabled')
)
const updateDomClipping = () => {
const lgCanvas = canvasStore.canvas
const selectedNode = Object.values(
lgCanvas.selected_nodes ?? {}
)[0] as LGraphNode
const node = widget.node
const isSelected = selectedNode === node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
const selectedAreaConfig = renderArea
? {
x: renderArea[0],
y: renderArea[1],
width: renderArea[2],
height: renderArea[3],
scale,
offset: [offset[0], offset[1]] as [number, number]
}
: undefined
updateClipPath(
widgetElement.value,
lgCanvas.canvas,
isSelected,
selectedAreaConfig
)
}
watch(
() => widgetState,
(newState) => {
updatePositionWithTransform(newState)
if (enableDomClipping.value) {
updateDomClipping()
}
},
{ deep: true }
)
watch(
() => widgetState.visible,
(newVisible, oldVisible) => {
if (!newVisible && oldVisible) {
widget.options.onHide?.(widget)
}
}
)
if (isDOMWidget(widget)) {
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
}
for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
useEventListener(widget.element, evt, () => {
const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widget.node)
lgCanvas?.bringToFront(widget.node)
})
}
}
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
onMounted(() => {
if (isDOMWidget(widget)) {
widgetElement.value.appendChild(widget.element)
}
})
</script>
<style scoped>
.dom-widget > * {
@apply h-full w-full;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div>
<MultiSelect
v-model="selectedItems"
:options="options"
filter
:placeholder="placeholder"
:maxSelectedLabels="3"
:display="display"
class="w-full"
/>
</div>
</template>
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
const selectedItems = defineModel<string[]>({ required: true })
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
}>()
const inputSpec = widget.inputSpec as ComboInputSpec
const options = inputSpec.options ?? []
const placeholder = inputSpec.multi_select?.placeholder ?? 'Select items'
const display = inputSpec.multi_select?.chip ? 'chip' : 'comma'
</script>

View File

@@ -12,6 +12,7 @@
:backgroundImage="backgroundImage"
:upDirection="upDirection"
:materialMode="materialMode"
:edgeThreshold="edgeThreshold"
@materialModeChange="listenMaterialModeChange"
@backgroundColorChange="listenBackgroundColorChange"
@lightIntensityChange="listenLightIntensityChange"
@@ -21,6 +22,7 @@
@showPreviewChange="listenShowPreviewChange"
@backgroundImageChange="listenBackgroundImageChange"
@upDirectionChange="listenUpDirectionChange"
@edgeThresholdChange="listenEdgeThresholdChange"
/>
<Load3DControls
:backgroundColor="backgroundColor"
@@ -36,6 +38,7 @@
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="false"
:edgeThreshold="edgeThreshold"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera"
@toggleGrid="toggleGrid"
@@ -45,6 +48,7 @@
@updateFOV="handleUpdateFOV"
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
/>
</div>
</template>
@@ -79,6 +83,7 @@ const hasBackgroundImage = ref(false)
const backgroundImage = ref('')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const showPreviewButton = computed(() => {
return !props.type.includes('Preview')
@@ -130,6 +135,12 @@ const handleUpdateFOV = (value: number) => {
node.value.properties['FOV'] = fov.value
}
const handleUpdateEdgeThreshold = (value: number) => {
edgeThreshold.value = value
node.value.properties['Edge Threshold'] = edgeThreshold.value
}
const handleBackgroundColorChange = (value: string) => {
backgroundColor.value = value
@@ -158,6 +169,10 @@ const listenUpDirectionChange = (value: UpDirection) => {
upDirection.value = value
}
const listenEdgeThresholdChange = (value: number) => {
edgeThreshold.value = value
}
const listenBackgroundColorChange = (value: string) => {
backgroundColor.value = value
}

View File

@@ -30,210 +30,46 @@
</div>
<div v-show="activeCategory" class="bg-gray-700 bg-opacity-30 rounded-lg">
<div v-if="activeCategory === 'scene'" class="flex flex-col">
<Button
class="p-button-rounded p-button-text"
:class="{ 'p-button-outlined': showGrid }"
@click="toggleGrid"
>
<i
class="pi pi-table text-white text-lg"
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
></i>
</Button>
<SceneControls
v-if="activeCategory === 'scene'"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:hasBackgroundImage="hasBackgroundImage"
@toggleGrid="handleToggleGrid"
@updateBackgroundColor="handleBackgroundColorChange"
@updateBackgroundImage="handleBackgroundImageUpdate"
ref="sceneControlsRef"
/>
<div v-if="!hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="openColorPicker"
>
<i
class="pi pi-palette text-white text-lg"
v-tooltip.right="{
value: t('load3d.backgroundColor'),
showDelay: 300
}"
></i>
<input
type="color"
ref="colorPickerRef"
:value="backgroundColor"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<ModelControls
v-if="activeCategory === 'model'"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="isAnimation"
:edgeThreshold="edgeThreshold"
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
ref="modelControlsRef"
/>
<div v-if="!hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="openImagePicker"
>
<i
class="pi pi-image text-white text-lg"
v-tooltip.right="{
value: t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
></i>
<input
type="file"
ref="imagePickerRef"
accept="image/*"
@change="uploadBackgroundImage"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<CameraControls
v-if="activeCategory === 'camera'"
:cameraType="cameraType"
:fov="fov"
:showFOVButton="showFOVButton"
@switchCamera="switchCamera"
@updateFOV="handleUpdateFOV"
ref="cameraControlsRef"
/>
<div v-if="hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="removeBackgroundImage"
>
<i
class="pi pi-times text-white text-lg"
v-tooltip.right="{
value: t('load3d.removeBackgroundImage'),
showDelay: 300
}"
></i>
</Button>
</div>
</div>
<div v-if="activeCategory === 'model'" class="flex flex-col">
<div v-if="notMaterialLineart" class="relative show-up-direction">
<Button
class="p-button-rounded p-button-text"
@click="toggleUpDirection"
>
<i
class="pi pi-arrow-up text-white text-lg"
v-tooltip.right="{
value: t('load3d.upDirection'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showUpDirection"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="direction in upDirections"
:key="direction"
class="p-button-text text-white"
:class="{ 'bg-blue-500': upDirection === direction }"
@click="selectUpDirection(direction)"
>
{{ formatOption(direction) }}
</Button>
</div>
</div>
</div>
<div class="relative show-material-mode">
<Button
class="p-button-rounded p-button-text"
@click="toggleMaterialMode"
>
<i
class="pi pi-box text-white text-lg"
v-tooltip.right="{
value: t('load3d.materialMode'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showMaterialMode"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="mode in materialModes"
:key="mode"
class="p-button-text text-white"
:class="{ 'bg-blue-500': materialMode === mode }"
@click="selectMaterialMode(mode)"
>
{{ formatMaterialMode(mode) }}
</Button>
</div>
</div>
</div>
</div>
<div v-if="activeCategory === 'camera'" class="flex flex-col">
<Button class="p-button-rounded p-button-text" @click="switchCamera">
<i
:class="['pi', getCameraIcon, 'text-white text-lg']"
v-tooltip.right="{
value: t('load3d.switchCamera'),
showDelay: 300
}"
></i>
</Button>
<div class="relative show-fov" v-if="showFOVButton">
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
<i
class="pi pi-expand text-white text-lg"
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
></i>
</Button>
<div
v-show="showFOV"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="fov"
class="w-full"
@change="updateFOV"
:min="10"
:max="150"
:step="1"
/>
</div>
</div>
</div>
<div v-if="activeCategory === 'light'" class="flex flex-col">
<div
class="relative show-light-intensity"
v-if="showLightIntensityButton"
>
<Button
class="p-button-rounded p-button-text"
@click="toggleLightIntensity"
>
<i
class="pi pi-sun text-white text-lg"
v-tooltip.right="{
value: t('load3d.lightIntensity'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showLightIntensity"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="lightIntensity"
class="w-full"
@change="updateLightIntensity"
:min="1"
:max="20"
:step="1"
/>
</div>
</div>
</div>
<LightControls
v-if="activeCategory === 'light'"
:lightIntensity="lightIntensity"
:showLightIntensityButton="showLightIntensityButton"
@updateLightIntensity="handleUpdateLightIntensity"
ref="lightControlsRef"
/>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
@@ -253,9 +89,12 @@
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
import {
CameraType,
MaterialMode,
@@ -279,6 +118,7 @@ const props = defineProps<{
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
const isMenuOpen = ref(false)
@@ -319,6 +159,7 @@ const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
}>()
const backgroundColor = ref(props.backgroundColor)
@@ -335,8 +176,6 @@ const showFOV = ref(false)
const showFOVButton = ref(props.showFOVButton)
const showPreviewButton = ref(props.showPreviewButton)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const imagePickerRef = ref<HTMLInputElement | null>(null)
const showUpDirection = ref(false)
const upDirections: UpDirection[] = [
'original',
'-x',
@@ -346,36 +185,12 @@ const upDirections: UpDirection[] = [
'-z',
'+z'
]
const showMaterialMode = ref(false)
const materialModes = computed(() => {
const modes: MaterialMode[] = [
'original',
'normal',
'wireframe'
//'depth' disable for now
]
if (!props.isAnimation) {
modes.push('lineart')
}
return modes
})
const notMaterialLineart = computed(() => {
return props.materialMode !== 'lineart'
})
const edgeThreshold = ref(props.edgeThreshold)
const switchCamera = () => {
emit('switchCamera')
}
const toggleGrid = () => {
showGrid.value = !showGrid.value
emit('toggleGrid', showGrid.value)
}
const togglePreview = () => {
showPreview.value = !showPreview.value
emit('togglePreview', showPreview.value)
@@ -385,51 +200,48 @@ const updateBackgroundColor = (color: string) => {
emit('updateBackgroundColor', color)
}
const openColorPicker = () => {
colorPickerRef.value?.click()
const handleToggleGrid = (value: boolean) => {
emit('toggleGrid', value)
}
const toggleLightIntensity = () => {
showLightIntensity.value = !showLightIntensity.value
const handleBackgroundColorChange = (value: string) => {
emit('updateBackgroundColor', value)
}
const handleBackgroundImageUpdate = (file: File | null) => {
emit('updateBackgroundImage', file)
}
const handleUpdateUpDirection = (direction: UpDirection) => {
emit('updateUpDirection', direction)
}
const handleUpdateMaterialMode = (mode: MaterialMode) => {
emit('updateMaterialMode', mode)
}
const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}
const updateLightIntensity = () => {
emit('updateLightIntensity', lightIntensity.value)
}
const toggleFOV = () => {
showFOV.value = !showFOV.value
const handleUpdateFOV = (value: number) => {
emit('updateFOV', value)
}
const updateFOV = () => {
emit('updateFOV', fov.value)
}
const toggleUpDirection = () => {
showUpDirection.value = !showUpDirection.value
}
const selectUpDirection = (direction: UpDirection) => {
upDirection.value = direction
emit('updateUpDirection', direction)
}
const formatOption = (option: string) => {
if (option === 'original') return 'Original'
return option.toUpperCase()
}
const toggleMaterialMode = () => {
showMaterialMode.value = !showMaterialMode.value
}
const selectMaterialMode = (mode: MaterialMode) => {
materialMode.value = mode
emit('updateMaterialMode', mode)
}
const formatMaterialMode = (mode: MaterialMode) => {
return mode.charAt(0).toUpperCase() + mode.slice(1)
const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const closeSlider = (e: MouseEvent) => {
@@ -438,26 +250,6 @@ const closeSlider = (e: MouseEvent) => {
if (!target.closest('.show-menu')) {
isMenuOpen.value = false
}
if (!target.closest('.show-fov')) {
showFOV.value = false
}
if (!target.closest('.show-light-intensity')) {
showLightIntensity.value = false
}
if (!target.closest('.show-up-direction')) {
showUpDirection.value = false
}
if (!target.closest('.show-material-mode')) {
showMaterialMode.value = false
}
}
const openImagePicker = () => {
imagePickerRef.value?.click()
}
watch(
@@ -469,22 +261,6 @@ watch(
}
)
const uploadBackgroundImage = (event: Event) => {
const input = event.target as HTMLInputElement
hasBackgroundImage.value = true
if (input.files && input.files[0]) {
emit('updateBackgroundImage', input.files[0])
}
}
const removeBackgroundImage = () => {
hasBackgroundImage.value = false
emit('updateBackgroundImage', null)
}
watch(
() => props.backgroundColor,
(newValue) => {
@@ -564,6 +340,13 @@ watch(
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
edgeThreshold.value = newValue
}
)
onMounted(() => {
document.addEventListener('click', closeSlider)
})
@@ -571,8 +354,4 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('click', closeSlider)
})
const getCameraIcon = computed(() => {
return props.cameraType === 'perspective' ? 'pi-camera' : 'pi-camera'
})
</script>

View File

@@ -31,6 +31,7 @@ const props = defineProps<{
backgroundImage: string
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold?: number
extraListeners?: Record<string, (value: any) => void>
}>()
@@ -51,6 +52,7 @@ const eventConfig = {
backgroundImageChange: (value: string) =>
emit('backgroundImageChange', value),
upDirectionChange: (value: string) => emit('upDirectionChange', value),
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
modelLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.loadingModel')),
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
@@ -85,6 +87,17 @@ watch(
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value)
rawLoad3d.setEdgeThreshold(newValue)
}
}
)
const emit = defineEmits<{
(e: 'materialModeChange', materialMode: string): void
(e: 'backgroundColorChange', color: string): void
@@ -95,6 +108,7 @@ const emit = defineEmits<{
(e: 'showPreviewChange', showPreview: boolean): void
(e: 'backgroundImageChange', backgroundImage: string): void
(e: 'upDirectionChange', upDirection: string): void
(e: 'edgeThresholdChange', threshold: number): void
}>()
const handleEvents = (action: 'add' | 'remove') => {

View File

@@ -0,0 +1,116 @@
<template>
<div class="flex flex-col">
<Button class="p-button-rounded p-button-text" @click="switchCamera">
<i
:class="['pi', getCameraIcon, 'text-white text-lg']"
v-tooltip.right="{
value: t('load3d.switchCamera'),
showDelay: 300
}"
></i>
</Button>
<div class="relative show-fov" v-if="showFOVButton">
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
<i
class="pi pi-expand text-white text-lg"
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
></i>
</Button>
<div
v-show="showFOV"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="fov"
class="w-full"
@change="updateFOV"
:min="10"
:max="150"
:step="1"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { CameraType } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
cameraType: CameraType
fov: number
showFOVButton: boolean
}>()
const emit = defineEmits<{
(e: 'switchCamera'): void
(e: 'updateFOV', value: number): void
}>()
const cameraType = ref(props.cameraType)
const fov = ref(props.fov)
const showFOVButton = ref(props.showFOVButton)
const showFOV = ref(false)
watch(
() => props.fov,
(newValue) => {
fov.value = newValue
}
)
watch(
() => props.showFOVButton,
(newValue) => {
showFOVButton.value = newValue
}
)
watch(
() => props.cameraType,
(newValue) => {
cameraType.value = newValue
}
)
const switchCamera = () => {
emit('switchCamera')
}
const toggleFOV = () => {
showFOV.value = !showFOV.value
}
const updateFOV = () => {
emit('updateFOV', fov.value)
}
const getCameraIcon = computed(() => {
return props.cameraType === 'perspective' ? 'pi-camera' : 'pi-camera'
})
const closeCameraSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-fov')) {
showFOV.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeCameraSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeCameraSlider)
})
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="flex flex-col">
<div class="relative show-light-intensity" v-if="showLightIntensityButton">
<Button
class="p-button-rounded p-button-text"
@click="toggleLightIntensity"
>
<i
class="pi pi-sun text-white text-lg"
v-tooltip.right="{
value: t('load3d.lightIntensity'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showLightIntensity"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="lightIntensity"
class="w-full"
@change="updateLightIntensity"
:min="1"
:max="20"
:step="1"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
lightIntensity: number
showLightIntensityButton: boolean
}>()
const emit = defineEmits<{
(e: 'updateLightIntensity', value: number): void
}>()
const lightIntensity = ref(props.lightIntensity)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const showLightIntensity = ref(false)
watch(
() => props.lightIntensity,
(newValue) => {
lightIntensity.value = newValue
}
)
watch(
() => props.showLightIntensityButton,
(newValue) => {
showLightIntensityButton.value = newValue
}
)
const toggleLightIntensity = () => {
showLightIntensity.value = !showLightIntensity.value
}
const updateLightIntensity = () => {
emit('updateLightIntensity', lightIntensity.value)
}
const closeLightSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-light-intensity')) {
showLightIntensity.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeLightSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeLightSlider)
})
</script>

View File

@@ -0,0 +1,242 @@
<template>
<div class="flex flex-col">
<div class="relative show-up-direction">
<Button class="p-button-rounded p-button-text" @click="toggleUpDirection">
<i
class="pi pi-arrow-up text-white text-lg"
v-tooltip.right="{
value: t('load3d.upDirection'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showUpDirection"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="direction in upDirections"
:key="direction"
class="p-button-text text-white"
:class="{ 'bg-blue-500': upDirection === direction }"
@click="selectUpDirection(direction)"
>
{{ formatOption(direction) }}
</Button>
</div>
</div>
</div>
<div class="relative show-material-mode">
<Button
class="p-button-rounded p-button-text"
@click="toggleMaterialMode"
>
<i
class="pi pi-box text-white text-lg"
v-tooltip.right="{
value: t('load3d.materialMode'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showMaterialMode"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="mode in materialModes"
:key="mode"
class="p-button-text text-white"
:class="{ 'bg-blue-500': materialMode === mode }"
@click="selectMaterialMode(mode)"
>
{{ formatMaterialMode(mode) }}
</Button>
</div>
</div>
</div>
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
<Button
class="p-button-rounded p-button-text"
@click="toggleEdgeThreshold"
>
<i
class="pi pi-sliders-h text-white text-lg"
v-tooltip.right="{
value: t('load3d.edgeThreshold'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showEdgeThreshold"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<label class="text-white text-xs mb-1 block"
>{{ t('load3d.edgeThreshold') }}: {{ edgeThreshold }}°</label
>
<Slider
v-model="edgeThreshold"
class="w-full"
@change="updateEdgeThreshold"
:min="0"
:max="120"
:step="1"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { MaterialMode, UpDirection } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
}>()
const upDirection = ref(props.upDirection || 'original')
const materialMode = ref(props.materialMode || 'original')
const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const upDirections: UpDirection[] = [
'original',
'-x',
'+x',
'-y',
'+y',
'-z',
'+z'
]
const materialModes = computed(() => {
const modes: MaterialMode[] = [
'original',
'normal',
'wireframe'
//'depth' disable for now
]
if (!props.isAnimation) {
modes.push('lineart')
}
return modes
})
watch(
() => props.upDirection,
(newValue) => {
if (newValue) {
upDirection.value = newValue
}
}
)
watch(
() => props.materialMode,
(newValue) => {
if (newValue) {
materialMode.value = newValue
}
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
edgeThreshold.value = newValue
}
)
const toggleUpDirection = () => {
showUpDirection.value = !showUpDirection.value
showMaterialMode.value = false
showEdgeThreshold.value = false
}
const selectUpDirection = (direction: UpDirection) => {
upDirection.value = direction
emit('updateUpDirection', direction)
showUpDirection.value = false
}
const formatOption = (option: string) => {
if (option === 'original') return 'Original'
return option.toUpperCase()
}
const toggleMaterialMode = () => {
showMaterialMode.value = !showMaterialMode.value
showUpDirection.value = false
showEdgeThreshold.value = false
}
const selectMaterialMode = (mode: MaterialMode) => {
materialMode.value = mode
emit('updateMaterialMode', mode)
showMaterialMode.value = false
}
const formatMaterialMode = (mode: MaterialMode) => {
return mode.charAt(0).toUpperCase() + mode.slice(1)
}
const toggleEdgeThreshold = () => {
showEdgeThreshold.value = !showEdgeThreshold.value
showUpDirection.value = false
showMaterialMode.value = false
}
const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-up-direction')) {
showUpDirection.value = false
}
if (!target.closest('.show-material-mode')) {
showMaterialMode.value = false
}
if (!target.closest('.show-edge-threshold')) {
showEdgeThreshold.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeSceneSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeSceneSlider)
})
</script>

View File

@@ -0,0 +1,147 @@
<template>
<div class="flex flex-col">
<Button
class="p-button-rounded p-button-text"
:class="{ 'p-button-outlined': showGrid }"
@click="toggleGrid"
>
<i
class="pi pi-table text-white text-lg"
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
></i>
</Button>
<div v-if="!hasBackgroundImage">
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
<i
class="pi pi-palette text-white text-lg"
v-tooltip.right="{
value: t('load3d.backgroundColor'),
showDelay: 300
}"
></i>
<input
type="color"
ref="colorPickerRef"
:value="backgroundColor"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button class="p-button-rounded p-button-text" @click="openImagePicker">
<i
class="pi pi-image text-white text-lg"
v-tooltip.right="{
value: t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
></i>
<input
type="file"
ref="imagePickerRef"
accept="image/*"
@change="uploadBackgroundImage"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<div v-if="hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="removeBackgroundImage"
>
<i
class="pi pi-times text-white text-lg"
v-tooltip.right="{
value: t('load3d.removeBackgroundImage'),
showDelay: 300
}"
></i>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { ref, watch } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
backgroundColor: string
showGrid: boolean
hasBackgroundImage?: boolean
}>()
const emit = defineEmits<{
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'updateBackgroundImage', file: File | null): void
}>()
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const colorPickerRef = ref<HTMLInputElement | null>(null)
const imagePickerRef = ref<HTMLInputElement | null>(null)
watch(
() => props.backgroundColor,
(newValue) => {
backgroundColor.value = newValue
}
)
watch(
() => props.showGrid,
(newValue) => {
showGrid.value = newValue
}
)
watch(
() => props.hasBackgroundImage,
(newValue) => {
hasBackgroundImage.value = newValue
}
)
const toggleGrid = () => {
showGrid.value = !showGrid.value
emit('toggleGrid', showGrid.value)
}
const updateBackgroundColor = (color: string) => {
emit('updateBackgroundColor', color)
}
const openColorPicker = () => {
colorPickerRef.value?.click()
}
const openImagePicker = () => {
imagePickerRef.value?.click()
}
const uploadBackgroundImage = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('updateBackgroundImage', input.files[0])
}
}
const removeBackgroundImage = () => {
emit('updateBackgroundImage', null)
}
</script>

View File

@@ -76,17 +76,34 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
>
{{ nodeDef.description }}
</div>
<Select
v-model="selectedSpeed"
:options="speedOptions"
optionLabel="name"
optionValue="value"
/>
</div>
</template>
<script setup lang="ts">
import _ from 'lodash'
import { computed } from 'vue'
import Select from 'primevue/select'
import { computed, ref } from 'vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const selectedSpeed = ref(1)
const speedOptions = [
{ name: '0.1x', value: 0.1 },
{ name: '0.5x', value: 0.5 },
{ name: '1x', value: 1 },
{ name: '1.5x', value: 1.5 },
{ name: '2x', value: 2 }
]
const props = defineProps<{
nodeDef: ComfyNodeDefV2
}>()

View File

@@ -2,7 +2,7 @@
<Card
ref="cardRef"
:data-testid="`template-workflow-${template.name}`"
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-[0_10px_15px_-3px_rgba(0,0,0,0.08),0_4px_6px_-4px_rgba(0,0,0,0.05)]"
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1"
:pt="{
body: { class: 'p-0' }
}"
@@ -103,8 +103,8 @@ const isHovered = useElementHover(cardRef)
const getThumbnailUrl = (index = '') => {
const basePath =
sourceModule === 'default'
? `/templates/${template.name}`
: `/api/workflow_templates/${sourceModule}/${template.name}`
? `templates/${template.name}`
: `api/workflow_templates/${sourceModule}/${template.name}`
// For templates from custom nodes, multiple images is not yet supported
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col h-[83vh] w-[90vw] relative"
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
data-testid="template-workflows-content"
>
<Button
@@ -60,7 +60,6 @@
</template>
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
@@ -70,6 +69,7 @@ import { useI18n } from 'vue-i18n'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
@@ -78,17 +78,11 @@ import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
const { t } = useI18n()
const breakpoints = useBreakpoints({
mobile: 0,
tablet: 768,
desktop: 1024
})
const isSmallScreen = breakpoints.between('mobile', 'desktop')
const isSideNavOpen = ref(!isSmallScreen.value)
const toggleSideNav = () => {
isSideNavOpen.value = !isSideNavOpen.value
}
watch(isSmallScreen, toggleSideNav)
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const workflowTemplatesStore = useWorkflowTemplatesStore()
const { isReady } = useAsyncState(

View File

@@ -1,5 +1,5 @@
<template>
<ScrollPanel class="w-80" style="height: calc(85vh - 48px)">
<ScrollPanel class="w-80" style="height: calc(83vh - 48px)">
<Listbox
:model-value="selectedTab"
@update:model-value="handleTabSelection"

View File

@@ -10,25 +10,23 @@
<div class="flex-grow min-w-0 app-drag h-full">
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
</div>
<template v-if="!webviewStore.hasActiveWebview">
<div class="comfyui-menu-right flex-shrink-0" ref="menuRight"></div>
<Actionbar />
<BottomPanelToggleButton class="flex-shrink-0" />
<Button
class="flex-shrink-0"
icon="pi pi-bars"
severity="secondary"
text
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
:aria-label="$t('menu.hideMenu')"
@click="workspaceState.focusMode = true"
@contextmenu="showNativeSystemMenu"
/>
<div
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
/>
</template>
<div class="comfyui-menu-right flex-shrink-0" ref="menuRight"></div>
<Actionbar />
<BottomPanelToggleButton class="flex-shrink-0" />
<Button
class="flex-shrink-0"
icon="pi pi-bars"
severity="secondary"
text
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
:aria-label="$t('menu.hideMenu')"
@click="workspaceState.focusMode = true"
@contextmenu="showNativeSystemMenu"
/>
<div
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
/>
</div>
<!-- Virtual top menu for native window (drag handle) -->
@@ -49,7 +47,6 @@ import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useWebviewStore } from '@/stores/webviewStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
electronAPI,
@@ -60,7 +57,6 @@ import {
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const webviewStore = useWebviewStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)

View File

@@ -19,7 +19,6 @@
>
<template #option="{ option }">
<WorkflowTab
@click="() => webviewStore.hideActiveWebview()"
@contextmenu="showContextMenu($event, option)"
@click.middle="onCloseWorkflow(option)"
:workflow-option="option"
@@ -51,7 +50,6 @@ import { useI18n } from 'vue-i18n'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useWebviewStore } from '@/stores/webviewStore'
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -70,7 +68,6 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const webviewStore = useWebviewStore()
const rightClickedTab = ref<WorkflowOption>(null)
const menu = ref()

View File

@@ -1,29 +0,0 @@
<template>
<div class="w-full h-full relative pointer-events-auto">
<KeepAlive>
<component
v-if="
webviewStore.hasActiveWebview && webviewStore.activeWebview.keepAlive
"
:is="webviewStore.activeWebview.component"
:key="webviewStore.activeWebviewId"
v-bind="webviewStore.activeWebview.props || {}"
/>
</KeepAlive>
<component
v-if="
webviewStore.hasActiveWebview && !webviewStore.activeWebview.keepAlive
"
:is="webviewStore.activeWebview.component"
:key="webviewStore.activeWebviewId"
v-bind="webviewStore.activeWebview.props || {}"
/>
</div>
</template>
<script setup lang="ts">
import { useWebviewStore } from '@/stores/webviewStore'
const webviewStore = useWebviewStore()
</script>

View File

@@ -23,6 +23,12 @@ export function useAbsolutePosition() {
height: '0px'
})
/**
* Update the position of the element on the litegraph canvas.
*
* @param config
* @param extraStyle
*/
const updatePosition = (
config: PositionConfig,
extraStyle?: CSSProperties
@@ -41,8 +47,36 @@ export function useAbsolutePosition() {
}
}
/**
* Update the position and size of the element on the litegraph canvas,
* with CSS transform scaling applied.
*
* @param config
* @param extraStyle
*/
const updatePositionWithTransform = (
config: PositionConfig,
extraStyle?: CSSProperties
) => {
const { pos, size, scale = canvasStore.canvas?.ds?.scale ?? 1 } = config
const [left, top] = app.canvasPosToClientPos(pos)
const [width, height] = size
style.value = {
...style.value,
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
...extraStyle
}
}
return {
style,
updatePosition
updatePosition,
updatePositionWithTransform
}
}

View File

@@ -0,0 +1,123 @@
import { CSSProperties, ref } from 'vue'
interface Rect {
x: number
y: number
width: number
height: number
}
/**
* Finds the intersection between two rectangles
*/
function intersect(a: Rect, b: Rect): [number, number, number, number] | null {
const x1 = Math.max(a.x, b.x)
const y1 = Math.max(a.y, b.y)
const x2 = Math.min(a.x + a.width, b.x + b.width)
const y2 = Math.min(a.y + a.height, b.y + b.height)
if (x1 >= x2 || y1 >= y2) {
return null
}
return [x1, y1, x2 - x1, y2 - y1]
}
export interface ClippingOptions {
margin?: number
}
export const useDomClipping = (options: ClippingOptions = {}) => {
const style = ref<CSSProperties>({})
const { margin = 4 } = options
/**
* Calculates a clip path for an element based on its intersection with a selected area
*/
const calculateClipPath = (
elementRect: DOMRect,
canvasRect: DOMRect,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
): string => {
if (!isSelected && selectedArea) {
const { scale, offset } = selectedArea
// Get intersection in browser space
const intersection = intersect(
{
x: elementRect.left - canvasRect.left,
y: elementRect.top - canvasRect.top,
width: elementRect.width,
height: elementRect.height
},
{
x: (selectedArea.x + offset[0] - margin) * scale,
y: (selectedArea.y + offset[1] - margin) * scale,
width: (selectedArea.width + 2 * margin) * scale,
height: (selectedArea.height + 2 * margin) * scale
}
)
if (!intersection) {
return ''
}
// Convert intersection to canvas scale (element has scale transform)
const clipX =
(intersection[0] - elementRect.left + canvasRect.left) / scale + 'px'
const clipY =
(intersection[1] - elementRect.top + canvasRect.top) / scale + 'px'
const clipWidth = intersection[2] / scale + 'px'
const clipHeight = intersection[3] / scale + 'px'
return `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
}
return ''
}
/**
* Updates the clip-path style based on element and selection information
*/
const updateClipPath = (
element: HTMLElement,
canvasElement: HTMLCanvasElement,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
) => {
const elementRect = element.getBoundingClientRect()
const canvasRect = canvasElement.getBoundingClientRect()
const clipPath = calculateClipPath(
elementRect,
canvasRect,
isSelected,
selectedArea
)
style.value = {
clipPath: clipPath || 'none',
willChange: 'clip-path'
}
}
return {
style,
updateClipPath
}
}

View File

@@ -0,0 +1,41 @@
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { ref, watch } from 'vue'
type BreakpointKey = keyof typeof breakpointsTailwind
/**
* Composable for element with responsive collapsed state
* @param breakpointThreshold - Breakpoint at which the element should become collapsible
*/
export const useResponsiveCollapse = (
breakpointThreshold: BreakpointKey = 'lg'
) => {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isSmallScreen = breakpoints.smallerOrEqual(breakpointThreshold)
const isOpen = ref(!isSmallScreen.value)
/**
* Handles screen size changes to automatically open/close the element
* when crossing the breakpoint threshold
*/
const onIsSmallScreenChange = () => {
if (isSmallScreen.value && isOpen.value) {
isOpen.value = false
} else if (!isSmallScreen.value && !isOpen.value) {
isOpen.value = true
}
}
watch(isSmallScreen, onIsSmallScreenChange)
return {
breakpoints,
isOpen,
isSmallScreen,
open: () => (isOpen.value = true),
close: () => (isOpen.value = false),
toggle: () => (isOpen.value = !isOpen.value)
}
}

View File

@@ -0,0 +1,68 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
import { createImageHost } from '@/scripts/ui/imagePreview'
/**
* Composable for handling animated image previews in nodes
*/
export function useNodeAnimatedImage() {
/**
* Shows animated image preview for a node
* @param node The graph node to show the preview for
*/
function showAnimatedPreview(node: LGraphNode) {
if (!node.imgs?.length) return
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === ANIM_PREVIEW_WIDGET
)
if (widgetIdx > -1) {
// Replace content in existing widget
const widget = node.widgets[widgetIdx] as IWidget & {
options: { host: ReturnType<typeof createImageHost> }
}
widget.options.host.updateImages(node.imgs)
} else {
// Create new widget
const host = createImageHost(node)
// @ts-expect-error host is not a standard DOM widget option.
const widget = node.addDOMWidget(ANIM_PREVIEW_WIDGET, 'img', host.el, {
host,
// @ts-expect-error `getHeight` of image host returns void instead of number.
getHeight: host.getHeight,
onDraw: host.onDraw,
hideOnZoom: false
}) as IWidget & {
options: { host: ReturnType<typeof createImageHost> }
}
widget.serializeValue = () => undefined
widget.options.host.updateImages(node.imgs)
}
}
/**
* Removes animated image preview from a node
* @param node The graph node to remove the preview from
*/
function removeAnimatedPreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === ANIM_PREVIEW_WIDGET
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showAnimatedPreview,
removeAnimatedPreview
}
}

View File

@@ -0,0 +1,48 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
/**
* Composable for handling canvas image previews in nodes
*/
export function useNodeCanvasImagePreview() {
const imagePreviewWidget = useImagePreviewWidget()
/**
* Shows canvas image preview for a node
* @param node The graph node to show the preview for
*/
function showCanvasImagePreview(node: LGraphNode) {
if (!node.imgs?.length) return
if (!node.widgets?.find((w) => w.name === CANVAS_IMAGE_PREVIEW_WIDGET)) {
imagePreviewWidget(node, {
type: 'IMAGE_PREVIEW',
name: CANVAS_IMAGE_PREVIEW_WIDGET
})
}
}
/**
* Removes canvas image preview from a node
* @param node The graph node to remove the preview from
*/
function removeCanvasImagePreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === CANVAS_IMAGE_PREVIEW_WIDGET
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showCanvasImagePreview,
removeCanvasImagePreview
}
}

View File

@@ -111,7 +111,6 @@ export const useNodeImage = (node: LGraphNode) => {
const onLoaded = (elements: HTMLImageElement[]) => {
node.imageIndex = null
node.imgs = elements
node.setSizeForImage?.()
}
return useNodePreview(node, {
@@ -159,7 +158,6 @@ export const useNodeVideo = (node: LGraphNode) => {
node.videoContainer.replaceChildren(videoElement)
node.imageOffset = VIDEO_PIXEL_OFFSET
node.setSizeForImage?.(true)
}
return useNodePreview(node, {

View File

@@ -0,0 +1,95 @@
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
type ModelType =
| 'Checkpoint'
| 'TextualInversion'
| 'Hypernetwork'
| 'AestheticGradient'
| 'LORA'
| 'Controlnet'
| 'Poses'
interface CivitaiFileMetadata {
fp?: 'fp16' | 'fp32'
size?: 'full' | 'pruned'
format?: 'SafeTensor' | 'PickleTensor' | 'Other'
}
interface CivitaiModelFile {
name: string
id: number
sizeKB: number
type: string
downloadUrl: string
metadata: CivitaiFileMetadata
}
interface CivitaiModel {
name: string
type: ModelType
}
interface CivitaiModelVersionResponse {
id: number
name: string
model: CivitaiModel
modelId: number
files: CivitaiModelFile[]
[key: string]: any
}
/**
* Composable to manage Civitai model
* @param url - The URL of the Civitai model, where the model ID is the last part of the URL's pathname
* @see https://developer.civitai.com/docs/api/public-rest
* @example
* const { fileSize, isLoading, error, modelData } =
* useCivitaiModel('https://civitai.com/api/download/models/16576?type=Model&format=SafeTensor&size=full&fp=fp16')
*/
export function useCivitaiModel(url: string) {
const createModelVersionUrl = (modelId: string): string =>
`https://civitai.com/api/v1/model-versions/${modelId}`
const extractModelIdFromUrl = (): string | null => {
const urlObj = new URL(url)
return urlObj.pathname.split('/').pop() || null
}
const fetchModelData =
async (): Promise<CivitaiModelVersionResponse | null> => {
const modelId = extractModelIdFromUrl()
if (!modelId) return null
const apiUrl = createModelVersionUrl(modelId)
const res = await fetch(apiUrl)
return res.json()
}
const findMatchingFileSize = (): number | null => {
const matchingFile = modelData.value?.files?.find(
(file) => file.downloadUrl && url.startsWith(file.downloadUrl)
)
return matchingFile?.sizeKB ? matchingFile.sizeKB << 10 : null
}
const {
state: modelData,
isLoading,
error
} = useAsyncState(fetchModelData, null, {
immediate: true
})
const fileSize = computed(() =>
!isLoading.value ? findMatchingFileSize() : null
)
return {
fileSize,
isLoading,
error,
modelData
}
}

View File

@@ -581,6 +581,15 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}
},
{
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Custom Nodes Manager',
versionAdded: '1.12.10',
function: () => {
dialogService.showManagerDialog()
}
}
]
}

View File

@@ -1,16 +1,24 @@
import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCivitaiModel } from '@/composables/useCivitaiModel'
import { isCivitaiModelUrl } from '@/utils/formatUtil'
export function useDownload(url: string, fileName?: string) {
const fileSize = ref<number | null>(null)
const fetchFileSize = async (): Promise<number | null> => {
const setFileSize = (size: number) => {
fileSize.value = size
}
const fetchFileSize = async () => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) throw new Error('Failed to fetch file size')
const size = response.headers.get('content-length')
if (size) {
return parseInt(size)
setFileSize(parseInt(size))
} else {
console.error('"content-length" header not found')
return null
@@ -33,8 +41,15 @@ export function useDownload(url: string, fileName?: string) {
link.click()
}
onMounted(async () => {
fileSize.value = await fetchFileSize()
onMounted(() => {
if (isCivitaiModelUrl(url)) {
const { fileSize: civitaiSize, error: civitaiErr } = useCivitaiModel(url)
whenever(civitaiSize, setFileSize)
// Try falling back to normal fetch if using Civitai API fails
whenever(civitaiErr, fetchFileSize, { once: true })
} else {
fetchFileSize()
}
})
return {

View File

@@ -77,15 +77,6 @@ export const useLitegraphSettings = () => {
}
})
watchEffect(() => {
const reroutesEnabled = settingStore.get('Comfy.RerouteBeta')
const { canvas } = canvasStore
if (canvas) {
canvas.reroutesEnabled = reroutesEnabled
canvas.setDirty(false, true)
}
})
watchEffect(() => {
const maximumFps = settingStore.get('LiteGraph.Canvas.MaximumFps')
const { canvas } = canvasStore

View File

@@ -0,0 +1,93 @@
import { useEventListener, whenever } from '@vueuse/core'
import { computed, readonly, ref } from 'vue'
import { api } from '@/scripts/api'
type QueuedTask<T> = {
task: () => Promise<T>
onComplete?: () => void
}
const MANAGER_WS_MSG_TYPE = 'cm-queue-status'
enum ManagerWsQueueStatus {
DONE = 'done',
IN_PROGRESS = 'in_progress'
}
export const useManagerQueue = () => {
const clientQueueItems = ref<QueuedTask<unknown>[]>([])
const clientQueueLength = computed(() => clientQueueItems.value.length)
const nextOnCompleted = ref<(() => void) | undefined>()
const serverQueueStatus = ref<ManagerWsQueueStatus>(ManagerWsQueueStatus.DONE)
const isServerIdle = computed(
() => serverQueueStatus.value === ManagerWsQueueStatus.DONE
)
const allTasksDone = computed(
() => isServerIdle.value && clientQueueLength.value === 0
)
const nextTaskReady = computed(
() => isServerIdle.value && clientQueueLength.value > 0
)
const cleanupListener = useEventListener(
api,
MANAGER_WS_MSG_TYPE,
(event: CustomEvent<{ status: ManagerWsQueueStatus }>) => {
if (event?.type === MANAGER_WS_MSG_TYPE && event.detail?.status) {
serverQueueStatus.value = event.detail.status
}
}
)
const startNextTask = () => {
const nextTask = clientQueueItems.value.shift()
if (!nextTask) return
const { task, onComplete } = nextTask
task()
.then(() => {
// Set the task's onComplete to be executed the next time the server is idle
nextOnCompleted.value = onComplete
})
.catch((e) => {
const message = `Error enqueuing task for ComfyUI Manager: ${e}`
console.error(message)
})
}
const enqueueTask = <T>(task: QueuedTask<T>): void => {
clientQueueItems.value.push(task)
}
const clearQueue = () => {
nextOnCompleted.value = undefined
clientQueueItems.value = []
}
const cleanup = () => {
clearQueue()
cleanupListener()
}
whenever(nextTaskReady, startNextTask)
whenever(isServerIdle, () => {
if (nextOnCompleted.value) {
nextOnCompleted.value()
nextOnCompleted.value = undefined
}
})
return {
allTasksDone,
statusMessage: readonly(serverQueueStatus),
queueLength: clientQueueLength,
enqueueTask,
clearQueue,
cleanup
}
}

View File

@@ -7,6 +7,7 @@ import type { components } from '@/types/comfyRegistryTypes'
const SEARCH_DEBOUNCE_TIME = 256
const DEFAULT_PAGE_SIZE = 60
const DEFAULT_SORT_FIELD: keyof components['schemas']['Node'] = 'downloads'
/**
* Composable for managing UI state of Comfy Node Registry search.
@@ -18,6 +19,7 @@ export function useRegistrySearch() {
const searchQuery = ref('')
const pageNumber = ref(1)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const sortField = ref<keyof components['schemas']['Node']>(DEFAULT_SORT_FIELD)
const searchResults = ref<components['schemas']['Node'][]>([])
const search = async () => {
@@ -26,7 +28,8 @@ export function useRegistrySearch() {
const result = isEmptySearch
? await registryStore.listAllPacks({
page: pageNumber.value,
limit: pageSize.value
limit: pageSize.value,
sort: [sortField.value]
})
: await registryService.search({
search: searchQuery.value,
@@ -45,12 +48,12 @@ export function useRegistrySearch() {
}
}
// Debounce search when query changes
const debouncedSearch = debounce(search, SEARCH_DEBOUNCE_TIME)
// Debounce search when query changes
watch(() => searchQuery.value, debouncedSearch)
// Normal search when page number changes and on load
watch(() => pageNumber.value, search, { immediate: true })
watch(() => [pageNumber.value, sortField.value], search, { immediate: true })
onUnmounted(() => {
debouncedSearch.cancel() // Cancel debounced searches
@@ -61,6 +64,7 @@ export function useRegistrySearch() {
return {
pageNumber,
pageSize,
sortField,
searchQuery,
searchResults,
isLoading: registryService.isLoading,

View File

@@ -1,70 +1,110 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
ComboInputSpec,
type InputSpec,
getComboSpecComboOptions,
isComboInputSpec
} from '@/schemas/nodeDefSchema'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useWidgetStore } from '@/stores/widgetStore'
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type BaseDOMWidget,
ComponentWidgetImpl,
addWidget
} from '@/scripts/domWidget'
import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { generateUUID } from '@/utils/formatUtil'
import { useRemoteWidget } from './useRemoteWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return undefined
}
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const widgetValue = ref<string[]>([])
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: MultiSelectWidget,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string[]) => {
widgetValue.value = value
}
}
})
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
return widget
}
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
}
) as IComboWidget
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = widget.options
widget.options = new Proxy(origOptions as Record<string | symbol, any>, {
get(target, prop: string | symbol) {
if (prop !== 'values') return target[prop]
return remoteWidget.getValue()
}
})
}
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}
export const useComboWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputName: string,
inputData: InputSpec
inputSpec: InputSpec
) => {
if (!isComboInputSpec(inputData)) {
throw new Error(`Invalid input data: ${inputData}`)
if (!isComboInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const widgetStore = useWidgetStore()
const inputOptions = inputData[1] ?? {}
const comboOptions = getComboSpecComboOptions(inputData)
const defaultValue = widgetStore.getDefaultValue(inputData)
const res = {
widget: node.addWidget('combo', inputName, defaultValue, () => {}, {
values: comboOptions
}) as IComboWidget
}
if (inputOptions.remote) {
const remoteWidget = useRemoteWidget({
inputData,
defaultValue,
node,
widget: res.widget
})
if (inputOptions.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = res.widget.options
res.widget.options = new Proxy(
origOptions as Record<string | symbol, any>,
{
get(target, prop: string | symbol) {
if (prop !== 'values') return target[prop]
return remoteWidget.getValue()
}
}
)
}
if (inputOptions.control_after_generate) {
res.widget.linkedWidgets = addValueControlWidgets(
node,
res.widget,
undefined,
undefined,
inputData
)
}
return res
return inputSpec.multi_select
? addMultiSelectWidget(node, inputSpec)
: addComboWidget(node, inputSpec)
}
return widgetConstructor

View File

@@ -0,0 +1,278 @@
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type {
IBaseWidget,
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number
) => {
const canvas = app.canvas
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && node.pointerDown) {
if (
mouse[0] === node.pointerDown.pos[0] &&
mouse[1] === node.pointerDown.pos[1]
) {
node.imageIndex = node.pointerDown.index
}
node.pointerDown = null
}
const imgs = node.imgs ?? []
let { imageIndex } = node
const numImages = imgs.length
if (numImages === 1 && !imageIndex) {
// This skips the thumbnail render section below
node.imageIndex = imageIndex = 0
}
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = 15
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
if (imageIndex == null) {
// No image selected; draw thumbnails of all
let cellWidth: number
let cellHeight: number
let shiftX: number
let cell_padding: number
let cols: number
const compact_mode = is_all_same_aspect_ratio(imgs)
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2
// Prevent infinite canvas2d scale-up
const largestDimension = imgs.reduce(
(acc, current) =>
Math.max(acc, current.naturalWidth, current.naturalHeight),
0
)
const fakeImgs = []
fakeImgs.length = imgs.length
fakeImgs[0] = {
naturalWidth: largestDimension,
naturalHeight: largestDimension
}
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
fakeImgs,
dw,
dh
))
} else {
cell_padding = 0
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
imgs,
dw,
dh
))
}
let anyHovered = false
node.imageRects = []
for (let i = 0; i < numImages; i++) {
const img = imgs[i]
const row = Math.floor(i / cols)
const col = i % cols
const x = col * cellWidth + shiftX
const y = row * cellHeight + shiftY
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
cellWidth,
cellHeight
)
if (anyHovered) {
node.overIndex = i
let value = 110
if (canvas.pointer_is_down) {
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
value = 125
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`
canvas.canvas.style.cursor = 'pointer'
}
}
node.imageRects.push([x, y, cellWidth, cellHeight])
const wratio = cellWidth / img.width
const hratio = cellHeight / img.height
const ratio = Math.min(wratio, hratio)
const imgHeight = ratio * img.height
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
ctx.lineWidth = 1
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
)
}
ctx.filter = 'none'
}
if (!anyHovered) {
node.pointerDown = null
node.overIndex = null
}
return
}
// Draw individual
const img = imgs[imageIndex]
let w = img.naturalWidth
let h = img.naturalHeight
const scaleX = dw / w
const scaleY = dh / h
const scale = Math.min(scaleX, scaleY, 1)
w *= scale
h *= scale
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Draw image size text below the image
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
ctx.font = '10px sans-serif'
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
const textY = y + h + 10
ctx.fillText(sizeText, x + w / 2, textY)
const drawButton = (
x: number,
y: number,
sz: number,
text: string
): boolean => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
sz,
sz
)
let fill = '#333'
let textFill = '#fff'
let isClicking = false
if (hovered) {
canvas.canvas.style.cursor = 'pointer'
if (canvas.pointer_is_down) {
fill = '#1e90ff'
isClicking = true
} else {
fill = '#eee'
textFill = '#000'
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}
if (!(numImages > 1)) return
const imageNum = (node.imageIndex ?? 0) + 1
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
const i = imageNum >= numImages ? 0 : imageNum
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
}
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
if (!node.pointerDown || node.pointerDown.index !== null) {
node.pointerDown = { index: null, pos: [...mouse] }
}
}
}
class ImagePreviewWidget implements ICustomWidget {
readonly type: 'custom'
readonly name: string
readonly options: IWidgetOptions<unknown>
// Dummy value to satisfy type requirements
value: string
y: number = 0
constructor(name: string, options: IWidgetOptions<unknown>) {
this.type = 'custom'
this.name = name
this.options = options
this.value = ''
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widget_width: number,
y: number,
H: number
): void {
renderPreview(ctx, node, y)
}
computeLayoutSize(this: IBaseWidget, node: LGraphNode) {
return {
minHeight: 220,
minWidth: 1
}
}
}
export const useImagePreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(inputSpec.name, {
serialize: false
})
)
}
return widgetConstructor
}

View File

@@ -71,7 +71,15 @@ export const useIntWidget = () => {
}
)
if (inputSpec.control_after_generate) {
const controlAfterGenerate =
inputSpec.control_after_generate ??
/**
* Compatibility with legacy node convention. Int input with name
* 'seed' or 'noise_seed' get automatically added a control widget.
*/
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
node,
widget,

View File

@@ -8,15 +8,14 @@ import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyApp } from '@/types'
import { type InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
function addMarkdownWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string },
app: ComfyApp
opts: { defaultVal: string }
) {
TiptapMarkdown.configure({
html: false,
@@ -52,6 +51,7 @@ function addMarkdownWidget(
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button !== 0) {
@@ -98,23 +98,17 @@ function addMarkdownWidget(
}
})
return { minWidth: 400, minHeight: 200, widget }
return widget
}
export const useMarkdownWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp
inputSpec: InputSpec
) => {
const defaultVal = inputData[1]?.default || ''
return addMarkdownWidget(
node,
inputName,
{ defaultVal, ...inputData[1] },
app
)
return addMarkdownWidget(node, inputSpec.name, {
defaultVal: inputSpec.default ?? ''
})
}
return widgetConstructor

View File

@@ -2,7 +2,7 @@ import { LGraphNode } from '@comfyorg/litegraph'
import { IWidget } from '@comfyorg/litegraph'
import axios from 'axios'
import type { InputSpec, RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -67,16 +67,15 @@ const fetchData = async (
export function useRemoteWidget<
T extends string | number | boolean | object
>(options: {
inputData: InputSpec
remoteConfig: RemoteWidgetConfig
defaultValue: T
node: LGraphNode
widget: IWidget
}) {
const { inputData, defaultValue, node, widget } = options
const config = (inputData[1]?.remote ?? {}) as RemoteWidgetConfig
const { refresh = 0, max_retries = MAX_RETRIES } = config
const { remoteConfig, defaultValue, node, widget } = options
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(config)
const cacheKey = createCacheKey(remoteConfig)
let isLoaded = false
let refreshQueued = false
@@ -131,7 +130,10 @@ export function useRemoteWidget<
try {
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(config, currentEntry.controller)
currentEntry.fetchPromise = fetchData(
remoteConfig,
currentEntry.controller
)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
@@ -146,11 +148,11 @@ export function useRemoteWidget<
}
const onRefresh = () => {
if (config.control_after_refresh) {
if (remoteConfig.control_after_refresh) {
const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
switch (config.control_after_refresh) {
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break

View File

@@ -1,25 +1,23 @@
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
import type { LGraphNode } from '@comfyorg/litegraph'
import { type InputSpec, isStringInputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import {
type InputSpec,
isStringInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
import type { ComfyApp } from '@/types'
function addMultilineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string },
app: ComfyApp
opts: { defaultVal: string; placeholder?: string }
) {
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
if (app.vueAppReady) {
inputEl.spellcheck = useSettingStore().get(
'Comfy.TextareaWidget.Spellcheck'
)
}
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
@@ -31,6 +29,7 @@ function addMultilineWidget(
})
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
@@ -54,43 +53,33 @@ function addMultilineWidget(
}
})
return { minWidth: 400, minHeight: 200, widget }
return widget
}
export const useStringWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp
inputSpec: InputSpec
) => {
if (!isStringInputSpec(inputData)) {
throw new Error(`Invalid input data: ${inputData}`)
if (!isStringInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const inputOptions = inputData[1] ?? {}
const defaultVal = inputOptions.default ?? ''
const multiline = inputOptions.multiline
const defaultVal = inputSpec.default ?? ''
const multiline = inputSpec.multiline
let res: { widget: IWidget }
if (multiline) {
res = addMultilineWidget(
node,
inputName,
{ defaultVal, ...inputOptions },
app
)
} else {
res = {
widget: node.addWidget('text', inputName, defaultVal, () => {}, {})
}
const widget = multiline
? addMultilineWidget(node, inputSpec.name, {
defaultVal,
placeholder: inputSpec.placeholder
})
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
if (typeof inputOptions.dynamicPrompts === 'boolean') {
res.widget.dynamicPrompts = inputOptions.dynamicPrompts
}
return res
return widget
}
return widgetConstructor

View File

@@ -528,12 +528,12 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.RerouteBeta',
category: ['LiteGraph', 'RerouteBeta'],
name: 'Opt-in to the reroute beta test',
tooltip:
'Enables the new native reroutes.\n\nReroutes can be added by holding alt and dragging from a link line, or on the link menu.\n\nDisabling this option is non-destructive - reroutes are hidden.',
experimental: true,
tooltip: 'No longer has any effect; reroutes are always enabled.',
deprecated: true,
type: 'boolean',
defaultValue: false,
versionAdded: '1.3.42'
versionAdded: '1.3.42',
versionModified: '1.13.3'
},
{
id: 'Comfy.Graph.LinkMarkers',

View File

@@ -12,6 +12,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useDialogService } from '@/services/dialogService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { ComfyExtension } from '@/types/comfy'
import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard'
@@ -442,8 +443,7 @@ export class GroupNodeConfig {
const converted = new Map()
const widgetMap = (this.oldToNewWidgetMap[node.index] = {})
for (const inputName of inputNames) {
let widgetType = app.getWidgetType(inputs[inputName], inputName)
if (widgetType) {
if (useWidgetStore().inputIsWidget(inputs[inputName])) {
const convertedIndex = node.inputs?.findIndex(
(inp) => inp.name === inputName && inp.widget?.name === inputName
)

View File

@@ -68,11 +68,13 @@ class Load3DConfiguration {
this.load3d.setBackgroundColor(bgColor)
const lightIntensity = this.load3d.loadNodeProperty('Light Intensity', 5)
const lightIntensity: number = Number(
this.load3d.loadNodeProperty('Light Intensity', 5)
)
this.load3d.setLightIntensity(lightIntensity)
const fov = this.load3d.loadNodeProperty('FOV', 35)
const fov: number = Number(this.load3d.loadNodeProperty('FOV', 35))
this.load3d.setFOV(fov)
@@ -113,6 +115,12 @@ class Load3DConfiguration {
this.load3d.setMaterialMode(materialMode)
const edgeThreshold: number = Number(
this.load3d.loadNodeProperty('Edge Threshold', 85)
)
this.load3d.setEdgeThreshold(edgeThreshold)
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
this.load3d.setCameraState(cameraState)

View File

@@ -233,6 +233,10 @@ class Load3d {
)
}
setEdgeThreshold(threshold: number): void {
this.modelManager.setEdgeThreshold(threshold)
}
setMaterialMode(mode: MaterialMode): void {
this.modelManager.setMaterialMode(mode)

View File

@@ -40,6 +40,7 @@ export class ModelManager implements ModelManagerInterface {
private eventManager: EventManagerInterface
private activeCamera: THREE.Camera
private setupCamera: (size: THREE.Vector3) => void
private lineartModel: THREE.Group
LIGHT_MODEL = 0xffffff
LIGHT_LINES = 0x455a64
@@ -84,6 +85,10 @@ export class ModelManager implements ModelManagerInterface {
})
this.standardMaterial = this.createSTLMaterial()
this.lineartModel = new THREE.Group()
this.lineartModel.name = 'lineartModel'
}
init(): void {}
@@ -142,7 +147,7 @@ export class ModelManager implements ModelManagerInterface {
}
this.edgesModel = this.currentModel.clone()
this.scene.add(this.edgesModel)
this.lineartModel.add(this.edgesModel)
const meshes: THREE.Mesh[] = []
@@ -156,7 +161,7 @@ export class ModelManager implements ModelManagerInterface {
const mesh = meshes[key]
const parent = mesh.parent
let lineGeom = new THREE.EdgesGeometry(mesh.geometry, 10)
let lineGeom = new THREE.EdgesGeometry(mesh.geometry, 85)
const line = new THREE.LineSegments(
lineGeom,
@@ -181,6 +186,88 @@ export class ModelManager implements ModelManagerInterface {
parent?.add(line)
parent?.add(thickLines)
}
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
}
setEdgeThreshold(threshold: number): void {
if (!this.edgesModel || !this.currentModel) {
return
}
const linesToRemove: THREE.Object3D[] = []
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.LineSegments ||
child instanceof LineSegments2
) {
linesToRemove.push(child)
}
})
for (const line of linesToRemove) {
if (line.parent) {
line.parent.remove(line)
}
}
const meshes: THREE.Mesh[] = []
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push(child)
}
})
for (const mesh of meshes) {
const meshClone = mesh.clone()
let lineGeom = new THREE.EdgesGeometry(meshClone.geometry, threshold)
const line = new THREE.LineSegments(
lineGeom,
new THREE.LineBasicMaterial({ color: this.LIGHT_LINES })
)
line.position.copy(mesh.position)
line.scale.copy(mesh.scale)
line.rotation.copy(mesh.rotation)
const thickLineGeom = new LineSegmentsGeometry().fromEdgesGeometry(
lineGeom
)
const thickLines = new LineSegments2(
thickLineGeom,
new LineMaterial({ color: this.LIGHT_LINES, linewidth: 13 })
)
thickLines.position.copy(mesh.position)
thickLines.scale.copy(mesh.scale)
thickLines.rotation.copy(mesh.rotation)
this.edgesModel.add(line)
this.edgesModel.add(thickLines)
}
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
this.eventManager.emitEvent('edgeThresholdChange', threshold)
}
disposeBackgroundModel(): void {
@@ -249,6 +336,7 @@ export class ModelManager implements ModelManagerInterface {
}
this.backgroundModel = this.currentModel.clone()
this.backgroundModel.visible = true
this.backgroundModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new THREE.MeshBasicMaterial({
@@ -258,12 +346,18 @@ export class ModelManager implements ModelManagerInterface {
child.material.polygonOffsetFactor = 1
child.material.polygonOffsetUnits = 1
child.renderOrder = 2
child.material.transparent = false
child.material.opacity = 0.25
}
})
this.scene.add(this.backgroundModel)
this.lineartModel.add(this.backgroundModel)
this.shadowModel = this.currentModel.clone()
// TODO this has some error, need to fix later
this.shadowModel.visible = false
this.shadowModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new ColoredShadowMaterial({
@@ -278,10 +372,10 @@ export class ModelManager implements ModelManagerInterface {
}
})
this.scene.add(this.shadowModel)
this.lineartModel.add(this.shadowModel)
this.depthModel = this.currentModel.clone()
this.depthModel.visible = true
this.depthModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new THREE.MeshBasicMaterial({
@@ -295,7 +389,7 @@ export class ModelManager implements ModelManagerInterface {
}
})
this.scene.add(this.depthModel)
this.lineartModel.add(this.depthModel)
}
initConditionalModel() {
@@ -306,7 +400,7 @@ export class ModelManager implements ModelManagerInterface {
}
this.conditionalModel = this.currentModel.clone()
this.scene.add(this.conditionalModel)
this.lineartModel.add(this.conditionalModel)
this.conditionalModel.visible = true
const meshes: THREE.Mesh[] = []
@@ -359,6 +453,18 @@ export class ModelManager implements ModelManagerInterface {
parent?.add(line)
parent?.add(thickLines)
}
this.conditionalModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
}
setMaterialMode(mode: MaterialMode): void {
@@ -366,14 +472,8 @@ export class ModelManager implements ModelManagerInterface {
return
}
this.disposeLineartModel()
this.materialMode = mode
if (mode === 'lineart' || this.materialMode === 'lineart') {
this.eventManager.emitEvent('materialLoadingStart', null)
}
if (mode === 'depth') {
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
} else {
@@ -384,6 +484,10 @@ export class ModelManager implements ModelManagerInterface {
this.currentModel.visible = mode !== 'lineart'
}
if (this.lineartModel) {
this.lineartModel.visible = mode === 'lineart'
}
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
switch (mode) {
@@ -466,57 +570,6 @@ export class ModelManager implements ModelManagerInterface {
}
})
if (mode === 'lineart') {
setTimeout(() => {
this.initEdgesModel()
this.initBackgroundModel()
this.initConditionalModel()
if (this.conditionalModel) {
this.conditionalModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
}
if (this.edgesModel) {
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
}
if (this.backgroundModel) {
this.backgroundModel.visible = true
this.backgroundModel.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) {
child.material.transparent = false
child.material.opacity = 0.25
child.material.color.set(this.LIGHT_MODEL)
}
})
}
this.eventManager.emitEvent('materialLoadingEnd', null)
}, 50)
} else if (this.materialMode === 'lineart') {
this.eventManager.emitEvent('materialLoadingEnd', null)
}
this.eventManager.emitEvent('materialModeChange', mode)
}
@@ -603,6 +656,18 @@ export class ModelManager implements ModelManagerInterface {
this.setupModelMaterials(model)
this.setupCamera(size)
this.setupLineartModel()
}
setupLineartModel(): void {
this.scene.add(this.lineartModel)
this.initEdgesModel()
this.initBackgroundModel()
this.initConditionalModel()
this.lineartModel.visible = false
}
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
@@ -620,6 +685,7 @@ export class ModelManager implements ModelManagerInterface {
if (this.originalRotation) {
this.currentModel.rotation.copy(this.originalRotation)
this.lineartModel.rotation.copy(this.originalRotation)
}
switch (direction) {
@@ -644,6 +710,8 @@ export class ModelManager implements ModelManagerInterface {
break
}
this.lineartModel.rotation.copy(this.currentModel.rotation)
this.eventManager.emitEvent('upDirectionChange', direction)
}
}

View File

@@ -1305,6 +1305,7 @@ class PaintBucketTool {
private imageData: ImageData | null = null
private data: Uint8ClampedArray | null = null
private tolerance: number = 5
private fillOpacity: number = 255 // Add opacity property (default 100%)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
@@ -1333,6 +1334,11 @@ class PaintBucketTool {
)
this.messageBroker.subscribe('invert', () => this.invertMask())
// Add new listener for opacity setting
this.messageBroker.subscribe('setFillOpacity', (opacity: number) =>
this.setFillOpacity(opacity)
)
}
private addPullTopics() {
@@ -1340,6 +1346,17 @@ class PaintBucketTool {
'getTolerance',
async () => this.tolerance
)
// Add pull topic for fillOpacity
this.messageBroker.createPullTopic(
'getFillOpacity',
async () => (this.fillOpacity / 255) * 100
)
}
// Add method to set opacity
setFillOpacity(opacity: number): void {
// Convert from percentage (0-100) to alpha value (0-255)
this.fillOpacity = Math.floor((opacity / 100) * 255)
}
private getPixel(x: number, y: number): number {
@@ -1439,8 +1456,8 @@ class PaintBucketTool {
}
visited[visitedIndex] = 1
// Set alpha to 255 for fill mode, 0 for erase mode
this.setPixel(x, y, isFillMode ? 255 : 0, maskColor)
// Set alpha to fillOpacity for fill mode, 0 for erase mode
this.setPixel(x, y, isFillMode ? this.fillOpacity : 0, maskColor)
// Check neighbors
const checkNeighbor = (nx: number, ny: number) => {
@@ -1540,6 +1557,7 @@ class ColorSelectTool {
private applyWholeImage: boolean = false
private maskBoundry: boolean = false
private maskTolerance: number = 0
private selectOpacity: number = 255 // Add opacity property (default 100%)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
@@ -1587,6 +1605,11 @@ class ColorSelectTool {
this.messageBroker.subscribe('setMaskTolerance', (maskTolerance: number) =>
this.setMaskTolerance(maskTolerance)
)
// Add new listener for opacity setting
this.messageBroker.subscribe('setSelectionOpacity', (opacity: number) =>
this.setSelectOpacity(opacity)
)
}
private async addPullTopics() {
@@ -1805,7 +1828,7 @@ class ColorSelectTool {
const x = pixelIndex % width
const y = Math.floor(pixelIndex / width)
if (this.isPixelInRange(this.getPixel(x, y), targetPixel)) {
this.setPixel(x, y, 255, maskColor)
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
}
}
// Allow UI updates between chunks
@@ -1845,7 +1868,7 @@ class ColorSelectTool {
}
visited[visitedIndex] = 1
this.setPixel(x, y, 255, maskColor)
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
// Inline direction checks for better performance
if (
@@ -1941,6 +1964,18 @@ class ColorSelectTool {
setMaskTolerance(maskTolerance: number): void {
this.maskTolerance = maskTolerance
}
// Add method to set opacity
setSelectOpacity(opacity: number): void {
// Convert from percentage (0-100) to alpha value (0-255)
this.selectOpacity = Math.floor((opacity / 100) * 255)
// Update preview if applicable
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
}
class BrushTool {
@@ -3011,10 +3046,25 @@ class UIManager {
}
)
// Add new slider for fill opacity
const fillOpacity = (await this.messageBroker.pull('getFillOpacity')) || 100
const fillOpacitySliderObj = this.createSlider(
'Fill Opacity',
0,
100,
1,
fillOpacity,
(event, value) => {
this.messageBroker.publish('setFillOpacity', parseInt(value))
}
)
paint_bucket_settings_container.appendChild(paint_bucket_settings_title)
paint_bucket_settings_container.appendChild(
paintBucketToleranceSliderObj.container
)
// Add the new opacity slider to the UI
paint_bucket_settings_container.appendChild(fillOpacitySliderObj.container)
return paint_bucket_settings_container
}
@@ -3038,6 +3088,18 @@ class UIManager {
}
)
// Add new slider for selection opacity
const selectionOpacitySliderObj = this.createSlider(
'Selection Opacity',
0,
100,
1,
100, // Default to 100%
(event, value) => {
this.messageBroker.publish('setSelectionOpacity', parseInt(value))
}
)
const livePreviewToggle = this.createToggle(
'Live Preview',
(event, value) => {
@@ -3083,6 +3145,10 @@ class UIManager {
color_select_settings_container.appendChild(
colorSelectToleranceSliderObj.container
)
// Add the new opacity slider to the UI
color_select_settings_container.appendChild(
selectionOpacitySliderObj.container
)
color_select_settings_container.appendChild(livePreviewToggle)
color_select_settings_container.appendChild(wholeImageToggle)
color_select_settings_container.appendChild(methodSelect)
@@ -4753,6 +4819,8 @@ class MessageBroker {
this.createPushTopic('setZoomText')
this.createPushTopic('resetZoom')
this.createPushTopic('invert')
this.createPushTopic('setSelectionOpacity')
this.createPushTopic('setFillOpacity')
}
/**

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import type { IWidget } from '@comfyorg/litegraph'
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
@@ -97,7 +97,7 @@ app.registerExtension({
},
getCustomWidgets() {
return {
AUDIO_UI(node, inputName: string) {
AUDIO_UI(node: LGraphNode, inputName: string) {
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')

View File

@@ -88,9 +88,6 @@ app.registerExtension({
img.onload = () => {
node.imgs = [img]
app.graph.setDirtyCanvas(true)
requestAnimationFrame(() => {
node.setSizeForImage?.()
})
}
img.src = data
}

View File

@@ -628,29 +628,27 @@ app.registerExtension({
return input?.type
}
document.addEventListener(
'litegraph:canvas',
async (e: LiteGraphCanvasEvent) => {
if (e.detail.subType === 'connectingWidgetLink') {
const { node, link, widget } = e.detail
if (!node || !link || !widget) return
app.canvas.linkConnector.events.addEventListener(
'dropped-on-widget',
(e) => {
const { node, link, widget } = e.detail
if (!node || !link || !widget) return
const nodeData = node.constructor.nodeData
if (!nodeData) return
const all = {
...nodeData?.input?.required,
...nodeData?.input?.optional
}
const inputSpec = all[widget.name]
if (!inputSpec) return
const input = convertToInput(node, widget, inputSpec)
if (!input) return
const originNode = link.node
originNode.connect(link.slot, node, node.inputs.lastIndexOf(input))
const nodeData = node.constructor.nodeData
if (!nodeData) return
const all = {
...nodeData?.input?.required,
...nodeData?.input?.optional
}
const inputSpec = all[widget.name]
if (!inputSpec) return
const input = convertToInput(node, widget, inputSpec)
if (!input) return
const originNode = link.node
originNode.connectSlots(link.fromSlot, node, input, undefined)
}
)
},

79
src/locales/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Internationalization (i18n)
Our project supports multiple languages using `vue-i18n`. This allows users around the world to use the application in their preferred language.
## Supported Languages
- en (English)
- zh (中文)
- ru (Русский)
- ja (日本語)
- ko (한국어)
- fr (Français)
## How to Add a New Language
We welcome the addition of new languages. You can add a new language by following these steps:
### 1\. Generate language files
We use [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/blob/master/packages/lobe-i18n/README.md) as our translation tool, which integrates with LLM for efficient localization.
Update the configuration file to include the new language(s) you wish to add:
```javascript
const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
entry: 'src/locales/en.json', // Base language file
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr'], // Add the new language(s) here
});
```
Set your OpenAI API Key by running the following command:
```sh
npx lobe-i18n --option
```
Once configured, generate the translation files with:
```sh
npx lobe-i18n locale
```
This will create the language files for the specified languages in the configuration.
### 2\. Update i18n Configuration
Import the newly generated locale file(s) in the `src/i18n.ts` file to include them in the application's i18n setup.
### 3\. Enable Selection of the New Language
Add the newly added language to the following item in `src/constants/coreSettings.ts`:
```typescript
{
id: 'Comfy.Locale',
name: 'Locale',
type: 'combo',
// Add the new language(s) here
options: [
{ value: 'en', text: 'English' },
{ value: 'zh', text: '中文' },
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' }
],
defaultValue: navigator.language.split('-')[0] || 'en'
},
```
This will make the new language selectable in the application's settings.
### 4\. Test the Translations
Start the development server, switch to the new language, and verify the translations. You can switch languages by opening the ComfyUI Settings and selecting from the `ComfyUI > Locale` dropdown box.

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Custom Nodes Manager"
},
"Comfy_NewBlankWorkflow": {
"label": "New Blank Workflow"
},

View File

@@ -78,7 +78,60 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"capture": "capture"
"capture": "capture",
"nodes": "Nodes",
"community": "Community",
"all": "All",
"updated": "Updated",
"resultsCount": "Found {count} Results",
"status": "Status",
"description": "Description",
"name": "Name",
"category": "Category",
"sort": "Sort",
"filter": "Filter",
"apply": "Apply"
},
"manager": {
"title": "Custom Nodes Manager",
"loadingVersions": "Loading versions...",
"selectVersion": "Select Version",
"downloads": "Downloads",
"repository": "Repository",
"license": "License",
"nightlyVersion": "Nightly",
"latestVersion": "Latest",
"createdBy": "Created By",
"totalNodes": "Total Nodes",
"discoverCommunityContent": "Discover community-made Node Packs, Extensions, and more...",
"errorConnecting": "Error connecting to the Comfy Node Registry.",
"noResultsFound": "No results found matching your search.",
"tryDifferentSearch": "Please try a different search query.",
"tryAgainLater": "Please try again later.",
"nodePack": "Node Pack",
"searchPlaceholder": "Search",
"version": "Version",
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
"packsSelected": "Packs Selected",
"status": {
"active": "Active",
"pending": "Pending",
"flagged": "Flagged",
"deleted": "Deleted",
"banned": "Banned",
"unknown": "Unknown"
},
"sort": {
"rating": "Rating",
"downloads": "Most Popular"
},
"filter": {
"nodePack": "Node Pack",
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"issueReport": {
"submitErrorReport": "Submit Error Report (Optional)",
@@ -347,9 +400,9 @@
"instantTooltip": "The workflow will be queued instantly after a generation finishes",
"onChange": "On Change",
"onChangeTooltip": "The workflow will be queued once a change is made",
"queueWorkflow": "Queue workflow (Shift to queue at front)",
"queueWorkflowFront": "Queue workflow at front",
"queue": "Queue",
"runWorkflow": "Run workflow (Shift to queue at front)",
"runWorkflowFront": "Run workflow (Queue at front)",
"run": "Run",
"execute": "Execute",
"interrupt": "Cancel current run",
"refresh": "Refresh node definitions",
@@ -369,6 +422,20 @@
},
"templateWorkflows": {
"title": "Get Started with a Template",
"category": {
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
"Basics": "Basics",
"Flux": "Flux",
"ControlNet": "ControlNet",
"Upscaling": "Upscaling",
"Video": "Video",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Area Composition": "Area Composition",
"3D": "3D",
"Audio": "Audio"
},
"template": {
"Flux": {
"flux_dev_checkpoint_example": "Flux Dev",
@@ -390,7 +457,7 @@
"inpain_model_outpainting": "Outpaint"
},
"ControlNet": {
"controlnet_example": "ControlNet",
"controlnet_example": "Scribble ControlNet",
"2_pass_pose_worship": "Pose ControlNet 2 Pass",
"depth_controlnet": "Depth ControlNet",
"depth_t2i_adapter": "Depth T2I Adapter",
@@ -512,6 +579,7 @@
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Custom Nodes Manager": "Custom Nodes Manager",
"New": "New",
"Clipspace": "Clipspace",
"Open": "Open",
@@ -858,6 +926,7 @@
"model": "Model",
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode..."
"switchingMaterialMode": "Switching Material Mode...",
"edgeThreshold": "Edge Threshold"
}
}

View File

@@ -245,7 +245,7 @@
},
"Comfy_RerouteBeta": {
"name": "Opt-in to the reroute beta test",
"tooltip": "Enables the new native reroutes.\n\nReroutes can be added by holding alt and dragging from a link line, or on the link menu.\n\nDisabling this option is non-destructive - reroutes are hidden."
"tooltip": "No longer has any effect; reroutes are always enabled."
},
"Comfy_Sidebar_Location": {
"name": "Sidebar location",

Some files were not shown because too many files have changed in this diff Show More