Compare commits
91 Commits
webview-st
...
test-for-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fd64ef38 | ||
|
|
e1e8fcf62c | ||
|
|
d133aeb614 | ||
|
|
e371880a4e | ||
|
|
bcd76ba49b | ||
|
|
aad7ded636 | ||
|
|
3e8ef33cbc | ||
|
|
0facb0458b | ||
|
|
fd6aae141a | ||
|
|
9be090f929 | ||
|
|
313f32b094 | ||
|
|
d76391b682 | ||
|
|
3c68a1da2c | ||
|
|
3de1ef993e | ||
|
|
29df14f477 | ||
|
|
e4e7d53fcc | ||
|
|
8fa970ffba | ||
|
|
d4c3685326 | ||
|
|
ebb030c401 | ||
|
|
db6a25a092 | ||
|
|
a90b6519b5 | ||
|
|
c2006412de | ||
|
|
a046e00bc3 | ||
|
|
b347693f4d | ||
|
|
c4ca694d1d | ||
|
|
cd7e2d7b91 | ||
|
|
46029a5ce3 | ||
|
|
4123cb611c | ||
|
|
03bf81cc1b | ||
|
|
710bc2bb2e | ||
|
|
e6ef4c8e6d | ||
|
|
62528fde2e | ||
|
|
abe4754904 | ||
|
|
3eb036b5e3 | ||
|
|
e29a5d3047 | ||
|
|
9cea1fe0d3 | ||
|
|
61cf07be88 | ||
|
|
ec013cc511 | ||
|
|
60022134ed | ||
|
|
1558a5bb19 | ||
|
|
445be93d50 | ||
|
|
4b05d5a8fa | ||
|
|
131229f02f | ||
|
|
3a0b337d0c | ||
|
|
97d9f90374 | ||
|
|
496abfde53 | ||
|
|
72994621a6 | ||
|
|
83f9240587 | ||
|
|
ba8c1ee823 | ||
|
|
3d6fe41ee9 | ||
|
|
96b84761f3 | ||
|
|
d32ff0b018 | ||
|
|
6e378c68f9 | ||
|
|
65573f106b | ||
|
|
d8721760f1 | ||
|
|
f53c04834f | ||
|
|
50ecefadc1 | ||
|
|
fdc899a7d9 | ||
|
|
fb0a134278 | ||
|
|
082f0061f6 | ||
|
|
d10dad85c6 | ||
|
|
e4659a3930 | ||
|
|
91388e8b16 | ||
|
|
d24bbe2d7f | ||
|
|
87c21b45d7 | ||
|
|
8718e20693 | ||
|
|
37ae12eb41 | ||
|
|
b546131efb | ||
|
|
48fe30a977 | ||
|
|
278555f984 | ||
|
|
e51dcb9860 | ||
|
|
5f149ceb30 | ||
|
|
af31937e54 | ||
|
|
d74f47db0b | ||
|
|
f7be9157e0 | ||
|
|
caaf050728 | ||
|
|
367a1c63a5 | ||
|
|
f0ba48ea22 | ||
|
|
49964b1c2f | ||
|
|
aca419e1fb | ||
|
|
e7863676dd | ||
|
|
a6d54de2a7 | ||
|
|
15e396b4cc | ||
|
|
ba4e4ed0b8 | ||
|
|
35e6cabfe7 | ||
|
|
8a479979b1 | ||
|
|
1882a9af6f | ||
|
|
c3a984a293 | ||
|
|
4c31d04573 | ||
|
|
621568f99e | ||
|
|
b80c991299 |
2
.github/workflows/release.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
`
|
||||
|
||||
85
README.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
32
browser_tests/assets/widgets/load_audio_widget.json
Normal 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
|
||||
}
|
||||
46
browser_tests/assets/widgets/load_image_widget.json
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
13
public/assets/images/fallback-gradient-avatar.svg
Normal file
|
After Width: | Height: | Size: 81 KiB |
@@ -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'
|
||||
|
||||
31
src/components/common/ContentDivider.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -289,7 +289,7 @@ watch(activeCategory, (_, oldValue) => {
|
||||
display: flex;
|
||||
height: 70vh;
|
||||
width: 60vw;
|
||||
max-width: 1024px;
|
||||
max-width: 64rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
207
src/components/dialog/content/manager/ManagerDialogContent.vue
Normal 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>
|
||||
14
src/components/dialog/content/manager/ManagerHeader.vue
Normal 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>
|
||||
50
src/components/dialog/content/manager/ManagerNavSidebar.vue
Normal 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>
|
||||
24
src/components/dialog/content/manager/PackInstallButton.vue
Normal 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>
|
||||
82
src/components/dialog/content/manager/PackStatusMessage.vue
Normal 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>
|
||||
85
src/components/dialog/content/manager/PackVersionBadge.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
114
src/components/dialog/content/manager/infoPanel/InfoPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
37
src/components/dialog/content/manager/infoPanel/InfoTabs.vue
Normal 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>
|
||||
@@ -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>
|
||||
108
src/components/dialog/content/manager/infoPanel/MarkdownText.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
116
src/components/dialog/content/manager/packCard/PackCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
41
src/components/dialog/content/manager/packIcon/PackIcon.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
78
src/components/graph/DomWidgets.vue
Normal 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>
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
143
src/components/graph/widgets/DomWidget.vue
Normal 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>
|
||||
30
src/components/graph/widgets/MultiSelectWidget.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
116
src/components/load3d/controls/CameraControls.vue
Normal 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>
|
||||
94
src/components/load3d/controls/LightControls.vue
Normal 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>
|
||||
242
src/components/load3d/controls/ModelControls.vue
Normal 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>
|
||||
147
src/components/load3d/controls/SceneControls.vue
Normal 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>
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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}` : ''
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
123
src/composables/element/useDomClipping.ts
Normal 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
|
||||
}
|
||||
}
|
||||
41
src/composables/element/useResponsiveCollapse.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
68
src/composables/node/useNodeAnimatedImage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
48
src/composables/node/useNodeCanvasImagePreview.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
95
src/composables/useCivitaiModel.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
93
src/composables/useManagerQueue.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
278
src/composables/widgets/useImagePreviewWidget.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -233,6 +233,10 @@ class Load3d {
|
||||
)
|
||||
}
|
||||
|
||||
setEdgeThreshold(threshold: number): void {
|
||||
this.modelManager.setEdgeThreshold(threshold)
|
||||
}
|
||||
|
||||
setMaterialMode(mode: MaterialMode): void {
|
||||
this.modelManager.setMaterialMode(mode)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -88,9 +88,6 @@ app.registerExtension({
|
||||
img.onload = () => {
|
||||
node.imgs = [img]
|
||||
app.graph.setDirtyCanvas(true)
|
||||
requestAnimationFrame(() => {
|
||||
node.setSizeForImage?.()
|
||||
})
|
||||
}
|
||||
img.src = data
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -125,6 +125,9 @@
|
||||
"Comfy_LoadDefaultWorkflow": {
|
||||
"label": "Load Default Workflow"
|
||||
},
|
||||
"Comfy_Manager_CustomNodesManager": {
|
||||
"label": "Custom Nodes Manager"
|
||||
},
|
||||
"Comfy_NewBlankWorkflow": {
|
||||
"label": "New Blank Workflow"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||