Compare commits
47 Commits
core/1.27
...
sno-storyb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce71c2c529 | ||
|
|
e5d4d07d32 | ||
|
|
f086377307 | ||
|
|
687b9e659c | ||
|
|
da0d51311b | ||
|
|
e314d9cbd9 | ||
|
|
95baf8d2f1 | ||
|
|
f951e07cea | ||
|
|
023e466dba | ||
|
|
abd6823744 | ||
|
|
c4c0e52e64 | ||
|
|
295332dc46 | ||
|
|
5c498348b8 | ||
|
|
8133bd4b7b | ||
|
|
fd12591756 | ||
|
|
b3c939ff15 | ||
|
|
0801778f60 | ||
|
|
8ffe63f54e | ||
|
|
893409dfc8 | ||
|
|
df2fda6077 | ||
|
|
4f5bbe0605 | ||
|
|
a975e50f1b | ||
|
|
a17c74fa0c | ||
|
|
5e625a5002 | ||
|
|
002fac0232 | ||
|
|
7e115543fa | ||
|
|
80d75bb164 | ||
|
|
d59885839a | ||
|
|
cbb0f765b8 | ||
|
|
726a2fbbc9 | ||
|
|
553b5aa02b | ||
|
|
2ff0d951ed | ||
|
|
1f88925144 | ||
|
|
250433a91a | ||
|
|
eb664f47af | ||
|
|
bc85d4e87b | ||
|
|
7585444ce6 | ||
|
|
a886798a10 | ||
|
|
37975e4eac | ||
|
|
a41b8a6d4f | ||
|
|
b264685052 | ||
|
|
78d0ea6fa5 | ||
|
|
ea4e57b602 | ||
|
|
4789d86fe8 | ||
|
|
09e7d1040e | ||
|
|
dfa1cbba4f | ||
|
|
08220d50d9 |
15
.github/workflows/json-validate.yaml
vendored
@@ -1,15 +0,0 @@
|
||||
name: Validate JSON
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
json-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate JSON syntax
|
||||
run: ./scripts/cicd/check-json.sh
|
||||
@@ -88,8 +88,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
env:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||
|
||||
- name: Build types
|
||||
run: pnpm build:types
|
||||
@@ -133,7 +131,7 @@ jobs:
|
||||
|
||||
- name: Publish package
|
||||
if: steps.check_npm.outputs.exists == 'false'
|
||||
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" --no-git-checks
|
||||
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}"
|
||||
working-directory: dist
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -65,7 +65,6 @@ export const withTheme = (Story: any, context: any) => {
|
||||
|
||||
return Story()
|
||||
}
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -1012,8 +1012,6 @@ test.describe('Canvas Navigation', () => {
|
||||
test('Shift + mouse wheel should pan canvas horizontally', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.MouseWheelScroll', 'panning')
|
||||
|
||||
await comfyPage.page.click('canvas')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
@@ -5,14 +5,13 @@ import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
|
||||
const extraFileExtensions = ['.vue']
|
||||
|
||||
export default defineConfig([
|
||||
export default [
|
||||
{
|
||||
files: ['src/**/*.{js,mjs,cjs,ts,vue}']
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'src/scripts/*',
|
||||
@@ -25,58 +24,35 @@ export default defineConfig([
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['./**/*.{ts,mts}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
},
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'vite.config.mts',
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts',
|
||||
'playwright.config.ts',
|
||||
'playwright.i18n.config.ts',
|
||||
'scripts/collect-i18n-node-defs.ts'
|
||||
]
|
||||
},
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
project: ['./tsconfig.json', './tsconfig.eslint.json'],
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['./**/*.vue'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
},
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
projectService: true,
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions
|
||||
extraFileExtensions: ['.vue']
|
||||
}
|
||||
}
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
pluginVue.configs['flat/recommended'],
|
||||
...tseslint.configs.recommended,
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
eslintPluginPrettierRecommended,
|
||||
storybook.configs['flat/recommended'],
|
||||
{
|
||||
files: ['src/**/*.vue'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tseslint.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'unused-imports': unusedImports,
|
||||
// @ts-expect-error Bad types in the plugin
|
||||
'@intlify/vue-i18n': pluginI18n
|
||||
},
|
||||
rules: {
|
||||
@@ -91,7 +67,6 @@ export default defineConfig([
|
||||
'vue/multi-word-component-names': 'off', // TODO: fix
|
||||
'vue/no-template-shadow': 'off', // TODO: fix
|
||||
'vue/one-component-per-file': 'off', // TODO: fix
|
||||
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
|
||||
// Restrict deprecated PrimeVue components
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
@@ -160,5 +135,6 @@ export default defineConfig([
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
},
|
||||
...storybook.configs['flat/recommended']
|
||||
]
|
||||
@@ -25,9 +25,7 @@ const config: KnipConfig = {
|
||||
'src/types/generatedManagerTypes.ts',
|
||||
'src/types/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Staged for for use with subgraph widget promotion
|
||||
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts'
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -3,13 +3,13 @@ export default {
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
|
||||
...formatAndEslint(stagedFiles),
|
||||
'pnpm typecheck'
|
||||
'vue-tsc --noEmit'
|
||||
]
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames) {
|
||||
return [
|
||||
`pnpm exec eslint --cache --fix ${fileNames.join(' ')}`,
|
||||
`pnpm exec prettier --cache --write ${fileNames.join(' ')}`
|
||||
`eslint --fix ${fileNames.join(' ')}`,
|
||||
`prettier --write ${fileNames.join(' ')}`
|
||||
]
|
||||
}
|
||||
|
||||
29
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.27.10",
|
||||
"version": "1.27.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -14,9 +14,9 @@
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"zipdist": "node scripts/zipdist.js",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"test:browser": "npx nx e2e",
|
||||
"test:unit": "nx run test tests-ui/tests",
|
||||
@@ -38,10 +38,10 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@iconify-json/lucide": "^1.2.66",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@nx/eslint": "21.4.1",
|
||||
"@nx/playwright": "21.4.1",
|
||||
@@ -64,11 +64,11 @@
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-storybook": "^9.1.6",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-storybook": "^9.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "^15.9.0",
|
||||
"happy-dom": "^15.11.0",
|
||||
@@ -79,23 +79,22 @@
|
||||
"lint-staged": "^15.2.7",
|
||||
"nx": "21.4.1",
|
||||
"prettier": "^3.3.2",
|
||||
"storybook": "^9.1.6",
|
||||
"storybook": "^9.1.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"tsx": "^4.15.6",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"unplugin-icons": "^0.22.0",
|
||||
"unplugin-vue-components": "^0.28.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-vue-devtools": "^7.7.6",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.7",
|
||||
"vue-tsc": "^2.1.10",
|
||||
"zip-dir": "^2.0.0",
|
||||
"zod-to-json-schema": "^3.24.1"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ export default defineConfig({
|
||||
headless: true
|
||||
},
|
||||
reporter: 'list',
|
||||
workers: 1,
|
||||
timeout: 60000,
|
||||
testMatch: /collect-i18n-.*\.ts/
|
||||
})
|
||||
|
||||
1048
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -1,77 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [--debug]" >&2
|
||||
}
|
||||
|
||||
debug=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--debug)
|
||||
debug=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Validate JSON syntax in tracked files using jq
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "Error: jq is required but not installed" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
EXCLUDE_PATTERNS=(
|
||||
'**/tsconfig*.json'
|
||||
)
|
||||
|
||||
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then
|
||||
# shellcheck disable=SC2206
|
||||
EXCLUDE_PATTERNS+=( ${JSON_LINT_EXCLUDES} )
|
||||
fi
|
||||
|
||||
pathspecs=(-- '*.json')
|
||||
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
|
||||
if [[ ${pattern:0:1} == ':' ]]; then
|
||||
pathspecs+=("$pattern")
|
||||
else
|
||||
pathspecs+=(":(glob,exclude)${pattern}")
|
||||
fi
|
||||
done
|
||||
|
||||
mapfile -t json_files < <(git ls-files "${pathspecs[@]}")
|
||||
|
||||
if [ "${#json_files[@]}" -eq 0 ]; then
|
||||
echo 'No JSON files found.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$debug" -eq 1 ]; then
|
||||
echo 'JSON files to validate:'
|
||||
printf ' %s\n' "${json_files[@]}"
|
||||
fi
|
||||
|
||||
failed=0
|
||||
for file in "${json_files[@]}"; do
|
||||
if ! jq -e . "$file" >/dev/null; then
|
||||
echo "Invalid JSON syntax: $file" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo 'JSON validation failed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 'All JSON files are valid.'
|
||||
@@ -2,7 +2,6 @@ import * as fs from 'fs'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
|
||||
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
|
||||
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
|
||||
import type { FormItem, SettingParams } from '../src/platform/settings/types'
|
||||
import type { ComfyCommandImpl } from '../src/stores/commandStore'
|
||||
@@ -132,23 +131,6 @@ test('collect-i18n-general', async ({ comfyPage }) => {
|
||||
])
|
||||
)
|
||||
|
||||
// Desktop Dialogs
|
||||
const allDesktopDialogsLocale = Object.fromEntries(
|
||||
Object.values(DESKTOP_DIALOGS).map((dialog) => [
|
||||
normalizeI18nKey(dialog.id),
|
||||
{
|
||||
title: dialog.title,
|
||||
message: dialog.message,
|
||||
buttons: Object.fromEntries(
|
||||
dialog.buttons.map((button) => [
|
||||
normalizeI18nKey(button.label),
|
||||
button.label
|
||||
])
|
||||
)
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
fs.writeFileSync(
|
||||
localePath,
|
||||
JSON.stringify(
|
||||
@@ -162,8 +144,7 @@ test('collect-i18n-general', async ({ comfyPage }) => {
|
||||
...allSettingCategoriesLocale
|
||||
},
|
||||
serverConfigItems: allServerConfigsLocale,
|
||||
serverConfigCategories: allServerConfigCategoriesLocale,
|
||||
desktopDialogs: allDesktopDialogsLocale
|
||||
serverConfigCategories: allServerConfigCategoriesLocale
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as fs from 'fs'
|
||||
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import type { ComfyNodeDef } from '../src/schemas/nodeDefSchema'
|
||||
import type { ComfyApi } from '../src/scripts/api'
|
||||
import { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import { normalizeI18nKey } from '../src/utils/formatUtil'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
@@ -11,28 +11,23 @@ const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
comfyPage.page.route('**/view**', async (route) => {
|
||||
await route.fulfill({
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
|
||||
// Note: Don't mock the object_info API endpoint - let it hit the actual backend
|
||||
|
||||
const nodeDefs: ComfyNodeDefImpl[] = await comfyPage.page.evaluate(
|
||||
async () => {
|
||||
const api = window['app'].api
|
||||
const rawNodeDefs = await api.getNodeDefs()
|
||||
const { ComfyNodeDefImpl } = await import('../src/stores/nodeDefStore')
|
||||
|
||||
return (
|
||||
Object.values(rawNodeDefs)
|
||||
// Ignore DevTools nodes (used for internal testing)
|
||||
.filter((def: ComfyNodeDef) => !def.name.startsWith('DevTools'))
|
||||
.map((def: ComfyNodeDef) => new ComfyNodeDefImpl(def))
|
||||
)
|
||||
}
|
||||
const nodeDefs: ComfyNodeDefImpl[] = (
|
||||
Object.values(
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const api = window['app'].api as ComfyApi
|
||||
return await api.getNodeDefs()
|
||||
})
|
||||
) as ComfyNodeDef[]
|
||||
)
|
||||
// Ignore DevTools nodes (used for internal testing)
|
||||
.filter((def) => !def.name.startsWith('DevTools'))
|
||||
.map((def) => new ComfyNodeDefImpl(def))
|
||||
|
||||
console.log(`Collected ${nodeDefs.length} node definitions`)
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/* Inter Font Family */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-latin-normal.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-latin-italic.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
@layer theme, base, primevue, components, utilities;
|
||||
|
||||
@import './fonts.css';
|
||||
@import 'tailwindcss/theme' layer(theme);
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
@import 'tw-animate-css';
|
||||
@@ -53,20 +52,15 @@
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
/* Font Families */
|
||||
--font-inter: 'Inter', sans-serif;
|
||||
|
||||
/* Palette Colors */
|
||||
--color-charcoal-100: #55565e;
|
||||
--color-charcoal-200: #494a50;
|
||||
--color-charcoal-300: #3c3d42;
|
||||
--color-charcoal-400: #313235;
|
||||
--color-charcoal-500: #2d2e32;
|
||||
--color-charcoal-600: #262729;
|
||||
--color-charcoal-700: #202121;
|
||||
--color-charcoal-800: #171718;
|
||||
|
||||
--color-neutral-550: #636363;
|
||||
--color-charcoal-100: #171718;
|
||||
--color-charcoal-200: #202121;
|
||||
--color-charcoal-300: #262729;
|
||||
--color-charcoal-400: #2d2e32;
|
||||
--color-charcoal-500: #313235;
|
||||
--color-charcoal-600: #3c3d42;
|
||||
--color-charcoal-700: #494a50;
|
||||
--color-charcoal-800: #55565e;
|
||||
|
||||
--color-stone-100: #444444;
|
||||
--color-stone-200: #828282;
|
||||
@@ -105,16 +99,12 @@
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
--color-coral-red-600: #973a40;
|
||||
--color-coral-red-500: #c53f49;
|
||||
--color-coral-red-400: #dd424e;
|
||||
|
||||
--color-bypass: #6a246a;
|
||||
--color-bypass: #6A246A;
|
||||
--color-error: #962a2a;
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
|
||||
--color-blue-selection: rgb( from var(--color-blue-100) r g b / 0.3);
|
||||
--color-node-hover-100: rgb( from var(--color-charcoal-800) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
|
||||
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
|
||||
|
||||
/* PrimeVue pulled colors */
|
||||
@@ -127,10 +117,10 @@
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-node-component-surface: var(--color-charcoal-600);
|
||||
--color-node-component-surface: var(--color-charcoal-300);
|
||||
--color-node-component-surface-highlight: var(--color-slate-100);
|
||||
--color-node-component-surface-hovered: var(--color-charcoal-400);
|
||||
--color-node-component-surface-selected: var(--color-charcoal-200);
|
||||
--color-node-component-surface-hovered: var(--color-charcoal-500);
|
||||
--color-node-component-surface-selected: var(--color-charcoal-700);
|
||||
--color-node-stroke: var(--color-stone-100);
|
||||
}
|
||||
|
||||
@@ -142,7 +132,7 @@
|
||||
|
||||
@utility scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
|
||||
546
src/components/actionbar/BatchCountEdit.stories.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import BatchCountEdit from './BatchCountEdit.vue'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Actionbar/BatchCountEdit',
|
||||
component: BatchCountEdit,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'BatchCountEdit allows users to set the batch count for queue operations with smart increment/decrement logic. Features exponential scaling (doubling/halving) and integrates with the queue settings store for ComfyUI workflow execution. This component can accept props for controlled mode or use Pinia store state by default.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
minQueueCount: {
|
||||
control: 'number',
|
||||
description: 'Minimum allowed batch count',
|
||||
table: {
|
||||
defaultValue: { summary: '1' }
|
||||
}
|
||||
},
|
||||
maxQueueCount: {
|
||||
control: 'number',
|
||||
description: 'Maximum allowed batch count',
|
||||
table: {
|
||||
defaultValue: { summary: '100' }
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: 100
|
||||
},
|
||||
render: (_args) => ({
|
||||
components: { BatchCountEdit },
|
||||
data() {
|
||||
return {
|
||||
count: 1,
|
||||
logAction: (action: string, value: number) => {
|
||||
console.log(`${action}: ${value}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Batch Count Editor</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Set the number of times to run the workflow. Smart increment/decrement with exponential scaling.
|
||||
</p>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
|
||||
<span style="font-weight: 600;">Batch Count:</span>
|
||||
<BatchCountEdit
|
||||
v-model:batch-count="count"
|
||||
:min-queue-count="+_args.minQueueCount"
|
||||
:max-queue-count="+_args.maxQueueCount"
|
||||
@update:batch-count="(v) => logAction('Set', Number(v))"
|
||||
/>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #6b7280; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
|
||||
<strong>Note:</strong> Current value: {{count}}. Check console for action logs.
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Default batch count editor with smart exponential scaling. Uses Pinia store for state management. Click +/- buttons to see the doubling/halving behavior.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WithTooltip: Story = {
|
||||
args: {
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: 50
|
||||
},
|
||||
render: (_args) => ({
|
||||
components: { BatchCountEdit },
|
||||
data() {
|
||||
return {
|
||||
count: 4,
|
||||
logAction: (action: string, value: number) => {
|
||||
console.log(`${action}: ${value}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 40px;">
|
||||
<div style="margin-bottom: 16px; text-align: center;">
|
||||
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
|
||||
Hover over the input to see tooltip
|
||||
</div>
|
||||
<BatchCountEdit
|
||||
v-model:batch-count="count"
|
||||
:min-queue-count="+_args.minQueueCount"
|
||||
:max-queue-count="+_args.maxQueueCount"
|
||||
@update:batch-count="(v) => logAction('Set', Number(v))"
|
||||
/>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #6b7280; text-align: center; margin-top: 20px;">
|
||||
⬆️ Tooltip appears on hover with 600ms delay
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'BatchCountEdit with tooltip functionality - hover to see the "Batch Count" tooltip.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const HighBatchCount: Story = {
|
||||
args: {
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: 200
|
||||
},
|
||||
render: (_args) => ({
|
||||
components: { BatchCountEdit },
|
||||
data() {
|
||||
return {
|
||||
count: 16,
|
||||
logAction: (action: string, value: number) => {
|
||||
console.log(`${action}: ${value}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
|
||||
High batch count scenario (16 generations):
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<span style="font-weight: 600;">Batch Count:</span>
|
||||
<BatchCountEdit
|
||||
v-model:batch-count="count"
|
||||
:min-queue-count="+_args.minQueueCount"
|
||||
:max-queue-count="+_args.maxQueueCount"
|
||||
@update:batch-count="(v) => logAction('Set', Number(v))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); border-radius: 4px; padding: 12px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; color: #b45309;">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span style="font-size: 14px; font-weight: 600;">High Batch Count Warning</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #92400e; margin-top: 4px;">
|
||||
Running 16 generations will consume significant GPU time and memory. Consider reducing batch size for faster iteration.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'High batch count scenario showing potential performance warnings for large generation batches.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ActionBarContext: Story = {
|
||||
args: {
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: 100
|
||||
},
|
||||
render: (_args) => ({
|
||||
components: { BatchCountEdit },
|
||||
data() {
|
||||
return {
|
||||
count: 2,
|
||||
logAction: (action: string, value: number) => {
|
||||
console.log(`${action}: ${value}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
BatchCountEdit in realistic action bar context:
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 16px; padding: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
<!-- Mock Queue Button -->
|
||||
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
|
||||
<i class="pi pi-play"></i>
|
||||
Queue Prompt
|
||||
</button>
|
||||
|
||||
<!-- BatchCountEdit -->
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
|
||||
<BatchCountEdit
|
||||
v-model:batch-count="count"
|
||||
:min-queue-count="+_args.minQueueCount"
|
||||
:max-queue-count="+_args.maxQueueCount"
|
||||
@update:batch-count="(v) => logAction('Set', Number(v))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mock Clear Button -->
|
||||
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #6b7280; color: white; border: none; border-radius: 6px; cursor: pointer;">
|
||||
<i class="pi pi-trash"></i>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
<!-- Mock Settings -->
|
||||
<button style="padding: 8px; background: none; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer;">
|
||||
<i class="pi pi-cog" style="color: #6b7280;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'BatchCountEdit integrated within a realistic ComfyUI action bar layout with queue controls.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ExponentialScaling: Story = {
|
||||
args: {
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: 100
|
||||
},
|
||||
render: (_args) => ({
|
||||
components: { BatchCountEdit },
|
||||
data() {
|
||||
return {
|
||||
scalingLog: [],
|
||||
currentValue: 1,
|
||||
count: 1,
|
||||
logAction: (action: string, value: number) => {
|
||||
console.log(`${action}: ${value}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
simulateIncrement() {
|
||||
const current = this.currentValue
|
||||
const newValue = Math.min(current * 2, 100)
|
||||
this.scalingLog.unshift(`Increment: ${current} → ${newValue} (×2)`)
|
||||
this.currentValue = newValue
|
||||
if (this.scalingLog.length > 10) this.scalingLog.pop()
|
||||
},
|
||||
simulateDecrement() {
|
||||
const current = this.currentValue
|
||||
const newValue = Math.floor(current / 2) || 1
|
||||
this.scalingLog.unshift(`Decrement: ${current} → ${newValue} (÷2)`)
|
||||
this.currentValue = newValue
|
||||
if (this.scalingLog.length > 10) this.scalingLog.pop()
|
||||
},
|
||||
reset() {
|
||||
this.currentValue = 1
|
||||
this.scalingLog = []
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Exponential Scaling Demo</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Demonstrates the smart doubling/halving behavior of batch count controls.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-weight: 600;">Current Value:</span>
|
||||
<span style="font-size: 18px; font-weight: bold; color: #3b82f6;">{{ currentValue }}</span>
|
||||
</div>
|
||||
<BatchCountEdit
|
||||
v-model:batch-count="count"
|
||||
:min-queue-count="+_args.minQueueCount"
|
||||
:max-queue-count="+_args.maxQueueCount"
|
||||
@update:batch-count="(v) => logAction('Set', Number(v))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<button @click="simulateIncrement" style="padding: 6px 12px; background: #10b981; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
<i class="pi pi-plus"></i> Double
|
||||
</button>
|
||||
<button @click="simulateDecrement" style="padding: 6px 12px; background: #ef4444; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
<i class="pi pi-minus"></i> Halve
|
||||
</button>
|
||||
<button @click="reset" style="padding: 6px 12px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
<i class="pi pi-refresh"></i> Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="scalingLog.length" style="background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Scaling Log:</div>
|
||||
<div v-for="(entry, index) in scalingLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
|
||||
{{ entry }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Demonstrates the exponential scaling behavior - increment doubles the value, decrement halves it.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const QueueWorkflowContext: Story = {
|
||||
args: {
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: 50
|
||||
},
|
||||
render: (_args) => ({
|
||||
components: { BatchCountEdit },
|
||||
data() {
|
||||
return {
|
||||
queueStatus: 'Ready',
|
||||
totalGenerations: 1,
|
||||
estimatedTime: '~2 min',
|
||||
count: 1,
|
||||
logAction: (action: string, value: number) => {
|
||||
console.log(`${action}: ${value}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
statusColor() {
|
||||
return this.queueStatus === 'Ready'
|
||||
? '#10b981'
|
||||
: this.queueStatus === 'Running'
|
||||
? '#f59e0b'
|
||||
: '#6b7280'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateEstimate() {
|
||||
// Simulate batch count change affecting estimates
|
||||
this.totalGenerations = 1 // This would be updated by actual batch count
|
||||
this.estimatedTime = `~${this.totalGenerations * 2} min`
|
||||
},
|
||||
queueWorkflow() {
|
||||
this.queueStatus = 'Running'
|
||||
setTimeout(() => {
|
||||
this.queueStatus = 'Complete'
|
||||
}, 3000)
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Queue Workflow Context</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
BatchCountEdit within a complete workflow queuing interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mock Workflow Preview -->
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
|
||||
<i class="pi pi-sitemap" style="color: #6366f1;"></i>
|
||||
<span style="font-weight: 600;">SDXL Portrait Generation</span>
|
||||
<span :style="{color: statusColor, fontSize: '12px', fontWeight: '600'}" style="background: rgba(0,0,0,0.05); padding: 2px 8px; border-radius: 12px;">
|
||||
{{ queueStatus }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Queue Controls -->
|
||||
<div style="display: flex; align-items: center; gap: 12px; justify-content: space-between;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<button @click="queueWorkflow" style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
|
||||
<i class="pi pi-play"></i>
|
||||
Queue Prompt
|
||||
</button>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
|
||||
<BatchCountEdit
|
||||
v-model:batch-count="count"
|
||||
:min-queue-count="+_args.minQueueCount"
|
||||
:max-queue-count="+_args.maxQueueCount"
|
||||
@update:batch-count="(v) => logAction('Set', Number(v))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 12px; color: #6b7280;">Total: {{ totalGenerations }} generations</div>
|
||||
<div style="font-size: 12px; color: #6b7280;">Est. time: {{ estimatedTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'BatchCountEdit in a complete workflow queuing context with status and time estimates.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LimitConstraints: Story = {
|
||||
args: {
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: 200
|
||||
},
|
||||
render: (_args) => ({
|
||||
components: { BatchCountEdit },
|
||||
data() {
|
||||
return {
|
||||
count: 1,
|
||||
logAction: (action: string, value: number) => {
|
||||
console.log(`${action}: ${value}`)
|
||||
},
|
||||
scenarios: [
|
||||
{
|
||||
name: 'Conservative (max 10)',
|
||||
maxLimit: 10,
|
||||
description: 'For memory-constrained systems'
|
||||
},
|
||||
{
|
||||
name: 'Standard (max 50)',
|
||||
maxLimit: 50,
|
||||
description: 'Typical production usage'
|
||||
},
|
||||
{
|
||||
name: 'High-end (max 200)',
|
||||
maxLimit: 200,
|
||||
description: 'For powerful GPU setups'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Limit Constraints</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Different batch count limits for various system configurations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px;">
|
||||
<div v-for="scenario in scenarios" :key="scenario.name" style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">{{ scenario.name }}</div>
|
||||
<div style="font-size: 12px; color: #6b7280; margin-bottom: 12px;">{{ scenario.description }}</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 12px; font-weight: 600;">BATCH:</span>
|
||||
<BatchCountEdit
|
||||
v-model:batch-count="count"
|
||||
:min-queue-count="+_args.minQueueCount"
|
||||
:max-queue-count="+_args.maxQueueCount"
|
||||
@update:batch-count="(v) => logAction('Set', Number(v))"
|
||||
/>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #9ca3af; margin-top: 8px;">
|
||||
Max limit: {{ scenario.maxLimit }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Different batch count limit scenarios for various system configurations and use cases.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalInline: Story = {
|
||||
args: {
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: 20
|
||||
},
|
||||
render: (_args) => ({
|
||||
components: { BatchCountEdit },
|
||||
data() {
|
||||
return {
|
||||
count: 3,
|
||||
logAction: (action: string, value: number) => {
|
||||
console.log(`${action}: ${value}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Minimal inline usage:
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; font-size: 14px;">
|
||||
<span>Run</span>
|
||||
<BatchCountEdit
|
||||
v-model:batch-count="count"
|
||||
:min-queue-count="+_args.minQueueCount"
|
||||
:max-queue-count="+_args.maxQueueCount"
|
||||
@update:batch-count="(v) => logAction('Set', Number(v))"
|
||||
/>
|
||||
<span>times</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Minimal inline usage of BatchCountEdit within a sentence context.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,15 +40,51 @@ import { computed } from 'vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
|
||||
interface Props {
|
||||
batchCount?: number
|
||||
minQueueCount?: number
|
||||
maxQueueCount?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:batch-count', value: number): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
batchCount: undefined,
|
||||
minQueueCount: 1,
|
||||
maxQueueCount: undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const { batchCount } = storeToRefs(queueSettingsStore)
|
||||
const minQueueCount = 1
|
||||
const { batchCount: storeBatchCount } = storeToRefs(queueSettingsStore)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const maxQueueCount = computed(() =>
|
||||
const defaultMaxQueueCount = computed(() =>
|
||||
settingStore.get('Comfy.QueueButton.BatchCountLimit')
|
||||
)
|
||||
|
||||
// Use props if provided, otherwise fallback to store values
|
||||
const batchCount = computed({
|
||||
get() {
|
||||
return props.batchCount ?? storeBatchCount.value
|
||||
},
|
||||
set(value: number) {
|
||||
if (props.batchCount !== undefined) {
|
||||
emit('update:batch-count', value)
|
||||
} else {
|
||||
storeBatchCount.value = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const minQueueCount = computed(() => props.minQueueCount)
|
||||
const maxQueueCount = computed(
|
||||
() => props.maxQueueCount ?? defaultMaxQueueCount.value
|
||||
)
|
||||
|
||||
const handleClick = (increment: boolean) => {
|
||||
let newCount: number
|
||||
if (increment) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="relative overflow-hidden h-full w-full bg-neutral-900"
|
||||
>
|
||||
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
|
||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||
<div ref="terminalEl" class="h-full terminal-host" />
|
||||
</div>
|
||||
@@ -49,7 +46,7 @@ const hasSelection = ref(false)
|
||||
const isHovered = useElementHover(rootEl)
|
||||
|
||||
const terminalData = useTerminal(terminalEl)
|
||||
emit('created', terminalData, ref(rootEl))
|
||||
emit('created', terminalData, rootEl)
|
||||
|
||||
const { terminal } = terminalData
|
||||
let selectionDisposable: IDisposable | undefined
|
||||
@@ -100,13 +97,12 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
background-color: black;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
152
src/components/common/ContentDivider.stories.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ContentDivider from './ContentDivider.vue'
|
||||
|
||||
const meta: Meta<typeof ContentDivider> = {
|
||||
title: 'Components/Common/ContentDivider',
|
||||
component: ContentDivider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'ContentDivider provides a visual separation between content sections. It supports both horizontal and vertical orientations with customizable width/thickness.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
orientation: {
|
||||
control: 'select',
|
||||
options: ['horizontal', 'vertical'],
|
||||
description: 'Direction of the divider line',
|
||||
defaultValue: 'horizontal'
|
||||
},
|
||||
width: {
|
||||
control: { type: 'range', min: 0.1, max: 10, step: 0.1 },
|
||||
description: 'Width/thickness of the divider in pixels',
|
||||
defaultValue: 0.3
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ContentDivider>
|
||||
|
||||
export const Horizontal: Story = {
|
||||
args: {
|
||||
orientation: 'horizontal',
|
||||
width: 0.3
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Default horizontal divider for separating content sections vertically.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="width: 300px; padding: 20px;">
|
||||
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
|
||||
Content Section 1
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
|
||||
Content Section 2
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
orientation: 'vertical',
|
||||
width: 0.3
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Vertical divider for separating content sections horizontally.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
|
||||
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 10px; flex: 1;">
|
||||
Left Content
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 10px; flex: 1;">
|
||||
Right Content
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const ThickHorizontal: Story = {
|
||||
args: {
|
||||
orientation: 'horizontal',
|
||||
width: 2
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Thicker horizontal divider for more prominent visual separation.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="width: 300px; padding: 20px;">
|
||||
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
|
||||
Content Section 1
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
|
||||
Content Section 2
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const ThickVertical: Story = {
|
||||
args: {
|
||||
orientation: 'vertical',
|
||||
width: 3
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Thicker vertical divider for more prominent visual separation.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
|
||||
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
|
||||
Left Content
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
|
||||
Right Content
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
259
src/components/common/EditableText.stories.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import EditableText from './EditableText.vue'
|
||||
|
||||
const meta: Meta<typeof EditableText> = {
|
||||
title: 'Components/Common/EditableText',
|
||||
component: EditableText,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'EditableText allows inline text editing with sophisticated focus management and keyboard handling. It supports automatic text selection, smart filename handling (excluding extensions), and seamless transitions between view and edit modes.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text',
|
||||
description: 'The text value to display and edit'
|
||||
},
|
||||
isEditing: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the component is currently in edit mode'
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof EditableText>
|
||||
|
||||
const createEditableStoryRender =
|
||||
(
|
||||
initialText = 'Click to edit this text',
|
||||
initialEditing = false,
|
||||
stayEditing = false
|
||||
) =>
|
||||
(args: any) => ({
|
||||
components: { EditableText },
|
||||
setup() {
|
||||
const text = ref(args.modelValue || initialText)
|
||||
const editing = ref(args.isEditing ?? initialEditing)
|
||||
const actions = ref<string[]>([])
|
||||
|
||||
const logAction = (action: string, data?: any) => {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
const message = data
|
||||
? `${action}: "${data}" (${timestamp})`
|
||||
: `${action} (${timestamp})`
|
||||
actions.value.unshift(message)
|
||||
if (actions.value.length > 5) actions.value.pop()
|
||||
console.log(action, data)
|
||||
}
|
||||
|
||||
const handleEdit = (newValue: string) => {
|
||||
logAction('Edit completed', newValue)
|
||||
text.value = newValue
|
||||
editing.value = stayEditing // Stay in edit mode if specified
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
editing.value = true
|
||||
logAction('Edit started')
|
||||
}
|
||||
|
||||
return { args, text, editing, actions, handleEdit, startEdit }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div @click="startEdit" style="cursor: pointer; border: 2px dashed #ccc; border-radius: 4px; padding: 20px;">
|
||||
<div style="margin-bottom: 8px; font-size: 12px; color: #666;">Click text to edit:</div>
|
||||
<EditableText
|
||||
:modelValue="text"
|
||||
:isEditing="editing"
|
||||
@edit="handleEdit"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
|
||||
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
export const Default: Story = {
|
||||
render: createEditableStoryRender(),
|
||||
args: {
|
||||
modelValue: 'Click to edit this text',
|
||||
isEditing: false
|
||||
}
|
||||
}
|
||||
|
||||
export const AlwaysEditing: Story = {
|
||||
render: createEditableStoryRender('Always in edit mode', true, true),
|
||||
args: {
|
||||
modelValue: 'Always in edit mode',
|
||||
isEditing: true
|
||||
}
|
||||
}
|
||||
|
||||
export const FilenameEditing: Story = {
|
||||
render: () => ({
|
||||
components: { EditableText },
|
||||
setup() {
|
||||
const filenames = ref([
|
||||
'my_workflow.json',
|
||||
'image_processing.png',
|
||||
'model_config.yaml',
|
||||
'final_render.mp4'
|
||||
])
|
||||
const actions = ref<string[]>([])
|
||||
|
||||
const logAction = (action: string, filename: string, newName: string) => {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
actions.value.unshift(
|
||||
`${action}: "${filename}" → "${newName}" (${timestamp})`
|
||||
)
|
||||
if (actions.value.length > 5) actions.value.pop()
|
||||
console.log(action, { filename, newName })
|
||||
}
|
||||
|
||||
const handleFilenameEdit = (index: number, newValue: string) => {
|
||||
const oldName = filenames.value[index]
|
||||
filenames.value[index] = newValue
|
||||
logAction('Filename changed', oldName, newValue)
|
||||
}
|
||||
|
||||
return { filenames, actions, handleFilenameEdit }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px; font-weight: bold;">File Browser (click filenames to edit):</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div v-for="(filename, index) in filenames" :key="index"
|
||||
style="display: flex; align-items: center; padding: 8px; background: #f9f9f9; border-radius: 4px;">
|
||||
<i style="margin-right: 8px; color: #666;" class="pi pi-file"></i>
|
||||
<EditableText
|
||||
:modelValue="filename"
|
||||
:isEditing="false"
|
||||
@edit="(newValue) => handleFilenameEdit(index, newValue)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
|
||||
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LongText: Story = {
|
||||
render: createEditableStoryRender(
|
||||
'This is a much longer text that demonstrates how the EditableText component handles longer content with multiple words and potentially line wrapping scenarios.'
|
||||
),
|
||||
args: {
|
||||
modelValue:
|
||||
'This is a much longer text that demonstrates how the EditableText component handles longer content.',
|
||||
isEditing: false
|
||||
}
|
||||
}
|
||||
|
||||
export const EmptyState: Story = {
|
||||
render: createEditableStoryRender(''),
|
||||
args: {
|
||||
modelValue: '',
|
||||
isEditing: false
|
||||
}
|
||||
}
|
||||
|
||||
export const SingleCharacter: Story = {
|
||||
render: createEditableStoryRender('A'),
|
||||
args: {
|
||||
modelValue: 'A',
|
||||
isEditing: false
|
||||
}
|
||||
}
|
||||
|
||||
// ComfyUI usage examples
|
||||
export const WorkflowNaming: Story = {
|
||||
render: () => ({
|
||||
components: { EditableText },
|
||||
setup() {
|
||||
const workflows = ref([
|
||||
'Portrait Enhancement',
|
||||
'Landscape Generation',
|
||||
'Style Transfer Workflow',
|
||||
'Untitled Workflow'
|
||||
])
|
||||
|
||||
const handleWorkflowRename = (index: number, newName: string) => {
|
||||
workflows.value[index] = newName
|
||||
console.log('Workflow renamed:', { index, newName })
|
||||
}
|
||||
|
||||
return { workflows, handleWorkflowRename }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; width: 300px;">
|
||||
<div style="margin-bottom: 16px; font-weight: bold;">Workflow Library</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<div v-for="(workflow, index) in workflows" :key="index"
|
||||
style="padding: 12px; border: 1px solid #ddd; border-radius: 6px; background: white;">
|
||||
<EditableText
|
||||
:modelValue="workflow"
|
||||
:isEditing="false"
|
||||
@edit="(newName) => handleWorkflowRename(index, newName)"
|
||||
style="font-size: 14px; font-weight: 500;"
|
||||
/>
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">
|
||||
Last modified: 2 hours ago
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ModelRenaming: Story = {
|
||||
render: () => ({
|
||||
components: { EditableText },
|
||||
setup() {
|
||||
const models = ref([
|
||||
'stable-diffusion-v1-5.safetensors',
|
||||
'controlnet_depth.pth',
|
||||
'vae-ft-mse-840000-ema.ckpt'
|
||||
])
|
||||
|
||||
const handleModelRename = (index: number, newName: string) => {
|
||||
models.value[index] = newName
|
||||
console.log('Model renamed:', { index, newName })
|
||||
}
|
||||
|
||||
return { models, handleModelRename }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; width: 350px;">
|
||||
<div style="margin-bottom: 16px; font-weight: bold;">Model Manager</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div v-for="(model, index) in models" :key="index"
|
||||
style="display: flex; align-items: center; padding: 8px; background: #f8f8f8; border-radius: 4px;">
|
||||
<i style="margin-right: 8px; color: #4a90e2;" class="pi pi-box"></i>
|
||||
<EditableText
|
||||
:modelValue="model"
|
||||
:isEditing="false"
|
||||
@edit="(newName) => handleModelRename(index, newName)"
|
||||
style="flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 12px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<InputText
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model:model-value="inputValue"
|
||||
v-model:modelValue="inputValue"
|
||||
v-focus
|
||||
type="text"
|
||||
size="small"
|
||||
|
||||
672
src/components/common/FormItem.stories.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { FormItem as FormItemType } from '@/types/settingTypes'
|
||||
|
||||
import FormItem from './FormItem.vue'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Common/FormItem',
|
||||
component: FormItem as any,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'FormItem is a generalized form component that dynamically renders different input types based on configuration. Supports text, number, boolean, combo, slider, knob, color, image, and custom renderer inputs with proper labeling and accessibility.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
item: {
|
||||
control: 'object',
|
||||
description:
|
||||
'FormItem configuration object defining the input type and properties'
|
||||
},
|
||||
formValue: {
|
||||
control: 'text',
|
||||
description: 'The current form value (v-model)',
|
||||
defaultValue: ''
|
||||
},
|
||||
id: {
|
||||
control: 'text',
|
||||
description: 'Optional HTML id for the form input',
|
||||
defaultValue: undefined
|
||||
},
|
||||
labelClass: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes for the label',
|
||||
defaultValue: undefined
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const TextInput: Story = {
|
||||
render: (args: any) => ({
|
||||
components: { FormItem },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: args.formValue || 'Default text value',
|
||||
textItem: {
|
||||
name: 'Workflow Name',
|
||||
type: 'text',
|
||||
tooltip: 'Enter a descriptive name for your workflow',
|
||||
attrs: {
|
||||
placeholder: 'e.g., SDXL Portrait Generation'
|
||||
}
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: string) {
|
||||
console.log('Text value updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Text input form item with tooltip:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="textItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="workflow-name"
|
||||
/>
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
|
||||
Current value: "{{ value }}"
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
formValue: 'My Workflow'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Text input FormItem with tooltip and placeholder. Hover over the info icon to see the tooltip.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NumberInput: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
value: 7.5,
|
||||
numberItem: {
|
||||
name: 'CFG Scale',
|
||||
type: 'number',
|
||||
tooltip:
|
||||
'Classifier-free guidance scale controls how closely the AI follows your prompt',
|
||||
attrs: {
|
||||
min: 1,
|
||||
max: 30,
|
||||
step: 0.5,
|
||||
showButtons: true
|
||||
}
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: number) {
|
||||
console.log('CFG scale updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Number input with controls and constraints:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="numberItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="cfg-scale"
|
||||
/>
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
|
||||
Current CFG scale: {{ value }}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Number input FormItem with min/max constraints and increment buttons for CFG scale parameter.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BooleanToggle: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
value: false,
|
||||
booleanItem: {
|
||||
name: 'Enable GPU Acceleration',
|
||||
type: 'boolean',
|
||||
tooltip: 'Use GPU for faster processing when available'
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: boolean) {
|
||||
console.log('GPU acceleration toggled:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Boolean toggle switch form item:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="booleanItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="gpu-accel"
|
||||
/>
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
|
||||
GPU acceleration: {{ value ? 'Enabled' : 'Disabled' }}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Boolean FormItem using ToggleSwitch component for enable/disable settings.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ComboSelect: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
value: 'euler_a',
|
||||
comboItem: {
|
||||
name: 'Sampling Method',
|
||||
type: 'combo',
|
||||
tooltip: 'Algorithm used for denoising during generation',
|
||||
options: [
|
||||
'euler_a',
|
||||
'euler',
|
||||
'heun',
|
||||
'dpm_2',
|
||||
'dpm_2_ancestral',
|
||||
'lms',
|
||||
'dpm_fast',
|
||||
'dpm_adaptive',
|
||||
'dpmpp_2s_ancestral',
|
||||
'dpmpp_sde',
|
||||
'dpmpp_2m'
|
||||
]
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: string) {
|
||||
console.log('Sampling method updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Combo select with sampling methods:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="comboItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="sampling-method"
|
||||
/>
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
|
||||
Selected: {{ value }}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Combo select FormItem with ComfyUI sampling methods showing dropdown selection.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SliderInput: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
value: 0.7,
|
||||
sliderItem: {
|
||||
name: 'Denoise Strength',
|
||||
type: 'slider',
|
||||
tooltip:
|
||||
'How much to denoise the input image (0 = no change, 1 = complete redraw)',
|
||||
attrs: {
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01
|
||||
}
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: number) {
|
||||
console.log('Denoise strength updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Slider input with precise decimal control:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="sliderItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="denoise-strength"
|
||||
/>
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
|
||||
Denoise: {{ (value * 100).toFixed(0) }}%
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Slider FormItem for denoise strength with percentage display and fine-grained control.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const KnobInput: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
value: 20,
|
||||
knobItem: {
|
||||
name: 'Sampling Steps',
|
||||
type: 'knob',
|
||||
tooltip:
|
||||
'Number of denoising steps - more steps = higher quality but slower generation',
|
||||
attrs: {
|
||||
min: 1,
|
||||
max: 150,
|
||||
step: 1
|
||||
}
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: number) {
|
||||
console.log('Steps updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Knob input for sampling steps:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="knobItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="sampling-steps"
|
||||
/>
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
|
||||
Steps: {{ value }} ({{ value < 10 ? 'Very Fast' : value < 30 ? 'Fast' : value < 50 ? 'Balanced' : 'High Quality' }})
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Knob FormItem for sampling steps with quality indicator based on step count.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleFormItems: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
widthValue: 512,
|
||||
heightValue: 512,
|
||||
stepsValue: 20,
|
||||
cfgValue: 7.5,
|
||||
samplerValue: 'euler_a',
|
||||
hiresValue: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formItems() {
|
||||
return [
|
||||
{
|
||||
name: 'Width',
|
||||
type: 'number',
|
||||
tooltip: 'Image width in pixels',
|
||||
attrs: { min: 64, max: 2048, step: 64 }
|
||||
},
|
||||
{
|
||||
name: 'Height',
|
||||
type: 'number',
|
||||
tooltip: 'Image height in pixels',
|
||||
attrs: { min: 64, max: 2048, step: 64 }
|
||||
},
|
||||
{
|
||||
name: 'Sampling Steps',
|
||||
type: 'knob',
|
||||
tooltip: 'Number of denoising steps',
|
||||
attrs: { min: 1, max: 150, step: 1 }
|
||||
},
|
||||
{
|
||||
name: 'CFG Scale',
|
||||
type: 'slider',
|
||||
tooltip: 'Classifier-free guidance scale',
|
||||
attrs: { min: 1, max: 30, step: 0.5 }
|
||||
},
|
||||
{
|
||||
name: 'Sampler',
|
||||
type: 'combo',
|
||||
tooltip: 'Sampling algorithm',
|
||||
options: ['euler_a', 'euler', 'heun', 'dpm_2', 'dpmpp_2m']
|
||||
},
|
||||
{
|
||||
name: 'High-res Fix',
|
||||
type: 'boolean',
|
||||
tooltip: 'Enable high-resolution generation'
|
||||
}
|
||||
] as FormItemType[]
|
||||
},
|
||||
allSettings() {
|
||||
return {
|
||||
width: this.widthValue,
|
||||
height: this.heightValue,
|
||||
steps: this.stepsValue,
|
||||
cfg: this.cfgValue,
|
||||
sampler: this.samplerValue,
|
||||
enableHires: this.hiresValue
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 500px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI Generation Settings</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Multiple form items demonstrating different input types in a realistic settings panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<FormItem
|
||||
:item="formItems[0]"
|
||||
:formValue="widthValue"
|
||||
@update:formValue="(value) => widthValue = value"
|
||||
id="form-width"
|
||||
/>
|
||||
<FormItem
|
||||
:item="formItems[1]"
|
||||
:formValue="heightValue"
|
||||
@update:formValue="(value) => heightValue = value"
|
||||
id="form-height"
|
||||
/>
|
||||
<FormItem
|
||||
:item="formItems[2]"
|
||||
:formValue="stepsValue"
|
||||
@update:formValue="(value) => stepsValue = value"
|
||||
id="form-steps"
|
||||
/>
|
||||
<FormItem
|
||||
:item="formItems[3]"
|
||||
:formValue="cfgValue"
|
||||
@update:formValue="(value) => cfgValue = value"
|
||||
id="form-cfg"
|
||||
/>
|
||||
<FormItem
|
||||
:item="formItems[4]"
|
||||
:formValue="samplerValue"
|
||||
@update:formValue="(value) => samplerValue = value"
|
||||
id="form-sampler"
|
||||
/>
|
||||
<FormItem
|
||||
:item="formItems[5]"
|
||||
:formValue="hiresValue"
|
||||
@update:formValue="(value) => hiresValue = value"
|
||||
id="form-hires"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Current Settings:</div>
|
||||
<div style="font-family: monospace; font-size: 12px; color: #4b5563;">
|
||||
{{ JSON.stringify(allSettings, null, 2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Multiple FormItems demonstrating all major input types in a realistic ComfyUI settings panel.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WithCustomLabels: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
value: 'custom_model.safetensors',
|
||||
customItem: {
|
||||
name: 'Model File',
|
||||
type: 'text',
|
||||
tooltip: 'Select the checkpoint model file to use for generation',
|
||||
attrs: {
|
||||
placeholder: 'Select or enter model filename...'
|
||||
}
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: string) {
|
||||
console.log('Model file updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
FormItem with custom label styling and slots:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="customItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="model-file"
|
||||
:labelClass="{ 'font-bold': true, 'text-blue-600': true }"
|
||||
>
|
||||
<template #name-prefix>
|
||||
<i class="pi pi-download" style="margin-right: 6px; color: #3b82f6;"></i>
|
||||
</template>
|
||||
<template #name-suffix>
|
||||
<span style="margin-left: 6px; font-size: 10px; color: #ef4444;">*</span>
|
||||
</template>
|
||||
</FormItem>
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
|
||||
Selected model: {{ value || 'None' }}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'FormItem with custom label styling and prefix/suffix slots for enhanced UI elements.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ColorPicker: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
value: '#3b82f6',
|
||||
colorItem: {
|
||||
name: 'Theme Accent Color',
|
||||
type: 'color',
|
||||
tooltip: 'Primary accent color for the interface theme'
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: string) {
|
||||
console.log('Color updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Color picker form item:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="colorItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="theme-color"
|
||||
/>
|
||||
<div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
|
||||
<div style="font-size: 12px; color: #6b7280;">Preview:</div>
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: value,
|
||||
width: '40px',
|
||||
height: '20px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}"
|
||||
></div>
|
||||
<span style="font-family: monospace; font-size: 12px;">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Color picker FormItem with live preview showing the selected color value.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ComboWithComplexOptions: Story = {
|
||||
render: () => ({
|
||||
components: { FormItem },
|
||||
data() {
|
||||
return {
|
||||
value: 'medium',
|
||||
comboItem: {
|
||||
name: 'Quality Preset',
|
||||
type: 'combo',
|
||||
tooltip:
|
||||
'Predefined quality settings that adjust multiple parameters',
|
||||
options: [
|
||||
{ text: 'Draft (Fast)', value: 'draft' },
|
||||
{ text: 'Medium Quality', value: 'medium' },
|
||||
{ text: 'High Quality', value: 'high' },
|
||||
{ text: 'Ultra (Slow)', value: 'ultra' }
|
||||
]
|
||||
} as FormItemType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: string) {
|
||||
console.log('Quality preset updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
presetDescription() {
|
||||
const descriptions = {
|
||||
draft: 'Fast generation with 10 steps, suitable for previews',
|
||||
medium: 'Balanced quality with 20 steps, good for most use cases',
|
||||
high: 'High quality with 40 steps, slower but better results',
|
||||
ultra: 'Maximum quality with 80 steps, very slow but best results'
|
||||
}
|
||||
return (descriptions as any)[this.value] || 'Unknown preset'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; min-width: 400px;">
|
||||
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
|
||||
Combo with complex option objects:
|
||||
</div>
|
||||
<FormItem
|
||||
:item="comboItem"
|
||||
:formValue="value"
|
||||
@update:formValue="updateValue"
|
||||
id="quality-preset"
|
||||
/>
|
||||
<div style="margin-top: 12px; padding: 8px; background: rgba(0,0,0,0.05); border-radius: 4px;">
|
||||
<div style="font-size: 12px; font-weight: 600; color: #374151;">{{ presetDescription }}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Complex combo FormItem with object options showing text/value pairs and descriptions.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
<component
|
||||
:is="markRaw(getFormComponent(props.item))"
|
||||
:id="props.id"
|
||||
v-model:model-value="formValue"
|
||||
v-model:modelValue="formValue"
|
||||
:aria-labelledby="`${props.id}-label`"
|
||||
v-bind="getFormAttrs(props.item)"
|
||||
/>
|
||||
|
||||
566
src/components/common/InputKnob.stories.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import InputKnob from './InputKnob.vue'
|
||||
|
||||
const meta: Meta<typeof InputKnob> = {
|
||||
title: 'Components/Common/InputKnob',
|
||||
component: InputKnob,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'InputKnob combines a PrimeVue Knob and InputNumber for dual input methods. It features value synchronization, range validation, step constraints, and automatic decimal precision handling based on step values.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: { type: 'number' },
|
||||
description: 'Current numeric value (v-model)',
|
||||
defaultValue: 50
|
||||
},
|
||||
min: {
|
||||
control: { type: 'number' },
|
||||
description: 'Minimum allowed value',
|
||||
defaultValue: 0
|
||||
},
|
||||
max: {
|
||||
control: { type: 'number' },
|
||||
description: 'Maximum allowed value',
|
||||
defaultValue: 100
|
||||
},
|
||||
step: {
|
||||
control: { type: 'number', step: 0.01 },
|
||||
description: 'Step increment for both knob and input',
|
||||
defaultValue: 1
|
||||
},
|
||||
resolution: {
|
||||
control: { type: 'number', min: 0, max: 5 },
|
||||
description:
|
||||
'Number of decimal places to display (auto-calculated from step if not provided)',
|
||||
defaultValue: undefined
|
||||
},
|
||||
inputClass: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes for the number input',
|
||||
defaultValue: undefined
|
||||
},
|
||||
knobClass: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes for the knob',
|
||||
defaultValue: undefined
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof InputKnob>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { InputKnob },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: args.modelValue || 50
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleUpdate(newValue: number) {
|
||||
console.log('Value updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong>Current Value: {{ value }}</strong>
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="value"
|
||||
:min="args.min"
|
||||
:max="args.max"
|
||||
:step="args.step"
|
||||
:resolution="args.resolution"
|
||||
:inputClass="args.inputClass"
|
||||
:knobClass="args.knobClass"
|
||||
@update:modelValue="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Default InputKnob with range 0-100 and step of 1. Use either the knob or number input to change the value.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DecimalPrecision: Story = {
|
||||
render: (args) => ({
|
||||
components: { InputKnob },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: args.modelValue || 2.5
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleUpdate(newValue: number) {
|
||||
console.log('Decimal value updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong>Precision Value: {{ value }}</strong>
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="value"
|
||||
:min="args.min"
|
||||
:max="args.max"
|
||||
:step="args.step"
|
||||
:resolution="args.resolution"
|
||||
@update:modelValue="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: 2.5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 0.1
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'InputKnob with decimal step (0.1) - automatically shows one decimal place based on step precision.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const HighPrecision: Story = {
|
||||
render: (args) => ({
|
||||
components: { InputKnob },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: args.modelValue || 1.234
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleUpdate(newValue: number) {
|
||||
console.log('High precision value updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong>High Precision: {{ value }}</strong>
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="value"
|
||||
:min="args.min"
|
||||
:max="args.max"
|
||||
:step="args.step"
|
||||
:resolution="args.resolution"
|
||||
@update:modelValue="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: 1.234,
|
||||
min: 0,
|
||||
max: 5,
|
||||
step: 0.001,
|
||||
resolution: 3
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'High precision InputKnob with step of 0.001 and 3 decimal places resolution.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeRange: Story = {
|
||||
render: (args) => ({
|
||||
components: { InputKnob },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: args.modelValue || 500
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleUpdate(newValue: number) {
|
||||
console.log('Large range value updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong>Large Range Value: {{ value }}</strong>
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="value"
|
||||
:min="args.min"
|
||||
:max="args.max"
|
||||
:step="args.step"
|
||||
@update:modelValue="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: 500,
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 10
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'InputKnob with large range (0-1000) and step of 10 for coarser control.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NegativeRange: Story = {
|
||||
render: (args) => ({
|
||||
components: { InputKnob },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: args.modelValue || 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleUpdate(newValue: number) {
|
||||
console.log('Negative range value updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong>Negative Range: {{ value }}</strong>
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="value"
|
||||
:min="args.min"
|
||||
:max="args.max"
|
||||
:step="args.step"
|
||||
@update:modelValue="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: 0,
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 5
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'InputKnob with negative range (-50 to 50) demonstrating bidirectional control.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ComfyUI specific examples
|
||||
export const CFGScale: Story = {
|
||||
render: () => ({
|
||||
components: { InputKnob },
|
||||
data() {
|
||||
return {
|
||||
cfgScale: 7.5
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateCFG(value: number) {
|
||||
console.log('CFG Scale updated:', value)
|
||||
this.cfgScale = value
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
|
||||
CFG Scale
|
||||
</div>
|
||||
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
|
||||
Controls how closely the model follows the prompt
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="cfgScale"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:step="0.5"
|
||||
@update:modelValue="updateCFG"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
|
||||
Current: {{ cfgScale }} (Recommended: 6-8)
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'ComfyUI CFG Scale parameter example - common parameter for controlling prompt adherence.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SamplingSteps: Story = {
|
||||
render: () => ({
|
||||
components: { InputKnob },
|
||||
data() {
|
||||
return {
|
||||
steps: 20
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateSteps(value: number) {
|
||||
console.log('Sampling steps updated:', value)
|
||||
this.steps = value
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
|
||||
Sampling Steps
|
||||
</div>
|
||||
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
|
||||
Number of denoising steps for image generation
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="steps"
|
||||
:min="1"
|
||||
:max="150"
|
||||
:step="1"
|
||||
@update:modelValue="updateSteps"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
|
||||
Current: {{ steps }} (Higher = better quality, slower)
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'ComfyUI Sampling Steps parameter example - controls generation quality vs speed.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DenoiseStrength: Story = {
|
||||
render: () => ({
|
||||
components: { InputKnob },
|
||||
data() {
|
||||
return {
|
||||
denoise: 1.0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateDenoise(value: number) {
|
||||
console.log('Denoise strength updated:', value)
|
||||
this.denoise = value
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
|
||||
Denoise Strength
|
||||
</div>
|
||||
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
|
||||
How much noise to add (1.0 = complete denoising)
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="denoise"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
@update:modelValue="updateDenoise"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
|
||||
Current: {{ denoise }} (0.0 = no change, 1.0 = full generation)
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'ComfyUI Denoise Strength parameter example - high precision control for img2img workflows.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render: () => ({
|
||||
components: { InputKnob },
|
||||
data() {
|
||||
return {
|
||||
value: 75
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue(newValue: number) {
|
||||
console.log('Custom styled value updated:', newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<div style="margin-bottom: 16px; font-weight: 600;">
|
||||
Custom Styled InputKnob
|
||||
</div>
|
||||
<InputKnob
|
||||
:modelValue="value"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
inputClass="custom-input"
|
||||
knobClass="custom-knob"
|
||||
@update:modelValue="updateValue"
|
||||
/>
|
||||
<style>
|
||||
.custom-input {
|
||||
font-weight: bold;
|
||||
color: #2563eb;
|
||||
}
|
||||
.custom-knob {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'InputKnob with custom CSS classes applied to both knob and input components.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery showing different parameter types
|
||||
export const ParameterGallery: Story = {
|
||||
render: () => ({
|
||||
components: { InputKnob },
|
||||
data() {
|
||||
return {
|
||||
params: {
|
||||
cfg: 7.5,
|
||||
steps: 20,
|
||||
denoise: 1.0,
|
||||
temperature: 0.8
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateParam(param: string, value: number) {
|
||||
console.log(`${param} updated:`, value)
|
||||
;(this.params as any)[param] = value
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; padding: 20px; max-width: 600px;">
|
||||
<div>
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">CFG Scale</div>
|
||||
<InputKnob
|
||||
:modelValue="params.cfg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:step="0.5"
|
||||
@update:modelValue="(v) => updateParam('cfg', v)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">Steps</div>
|
||||
<InputKnob
|
||||
:modelValue="params.steps"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
@update:modelValue="(v) => updateParam('steps', v)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">Denoise</div>
|
||||
<InputKnob
|
||||
:modelValue="params.denoise"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
@update:modelValue="(v) => updateParam('denoise', v)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">Temperature</div>
|
||||
<InputKnob
|
||||
:modelValue="params.temperature"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="0.1"
|
||||
@update:modelValue="(v) => updateParam('temperature', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Gallery showing different parameter types commonly used in ComfyUI workflows.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
256
src/components/common/NoResultsPlaceholder.stories.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import NoResultsPlaceholder from './NoResultsPlaceholder.vue'
|
||||
|
||||
const meta: Meta<typeof NoResultsPlaceholder> = {
|
||||
title: 'Components/Common/NoResultsPlaceholder',
|
||||
component: NoResultsPlaceholder,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'NoResultsPlaceholder displays an empty state with optional icon, title, message, and action button. Built with PrimeVue Card component and customizable styling.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
class: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes to apply to the wrapper',
|
||||
defaultValue: undefined
|
||||
},
|
||||
icon: {
|
||||
control: 'text',
|
||||
description: 'PrimeIcons icon class to display',
|
||||
defaultValue: undefined
|
||||
},
|
||||
title: {
|
||||
control: 'text',
|
||||
description: 'Main heading text',
|
||||
defaultValue: 'No Results'
|
||||
},
|
||||
message: {
|
||||
control: 'text',
|
||||
description: 'Descriptive message text (supports multi-line with \\n)',
|
||||
defaultValue: 'No items found'
|
||||
},
|
||||
textClass: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes for the message text',
|
||||
defaultValue: undefined
|
||||
},
|
||||
buttonLabel: {
|
||||
control: 'text',
|
||||
description: 'Label for action button (button hidden if not provided)',
|
||||
defaultValue: undefined
|
||||
},
|
||||
onAction: {
|
||||
action: 'action',
|
||||
description: 'Event emitted when action button is clicked'
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof NoResultsPlaceholder>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'No Results',
|
||||
message: 'No items found'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Basic placeholder with just title and message.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
icon: 'pi pi-search',
|
||||
title: 'No Search Results',
|
||||
message: 'Try adjusting your search criteria or filters'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Placeholder with a search icon to indicate empty search results.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WithActionButton: Story = {
|
||||
args: {
|
||||
icon: 'pi pi-plus',
|
||||
title: 'No Items',
|
||||
message: 'Get started by creating your first item',
|
||||
buttonLabel: 'Create Item'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Placeholder with an action button to help users take the next step.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultilineMessage: Story = {
|
||||
args: {
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
title: 'Connection Error',
|
||||
message:
|
||||
'Unable to load data from the server.\nPlease check your internet connection\nand try again.',
|
||||
buttonLabel: 'Retry'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Placeholder with multi-line message using newline characters.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EmptyWorkflow: Story = {
|
||||
args: {
|
||||
icon: 'pi pi-sitemap',
|
||||
title: 'No Workflows',
|
||||
message:
|
||||
'Create your first ComfyUI workflow to get started with image generation',
|
||||
buttonLabel: 'New Workflow'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Example for empty workflow state in ComfyUI context.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EmptyModels: Story = {
|
||||
args: {
|
||||
icon: 'pi pi-download',
|
||||
title: 'No Models Found',
|
||||
message:
|
||||
'Download models from the model manager to start generating images',
|
||||
buttonLabel: 'Open Model Manager'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Example for empty models state with download action.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FilteredResults: Story = {
|
||||
args: {
|
||||
icon: 'pi pi-filter',
|
||||
title: 'No Matching Results',
|
||||
message:
|
||||
'No items match your current filters.\nTry clearing some filters to see more results.',
|
||||
buttonLabel: 'Clear Filters'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Placeholder for filtered results with option to clear filters.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
class: 'custom-placeholder',
|
||||
icon: 'pi pi-star',
|
||||
title: 'No Favorites',
|
||||
message: 'Mark items as favorites to see them here',
|
||||
textClass: 'text-muted-foreground',
|
||||
buttonLabel: 'Browse Items'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Placeholder with custom CSS classes applied.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive story to test action event
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
icon: 'pi pi-cog',
|
||||
title: 'Configuration Required',
|
||||
message: 'Complete the setup to continue',
|
||||
buttonLabel: 'Configure'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Interactive placeholder - click the button to see the action event in the Actions panel.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery view showing different icon options
|
||||
export const IconGallery: Story = {
|
||||
render: () => ({
|
||||
components: { NoResultsPlaceholder },
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; padding: 20px;">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-search"
|
||||
title="Search"
|
||||
message="No search results"
|
||||
/>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-inbox"
|
||||
title="Empty Inbox"
|
||||
message="No messages"
|
||||
/>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-heart"
|
||||
title="No Favorites"
|
||||
message="No favorite items"
|
||||
/>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-folder-open"
|
||||
title="Empty Folder"
|
||||
message="This folder is empty"
|
||||
/>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-shopping-cart"
|
||||
title="Empty Cart"
|
||||
message="Your cart is empty"
|
||||
/>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-users"
|
||||
title="No Users"
|
||||
message="No users found"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Gallery showing different icon options and use cases.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/components/common/RefreshButton.stories.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import RefreshButton from './RefreshButton.vue'
|
||||
|
||||
const meta: Meta<typeof RefreshButton> = {
|
||||
title: 'Components/Common/RefreshButton',
|
||||
component: RefreshButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'RefreshButton is an interactive button with loading state management. It shows a refresh icon that transforms into a progress spinner when active, using v-model for state control.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'boolean',
|
||||
description: 'Active/loading state of the button (v-model)'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the button is disabled'
|
||||
},
|
||||
outlined: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to use outlined button style'
|
||||
},
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['secondary', 'success', 'info', 'warn', 'help', 'danger'],
|
||||
description: 'PrimeVue severity level for button styling'
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof RefreshButton>
|
||||
|
||||
const createStoryRender =
|
||||
(initialState = false, asyncDuration = 2000) =>
|
||||
(args: any) => ({
|
||||
components: { RefreshButton },
|
||||
setup() {
|
||||
const isActive = ref(args.modelValue ?? initialState)
|
||||
const actions = ref<string[]>([])
|
||||
|
||||
const logAction = (action: string) => {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
actions.value.unshift(`${action} (${timestamp})`)
|
||||
if (actions.value.length > 5) actions.value.pop()
|
||||
console.log(action)
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
logAction('Refresh started')
|
||||
isActive.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, asyncDuration))
|
||||
isActive.value = false
|
||||
logAction('Refresh completed')
|
||||
}
|
||||
|
||||
return { args, isActive, actions, handleRefresh }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px;">
|
||||
<RefreshButton
|
||||
v-model="isActive"
|
||||
v-bind="args"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
|
||||
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
export const Default: Story = {
|
||||
render: createStoryRender(),
|
||||
args: {
|
||||
modelValue: false,
|
||||
disabled: false,
|
||||
outlined: true,
|
||||
severity: 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
export const Active: Story = {
|
||||
render: createStoryRender(true),
|
||||
args: {
|
||||
disabled: false,
|
||||
outlined: true,
|
||||
severity: 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: createStoryRender(),
|
||||
args: {
|
||||
disabled: true,
|
||||
outlined: true,
|
||||
severity: 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
export const Filled: Story = {
|
||||
render: createStoryRender(),
|
||||
args: {
|
||||
disabled: false,
|
||||
outlined: false,
|
||||
severity: 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
export const SuccessSeverity: Story = {
|
||||
render: createStoryRender(),
|
||||
args: {
|
||||
disabled: false,
|
||||
outlined: true,
|
||||
severity: 'success'
|
||||
}
|
||||
}
|
||||
|
||||
export const DangerSeverity: Story = {
|
||||
render: createStoryRender(),
|
||||
args: {
|
||||
disabled: false,
|
||||
outlined: true,
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified gallery showing all severities
|
||||
export const SeverityGallery: Story = {
|
||||
render: () => ({
|
||||
components: { RefreshButton },
|
||||
setup() {
|
||||
const severities = [
|
||||
'secondary',
|
||||
'success',
|
||||
'info',
|
||||
'warn',
|
||||
'help',
|
||||
'danger'
|
||||
]
|
||||
const states = ref(Object.fromEntries(severities.map((s) => [s, false])))
|
||||
|
||||
const refresh = async (severity: string) => {
|
||||
console.log(`Refreshing with ${severity} severity`)
|
||||
states.value[severity] = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
states.value[severity] = false
|
||||
}
|
||||
|
||||
return { severities, states, refresh }
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; padding: 20px;">
|
||||
<div v-for="severity in severities" :key="severity" style="text-align: center;">
|
||||
<RefreshButton
|
||||
v-model="states[severity]"
|
||||
:severity="severity"
|
||||
@refresh="refresh(severity)"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666; text-transform: capitalize;">
|
||||
{{ severity }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// ComfyUI usage examples
|
||||
export const WorkflowRefresh: Story = {
|
||||
render: () => ({
|
||||
components: { RefreshButton },
|
||||
setup() {
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
const refreshWorkflows = async () => {
|
||||
console.log('Refreshing workflows...')
|
||||
isRefreshing.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
isRefreshing.value = false
|
||||
console.log('Workflows refreshed!')
|
||||
}
|
||||
|
||||
return { isRefreshing, refreshWorkflows }
|
||||
},
|
||||
template: `
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 20px;">
|
||||
<span>Workflows:</span>
|
||||
<RefreshButton v-model="isRefreshing" @refresh="refreshWorkflows" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
265
src/components/common/SearchBox.stories.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchBox from './SearchBox.vue'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Common/SearchBox',
|
||||
component: SearchBox as any,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'SearchBox provides a comprehensive search interface with debounced input, active filter chips, and optional filter button. Features automatic clear functionality and sophisticated event handling for search workflows.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text',
|
||||
description: 'Current search query text (v-model)'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text for the search input'
|
||||
},
|
||||
icon: {
|
||||
control: 'text',
|
||||
description: 'PrimeIcons icon class for the search icon'
|
||||
},
|
||||
debounceTime: {
|
||||
control: { type: 'number', min: 0, max: 1000, step: 50 },
|
||||
description: 'Debounce delay in milliseconds for search events'
|
||||
},
|
||||
filterIcon: {
|
||||
control: 'text',
|
||||
description: 'Optional filter button icon (button hidden if not provided)'
|
||||
},
|
||||
filters: {
|
||||
control: 'object',
|
||||
description: 'Array of active filter chips to display'
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
const createSearchBoxRender =
|
||||
(initialFilters: any[] = []) =>
|
||||
(args: any) => ({
|
||||
components: { SearchBox },
|
||||
setup() {
|
||||
const searchQuery = ref(args.modelValue || '')
|
||||
const filters = ref(args.filters || initialFilters)
|
||||
const actions = ref<string[]>([])
|
||||
|
||||
const logAction = (action: string, data?: any) => {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
const message = data
|
||||
? `${action}: "${data}" (${timestamp})`
|
||||
: `${action} (${timestamp})`
|
||||
actions.value.unshift(message)
|
||||
if (actions.value.length > 5) actions.value.pop()
|
||||
console.log(action, data)
|
||||
}
|
||||
|
||||
const handleUpdate = (value: string) => {
|
||||
searchQuery.value = value
|
||||
logAction('Search text updated', value)
|
||||
}
|
||||
|
||||
const handleSearch = (value: string, searchFilters: any[]) => {
|
||||
logAction(
|
||||
'Debounced search',
|
||||
`"${value}" with ${searchFilters.length} filters`
|
||||
)
|
||||
}
|
||||
|
||||
const handleShowFilter = () => {
|
||||
logAction('Filter button clicked')
|
||||
}
|
||||
|
||||
const handleRemoveFilter = (filter: any) => {
|
||||
const index = filters.value.findIndex((f: any) => f === filter)
|
||||
if (index > -1) {
|
||||
filters.value.splice(index, 1)
|
||||
logAction('Filter removed', filter.label || filter)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
args,
|
||||
searchQuery,
|
||||
filters,
|
||||
actions,
|
||||
handleUpdate,
|
||||
handleSearch,
|
||||
handleShowFilter,
|
||||
handleRemoveFilter
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="width: 400px; padding: 20px;">
|
||||
<SearchBox
|
||||
:modelValue="searchQuery"
|
||||
v-bind="args"
|
||||
:filters="filters"
|
||||
@update:modelValue="handleUpdate"
|
||||
@search="handleSearch"
|
||||
@showFilter="handleShowFilter"
|
||||
@removeFilter="handleRemoveFilter"
|
||||
/>
|
||||
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
|
||||
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
export const Default: Story = {
|
||||
render: createSearchBoxRender(),
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Search nodes...',
|
||||
icon: 'pi pi-search',
|
||||
debounceTime: 300,
|
||||
filters: []
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFilters: Story = {
|
||||
render: createSearchBoxRender([
|
||||
{ label: 'Image', type: 'category' },
|
||||
{ label: 'Sampling', type: 'category' },
|
||||
{ label: 'Recent', type: 'sort' }
|
||||
]),
|
||||
args: {
|
||||
modelValue: 'stable diffusion',
|
||||
placeholder: 'Search models...',
|
||||
icon: 'pi pi-search',
|
||||
debounceTime: 300,
|
||||
filterIcon: 'pi pi-filter'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFilterButton: Story = {
|
||||
render: createSearchBoxRender(),
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Search workflows...',
|
||||
icon: 'pi pi-search',
|
||||
debounceTime: 300,
|
||||
filterIcon: 'pi pi-filter',
|
||||
filters: []
|
||||
}
|
||||
}
|
||||
|
||||
export const FastDebounce: Story = {
|
||||
render: createSearchBoxRender(),
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Fast search (50ms debounce)...',
|
||||
icon: 'pi pi-search',
|
||||
debounceTime: 50,
|
||||
filters: []
|
||||
}
|
||||
}
|
||||
|
||||
export const SlowDebounce: Story = {
|
||||
render: createSearchBoxRender(),
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Slow search (1000ms debounce)...',
|
||||
icon: 'pi pi-search',
|
||||
debounceTime: 1000,
|
||||
filters: []
|
||||
}
|
||||
}
|
||||
|
||||
// ComfyUI examples
|
||||
export const NodeSearch: Story = {
|
||||
render: () => ({
|
||||
components: { SearchBox },
|
||||
setup() {
|
||||
const searchQuery = ref('')
|
||||
const nodeFilters = ref([
|
||||
{ label: 'Sampling', type: 'category' },
|
||||
{ label: 'Popular', type: 'sort' }
|
||||
])
|
||||
|
||||
const handleSearch = (value: string, filters: any[]) => {
|
||||
console.log('Searching nodes:', { value, filters })
|
||||
}
|
||||
|
||||
const handleRemoveFilter = (filter: any) => {
|
||||
const index = nodeFilters.value.findIndex((f) => f === filter)
|
||||
if (index > -1) {
|
||||
nodeFilters.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
nodeFilters,
|
||||
handleSearch,
|
||||
handleRemoveFilter
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="width: 300px;">
|
||||
<div style="margin-bottom: 8px; font-weight: 600;">Node Library</div>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
placeholder="Search nodes..."
|
||||
icon="pi pi-box"
|
||||
:debounceTime="300"
|
||||
filterIcon="pi pi-filter"
|
||||
:filters="nodeFilters"
|
||||
@search="handleSearch"
|
||||
@removeFilter="handleRemoveFilter"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ModelSearch: Story = {
|
||||
render: () => ({
|
||||
components: { SearchBox },
|
||||
setup() {
|
||||
const searchQuery = ref('stable-diffusion')
|
||||
const modelFilters = ref([
|
||||
{ label: 'SDXL', type: 'version' },
|
||||
{ label: 'Checkpoints', type: 'type' }
|
||||
])
|
||||
|
||||
const handleSearch = (value: string, filters: any[]) => {
|
||||
console.log('Searching models:', { value, filters })
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
modelFilters,
|
||||
handleSearch
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="width: 350px;">
|
||||
<div style="margin-bottom: 8px; font-weight: 600;">Model Manager</div>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
placeholder="Search models..."
|
||||
icon="pi pi-database"
|
||||
:debounceTime="400"
|
||||
filterIcon="pi pi-sliders-h"
|
||||
:filters="modelFilters"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
279
src/components/common/SearchFilterChip.stories.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import SearchFilterChip from './SearchFilterChip.vue'
|
||||
|
||||
const meta: Meta<typeof SearchFilterChip> = {
|
||||
title: 'Components/Common/SearchFilterChip',
|
||||
component: SearchFilterChip,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'SearchFilterChip displays a removable chip with a badge and text, commonly used for showing active filters in search interfaces. Built with PrimeVue Chip and Badge components.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
text: {
|
||||
control: 'text',
|
||||
description: 'Main text content displayed on the chip',
|
||||
defaultValue: 'Filter'
|
||||
},
|
||||
badge: {
|
||||
control: 'text',
|
||||
description: 'Badge text/number displayed before the main text',
|
||||
defaultValue: '1'
|
||||
},
|
||||
badgeClass: {
|
||||
control: 'select',
|
||||
options: ['i-badge', 'o-badge', 'c-badge', 's-badge'],
|
||||
description:
|
||||
'CSS class for badge styling (i-badge: green, o-badge: red, c-badge: blue, s-badge: yellow)',
|
||||
defaultValue: 'i-badge'
|
||||
},
|
||||
onRemove: {
|
||||
description: 'Event emitted when the chip remove button is clicked'
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SearchFilterChip>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: 'Active Filter',
|
||||
badge: '5',
|
||||
badgeClass: 'i-badge'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Default search filter chip with green badge.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const InputBadge: Story = {
|
||||
args: {
|
||||
text: 'Inputs',
|
||||
badge: '3',
|
||||
badgeClass: 'i-badge'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Filter chip with green input badge (i-badge class).'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OutputBadge: Story = {
|
||||
args: {
|
||||
text: 'Outputs',
|
||||
badge: '2',
|
||||
badgeClass: 'o-badge'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Filter chip with red output badge (o-badge class).'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CategoryBadge: Story = {
|
||||
args: {
|
||||
text: 'Category',
|
||||
badge: '8',
|
||||
badgeClass: 'c-badge'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Filter chip with blue category badge (c-badge class).'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const StatusBadge: Story = {
|
||||
args: {
|
||||
text: 'Status',
|
||||
badge: '12',
|
||||
badgeClass: 's-badge'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Filter chip with yellow status badge (s-badge class).'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
text: 'Very Long Filter Name That Might Wrap',
|
||||
badge: '999+',
|
||||
badgeClass: 'i-badge'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Filter chip with long text and large badge number to test layout.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SingleCharacterBadge: Story = {
|
||||
args: {
|
||||
text: 'Model Type',
|
||||
badge: 'A',
|
||||
badgeClass: 'c-badge'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Filter chip with single character badge.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ComfyUIFilters: Story = {
|
||||
render: () => ({
|
||||
components: { SearchFilterChip },
|
||||
methods: {
|
||||
handleRemove: () => console.log('Filter removed')
|
||||
},
|
||||
template: `
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 20px;">
|
||||
<SearchFilterChip
|
||||
text="Sampling Nodes"
|
||||
badge="5"
|
||||
badgeClass="i-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<SearchFilterChip
|
||||
text="Image Outputs"
|
||||
badge="3"
|
||||
badgeClass="o-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<SearchFilterChip
|
||||
text="Conditioning"
|
||||
badge="12"
|
||||
badgeClass="c-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<SearchFilterChip
|
||||
text="Advanced"
|
||||
badge="7"
|
||||
badgeClass="s-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<SearchFilterChip
|
||||
text="SDXL Models"
|
||||
badge="24"
|
||||
badgeClass="i-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<SearchFilterChip
|
||||
text="ControlNet"
|
||||
badge="8"
|
||||
badgeClass="o-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Example showing multiple filter chips as they might appear in ComfyUI search interface.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
text: 'Removable Filter',
|
||||
badge: '42',
|
||||
badgeClass: 'i-badge'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Interactive chip - click the X button to see the remove event in the Actions panel.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery showing all badge styles
|
||||
export const BadgeStyleGallery: Story = {
|
||||
render: () => ({
|
||||
components: { SearchFilterChip },
|
||||
methods: {
|
||||
handleRemove: () => console.log('Filter removed')
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 20px; max-width: 400px;">
|
||||
<div style="text-align: center;">
|
||||
<SearchFilterChip
|
||||
text="Input Badge"
|
||||
badge="I"
|
||||
badgeClass="i-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">Green (i-badge)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<SearchFilterChip
|
||||
text="Output Badge"
|
||||
badge="O"
|
||||
badgeClass="o-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">Red (o-badge)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<SearchFilterChip
|
||||
text="Category Badge"
|
||||
badge="C"
|
||||
badgeClass="c-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">Blue (c-badge)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<SearchFilterChip
|
||||
text="Status Badge"
|
||||
badge="S"
|
||||
badgeClass="s-badge"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">Yellow (s-badge)</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Gallery showing all available badge styles and their colors.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div class="grid grid-rows-2 gap-8">
|
||||
<!-- Top container: Logo -->
|
||||
<div class="flex items-end justify-center">
|
||||
<img
|
||||
src="/assets/images/comfy-brand-mark.svg"
|
||||
:alt="t('g.logoAlt')"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
<!-- Bottom container: Progress and text -->
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<ProgressBar
|
||||
v-if="!hideProgress"
|
||||
:mode="progressMode"
|
||||
:value="progressPercentage ?? 0"
|
||||
:show-value="false"
|
||||
class="w-90 h-2 mt-8"
|
||||
:pt="{ value: { class: 'bg-brand-yellow' } }"
|
||||
/>
|
||||
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="statusText" class="text-lg text-neutral-400">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Props for the StartupDisplay component */
|
||||
interface StartupDisplayProps {
|
||||
/** Progress: 0-100 for determinate, undefined for indeterminate */
|
||||
progressPercentage?: number
|
||||
/** Main title text */
|
||||
title?: string
|
||||
/** Status text shown below the title */
|
||||
statusText?: string
|
||||
/** Hide the progress bar */
|
||||
hideProgress?: boolean
|
||||
/** Use full screen wrapper (default: true) */
|
||||
fullScreen?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
progressPercentage,
|
||||
title,
|
||||
statusText,
|
||||
hideProgress = false,
|
||||
fullScreen = true
|
||||
} = defineProps<StartupDisplayProps>()
|
||||
|
||||
const progressMode = computed(() =>
|
||||
progressPercentage === undefined ? 'indeterminate' : 'determinate'
|
||||
)
|
||||
|
||||
const wrapperClass = computed(() =>
|
||||
fullScreen
|
||||
? 'flex items-center justify-center min-h-screen'
|
||||
: 'flex items-center justify-center'
|
||||
)
|
||||
</script>
|
||||
250
src/components/common/TextDivider.stories.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import TextDivider from './TextDivider.vue'
|
||||
|
||||
const meta: Meta<typeof TextDivider> = {
|
||||
title: 'Components/Common/TextDivider',
|
||||
component: TextDivider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'TextDivider combines text with a PrimeVue divider to create labeled section separators. The text can be positioned on either side of the divider line with various styling options.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
text: {
|
||||
control: 'text',
|
||||
description: 'Text content to display alongside the divider',
|
||||
defaultValue: 'Section'
|
||||
},
|
||||
position: {
|
||||
control: 'select',
|
||||
options: ['left', 'right'],
|
||||
description: 'Position of text relative to the divider',
|
||||
defaultValue: 'left'
|
||||
},
|
||||
align: {
|
||||
control: 'select',
|
||||
options: ['left', 'center', 'right', 'top', 'bottom'],
|
||||
description: 'Alignment of the divider line',
|
||||
defaultValue: 'center'
|
||||
},
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['solid', 'dashed', 'dotted'],
|
||||
description: 'Style of the divider line',
|
||||
defaultValue: 'solid'
|
||||
},
|
||||
layout: {
|
||||
control: 'select',
|
||||
options: ['horizontal', 'vertical'],
|
||||
description: 'Layout direction of the divider',
|
||||
defaultValue: 'horizontal'
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof TextDivider>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: 'Section Title',
|
||||
position: 'left',
|
||||
align: 'center',
|
||||
type: 'solid',
|
||||
layout: 'horizontal'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Default text divider with text on the left side of a solid horizontal line.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="width: 400px; padding: 20px;">
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
|
||||
Content above divider
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
|
||||
Content below divider
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const RightPosition: Story = {
|
||||
args: {
|
||||
text: 'Section Title',
|
||||
position: 'right',
|
||||
align: 'center',
|
||||
type: 'solid',
|
||||
layout: 'horizontal'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Text divider with text positioned on the right side of the line.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="width: 400px; padding: 20px;">
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
|
||||
Content above divider
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
|
||||
Content below divider
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const DashedStyle: Story = {
|
||||
args: {
|
||||
text: 'Dashed Section',
|
||||
position: 'left',
|
||||
align: 'center',
|
||||
type: 'dashed',
|
||||
layout: 'horizontal'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Text divider with a dashed line style for a softer visual separation.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="width: 400px; padding: 20px;">
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
|
||||
Content above divider
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
|
||||
Content below divider
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const DottedStyle: Story = {
|
||||
args: {
|
||||
text: 'Dotted Section',
|
||||
position: 'right',
|
||||
align: 'center',
|
||||
type: 'dotted',
|
||||
layout: 'horizontal'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Text divider with a dotted line style for subtle content separation.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="width: 400px; padding: 20px;">
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
|
||||
Content above divider
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
|
||||
Content below divider
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const VerticalLayout: Story = {
|
||||
args: {
|
||||
text: 'Vertical',
|
||||
position: 'left',
|
||||
align: 'center',
|
||||
type: 'solid',
|
||||
layout: 'vertical'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Text divider in vertical layout for side-by-side content separation.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
|
||||
Left Content
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
|
||||
Right Content
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
text: 'Configuration Settings and Options',
|
||||
position: 'left',
|
||||
align: 'center',
|
||||
type: 'solid',
|
||||
layout: 'horizontal'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Text divider with longer text content to demonstrate text wrapping and spacing behavior.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div style="width: 300px; padding: 20px;">
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
|
||||
Content above divider
|
||||
</div>
|
||||
<story />
|
||||
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
|
||||
Content below divider
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
651
src/components/common/TreeExplorer.stories.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import TreeExplorer from './TreeExplorer.vue'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Common/TreeExplorer',
|
||||
component: TreeExplorer as any,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'TreeExplorer provides a sophisticated tree navigation component with expandable nodes, selection, context menus, drag-and-drop support, and customizable node rendering. Features folder operations, renaming, deletion, and advanced tree manipulation capabilities.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
root: {
|
||||
control: 'object',
|
||||
description: 'Root tree node with hierarchical structure'
|
||||
},
|
||||
expandedKeys: {
|
||||
control: 'object',
|
||||
description: 'Object tracking which nodes are expanded (v-model)',
|
||||
defaultValue: {}
|
||||
},
|
||||
selectionKeys: {
|
||||
control: 'object',
|
||||
description: 'Object tracking which nodes are selected (v-model)',
|
||||
defaultValue: {}
|
||||
},
|
||||
class: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes for the tree',
|
||||
defaultValue: undefined
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const BasicTree: Story = {
|
||||
render: (args: any) => ({
|
||||
components: { TreeExplorer },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: {},
|
||||
selected: {},
|
||||
treeData: {
|
||||
key: 'root',
|
||||
label: 'Root',
|
||||
children: [
|
||||
{
|
||||
key: 'workflows',
|
||||
label: 'Workflows',
|
||||
icon: 'pi pi-sitemap',
|
||||
children: [
|
||||
{
|
||||
key: 'portrait',
|
||||
label: 'Portrait Generation.json',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'landscape',
|
||||
label: 'Landscape SDXL.json',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{ key: 'anime', label: 'Anime Style.json', icon: 'pi pi-file' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'models',
|
||||
label: 'Models',
|
||||
icon: 'pi pi-download',
|
||||
children: [
|
||||
{
|
||||
key: 'checkpoints',
|
||||
label: 'Checkpoints',
|
||||
icon: 'pi pi-folder',
|
||||
children: [
|
||||
{
|
||||
key: 'sdxl',
|
||||
label: 'SDXL_base.safetensors',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'sd15',
|
||||
label: 'SD_1.5.safetensors',
|
||||
icon: 'pi pi-file'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'lora',
|
||||
label: 'LoRA',
|
||||
icon: 'pi pi-folder',
|
||||
children: [
|
||||
{
|
||||
key: 'portrait_lora',
|
||||
label: 'portrait_enhance.safetensors',
|
||||
icon: 'pi pi-file'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'outputs',
|
||||
label: 'Outputs',
|
||||
icon: 'pi pi-images',
|
||||
children: [
|
||||
{
|
||||
key: 'output1',
|
||||
label: 'ComfyUI_00001_.png',
|
||||
icon: 'pi pi-image'
|
||||
},
|
||||
{
|
||||
key: 'output2',
|
||||
label: 'ComfyUI_00002_.png',
|
||||
icon: 'pi pi-image'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as TreeExplorerNode
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleNodeClick(node: any, _event: MouseEvent) {
|
||||
console.log('Node clicked:', node.label)
|
||||
},
|
||||
handleNodeDelete(node: any) {
|
||||
console.log('Node delete requested:', node.label)
|
||||
},
|
||||
handleContextMenu(node: any, _event: MouseEvent) {
|
||||
console.log('Context menu on node:', node.label)
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; width: 400px; height: 500px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI File Explorer</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Navigate through workflows, models, and outputs
|
||||
</p>
|
||||
</div>
|
||||
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 400px; overflow: auto;">
|
||||
<TreeExplorer
|
||||
:root="treeData"
|
||||
v-model:expandedKeys="expanded"
|
||||
v-model:selectionKeys="selected"
|
||||
@nodeClick="handleNodeClick"
|
||||
@nodeDelete="handleNodeDelete"
|
||||
@contextMenu="handleContextMenu"
|
||||
/>
|
||||
</div>
|
||||
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
|
||||
Expanded: {{ Object.keys(expanded).length }} | Selected: {{ Object.keys(selected).length }}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
expandedKeys: { workflows: true, models: true },
|
||||
selectionKeys: { portrait: true }
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Basic TreeExplorer with ComfyUI file structure showing workflows, models, and outputs.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EmptyTree: Story = {
|
||||
render: () => ({
|
||||
components: { TreeExplorer },
|
||||
data() {
|
||||
return {
|
||||
expanded: {},
|
||||
selected: {},
|
||||
emptyTree: {
|
||||
key: 'empty-root',
|
||||
label: 'Empty Workspace',
|
||||
children: []
|
||||
} as TreeExplorerNode
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleNodeClick(node: any, event: MouseEvent) {
|
||||
console.log('Empty tree node clicked:', node, event)
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; width: 350px; height: 300px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Empty Workspace</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Empty tree explorer state
|
||||
</p>
|
||||
</div>
|
||||
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||
<TreeExplorer
|
||||
:root="emptyTree"
|
||||
v-model:expandedKeys="expanded"
|
||||
v-model:selectionKeys="selected"
|
||||
@nodeClick="handleNodeClick"
|
||||
/>
|
||||
<div style="color: #9ca3af; font-style: italic; text-align: center;">
|
||||
<i class="pi pi-folder-open" style="display: block; font-size: 24px; margin-bottom: 8px;"></i>
|
||||
No items in workspace
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Empty TreeExplorer showing the state when no items are present in the workspace.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DeepHierarchy: Story = {
|
||||
render: () => ({
|
||||
components: { TreeExplorer },
|
||||
data() {
|
||||
return {
|
||||
expanded: { workflows: true, 'stable-diffusion': true },
|
||||
selected: {},
|
||||
deepTree: {
|
||||
key: 'root',
|
||||
label: 'Projects',
|
||||
children: [
|
||||
{
|
||||
key: 'workflows',
|
||||
label: 'Workflows',
|
||||
icon: 'pi pi-sitemap',
|
||||
children: [
|
||||
{
|
||||
key: 'stable-diffusion',
|
||||
label: 'Stable Diffusion',
|
||||
icon: 'pi pi-folder',
|
||||
children: [
|
||||
{
|
||||
key: 'portraits',
|
||||
label: 'Portraits',
|
||||
icon: 'pi pi-folder',
|
||||
children: [
|
||||
{
|
||||
key: 'realistic',
|
||||
label: 'Realistic Portrait.json',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'artistic',
|
||||
label: 'Artistic Portrait.json',
|
||||
icon: 'pi pi-file'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'landscapes',
|
||||
label: 'Landscapes',
|
||||
icon: 'pi pi-folder',
|
||||
children: [
|
||||
{
|
||||
key: 'nature',
|
||||
label: 'Nature Scene.json',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'urban',
|
||||
label: 'Urban Environment.json',
|
||||
icon: 'pi pi-file'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'controlnet',
|
||||
label: 'ControlNet',
|
||||
icon: 'pi pi-folder',
|
||||
children: [
|
||||
{
|
||||
key: 'canny',
|
||||
label: 'Canny Edge.json',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'depth',
|
||||
label: 'Depth Map.json',
|
||||
icon: 'pi pi-file'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as TreeExplorerNode
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleNodeClick(node: any, _event: MouseEvent) {
|
||||
console.log('Deep tree node clicked:', node.label)
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; width: 400px; height: 600px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Deep Hierarchy</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Multi-level nested folder structure with organized workflows
|
||||
</p>
|
||||
</div>
|
||||
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
|
||||
<TreeExplorer
|
||||
:root="deepTree"
|
||||
v-model:expandedKeys="expanded"
|
||||
v-model:selectionKeys="selected"
|
||||
@nodeClick="handleNodeClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Deep hierarchical TreeExplorer showing multi-level folder organization with workflows.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const InteractiveOperations: Story = {
|
||||
render: () => ({
|
||||
components: { TreeExplorer },
|
||||
data() {
|
||||
return {
|
||||
expanded: { workflows: true },
|
||||
selected: {},
|
||||
operationLog: [],
|
||||
interactiveTree: {
|
||||
key: 'root',
|
||||
label: 'Interactive Workspace',
|
||||
children: [
|
||||
{
|
||||
key: 'workflows',
|
||||
label: 'My Workflows',
|
||||
icon: 'pi pi-sitemap',
|
||||
children: [
|
||||
{
|
||||
key: 'workflow1',
|
||||
label: 'Image Generation.json',
|
||||
icon: 'pi pi-file',
|
||||
handleRename: function (newName: string) {
|
||||
console.log(`Renaming workflow to: ${newName}`)
|
||||
},
|
||||
handleDelete: function () {
|
||||
console.log('Deleting workflow')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'workflow2',
|
||||
label: 'Video Processing.json',
|
||||
icon: 'pi pi-file',
|
||||
handleRename: function (newName: string) {
|
||||
console.log(`Renaming workflow to: ${newName}`)
|
||||
},
|
||||
handleDelete: function () {
|
||||
console.log('Deleting workflow')
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as TreeExplorerNode
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleNodeClick(node: any, _event: MouseEvent) {
|
||||
this.operationLog.unshift(`Clicked: ${node.label}`)
|
||||
if (this.operationLog.length > 8) this.operationLog.pop()
|
||||
},
|
||||
handleNodeDelete(node: any) {
|
||||
this.operationLog.unshift(`Delete requested: ${node.label}`)
|
||||
if (this.operationLog.length > 8) this.operationLog.pop()
|
||||
},
|
||||
handleContextMenu(node: any, _event: MouseEvent) {
|
||||
this.operationLog.unshift(`Context menu: ${node.label}`)
|
||||
if (this.operationLog.length > 8) this.operationLog.pop()
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; width: 500px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Interactive Operations</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Click nodes, right-click for context menu, test selection behavior
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<div style="flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
|
||||
<TreeExplorer
|
||||
:root="interactiveTree"
|
||||
v-model:expandedKeys="expanded"
|
||||
v-model:selectionKeys="selected"
|
||||
@nodeClick="handleNodeClick"
|
||||
@nodeDelete="handleNodeDelete"
|
||||
@contextMenu="handleContextMenu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; background: rgba(0,0,0,0.05); border-radius: 8px; padding: 12px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Operation Log:</div>
|
||||
<div v-if="operationLog.length === 0" style="font-style: italic; color: #9ca3af; font-size: 12px;">
|
||||
No operations yet...
|
||||
</div>
|
||||
<div v-for="(entry, index) in operationLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
|
||||
{{ entry }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Interactive TreeExplorer demonstrating click, context menu, and selection operations with live logging.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WorkflowManager: Story = {
|
||||
render: () => ({
|
||||
components: { TreeExplorer },
|
||||
data() {
|
||||
return {
|
||||
expanded: { 'workflow-library': true, 'my-workflows': true },
|
||||
selected: {},
|
||||
workflowTree: {
|
||||
key: 'root',
|
||||
label: 'Workflow Manager',
|
||||
children: [
|
||||
{
|
||||
key: 'my-workflows',
|
||||
label: 'My Workflows',
|
||||
icon: 'pi pi-user',
|
||||
children: [
|
||||
{
|
||||
key: 'draft1',
|
||||
label: 'Draft - SDXL Portrait.json',
|
||||
icon: 'pi pi-file-edit'
|
||||
},
|
||||
{
|
||||
key: 'final1',
|
||||
label: 'Final - Product Shots.json',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'temp1',
|
||||
label: 'Temp - Testing.json',
|
||||
icon: 'pi pi-clock'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'workflow-library',
|
||||
label: 'Workflow Library',
|
||||
icon: 'pi pi-book',
|
||||
children: [
|
||||
{
|
||||
key: 'community',
|
||||
label: 'Community',
|
||||
icon: 'pi pi-users',
|
||||
children: [
|
||||
{
|
||||
key: 'popular1',
|
||||
label: 'SDXL Ultimate.json',
|
||||
icon: 'pi pi-star-fill'
|
||||
},
|
||||
{
|
||||
key: 'popular2',
|
||||
label: 'ControlNet Pro.json',
|
||||
icon: 'pi pi-star-fill'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
label: 'Templates',
|
||||
icon: 'pi pi-clone',
|
||||
children: [
|
||||
{
|
||||
key: 'template1',
|
||||
label: 'Basic Generation.json',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'template2',
|
||||
label: 'Img2Img Template.json',
|
||||
icon: 'pi pi-file'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'recent',
|
||||
label: 'Recent',
|
||||
icon: 'pi pi-history',
|
||||
children: [
|
||||
{
|
||||
key: 'recent1',
|
||||
label: 'Last Session.json',
|
||||
icon: 'pi pi-clock'
|
||||
},
|
||||
{
|
||||
key: 'recent2',
|
||||
label: 'Quick Test.json',
|
||||
icon: 'pi pi-clock'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as TreeExplorerNode
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleNodeClick(node: any, _event: MouseEvent) {
|
||||
console.log('Workflow selected:', node.label)
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; width: 450px; height: 600px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Workflow Manager</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Organized workflow library with categories, templates, and recent files
|
||||
</p>
|
||||
</div>
|
||||
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
|
||||
<TreeExplorer
|
||||
:root="workflowTree"
|
||||
v-model:expandedKeys="expanded"
|
||||
v-model:selectionKeys="selected"
|
||||
@nodeClick="handleNodeClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Realistic workflow manager showing organized hierarchy with categories, templates, and recent files.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CompactView: Story = {
|
||||
render: () => ({
|
||||
components: { TreeExplorer },
|
||||
data() {
|
||||
return {
|
||||
expanded: { models: true },
|
||||
selected: {},
|
||||
compactTree: {
|
||||
key: 'root',
|
||||
label: 'Models',
|
||||
children: [
|
||||
{
|
||||
key: 'models',
|
||||
label: 'Checkpoints',
|
||||
icon: 'pi pi-download',
|
||||
children: [
|
||||
{
|
||||
key: 'model1',
|
||||
label: 'SDXL_base.safetensors',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'model2',
|
||||
label: 'SD_1.5_pruned.safetensors',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'model3',
|
||||
label: 'Realistic_Vision_V5.safetensors',
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
key: 'model4',
|
||||
label: 'AnythingV5_v3.safetensors',
|
||||
icon: 'pi pi-file'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as TreeExplorerNode
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 20px; width: 300px; height: 400px;">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #374151;">Compact Model List</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
Compact view for smaller spaces
|
||||
</p>
|
||||
</div>
|
||||
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
|
||||
<TreeExplorer
|
||||
:root="compactTree"
|
||||
v-model:expandedKeys="expanded"
|
||||
v-model:selectionKeys="selected"
|
||||
class="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Compact TreeExplorer view for smaller interface areas with minimal spacing.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Tree
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:selection-keys="selectionKeys"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
v-model:selectionKeys="selectionKeys"
|
||||
class="tree-explorer py-0 px-2 2xl:px-4"
|
||||
:class="props.class"
|
||||
:value="renderedRoot.children"
|
||||
|
||||
162
src/components/common/UserAvatar.stories.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
|
||||
const meta: Meta<typeof UserAvatar> = {
|
||||
title: 'Components/Common/UserAvatar',
|
||||
component: UserAvatar,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'UserAvatar displays a circular avatar image with fallback to a user icon when no image is provided or when the image fails to load. Built on top of PrimeVue Avatar component.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
photoUrl: {
|
||||
control: 'text',
|
||||
description:
|
||||
'URL of the user photo to display. Falls back to user icon if null, undefined, or fails to load',
|
||||
defaultValue: null
|
||||
},
|
||||
ariaLabel: {
|
||||
control: 'text',
|
||||
description: 'Accessibility label for screen readers',
|
||||
defaultValue: undefined
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof UserAvatar>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
photoUrl: null,
|
||||
ariaLabel: 'User avatar'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Default avatar with no image - shows user icon fallback.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WithValidImage: Story = {
|
||||
args: {
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face',
|
||||
ariaLabel: 'John Doe avatar'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Avatar with a valid image URL displaying a user photo.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WithBrokenImage: Story = {
|
||||
args: {
|
||||
photoUrl: 'https://example.com/nonexistent-image.jpg',
|
||||
ariaLabel: 'User with broken image'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Avatar with a broken image URL - automatically falls back to user icon when image fails to load.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WithCustomAriaLabel: Story = {
|
||||
args: {
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face',
|
||||
ariaLabel: 'Sarah Johnson, Project Manager'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Avatar with custom accessibility label for better screen reader experience.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EmptyString: Story = {
|
||||
args: {
|
||||
photoUrl: '',
|
||||
ariaLabel: 'User with empty photo URL'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Avatar with empty string photo URL - treats empty string as no image.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const UndefinedUrl: Story = {
|
||||
args: {
|
||||
photoUrl: undefined,
|
||||
ariaLabel: 'User with undefined photo URL'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Avatar with undefined photo URL - shows default user icon.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery view showing different states
|
||||
export const Gallery: Story = {
|
||||
render: () => ({
|
||||
components: { UserAvatar },
|
||||
template: `
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center; padding: 20px;">
|
||||
<div style="text-align: center;">
|
||||
<UserAvatar :photoUrl="null" ariaLabel="No image" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">No Image</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<UserAvatar photoUrl="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face" ariaLabel="Valid image" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">Valid Image</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<UserAvatar photoUrl="https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face" ariaLabel="Another valid image" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">Another Valid</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<UserAvatar photoUrl="https://example.com/broken.jpg" ariaLabel="Broken image" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">Broken URL</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<UserAvatar photoUrl="" ariaLabel="Empty string" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">Empty String</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Gallery showing different avatar states side by side for comparison.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ const allMissingNodesInstalled = computed(() => {
|
||||
})
|
||||
// Watch for completion and close dialog
|
||||
watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
if (allInstalled && showInstallAllButton.value) {
|
||||
if (allInstalled) {
|
||||
// Use nextTick to ensure state updates are complete
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="flex flex-1 relative overflow-hidden">
|
||||
<ManagerNavSidebar
|
||||
v-if="isSideNavOpen"
|
||||
v-model:selected-tab="selectedTab"
|
||||
v-model:selectedTab="selectedTab"
|
||||
:tabs="tabs"
|
||||
/>
|
||||
<div
|
||||
@@ -57,9 +57,9 @@
|
||||
</IconButton>
|
||||
</div>
|
||||
<RegistrySearchBar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:search-mode="searchMode"
|
||||
v-model:sort-field="sortField"
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:searchMode="searchMode"
|
||||
v-model:sortField="sortField"
|
||||
:search-results="searchResults"
|
||||
:suggestions="suggestions"
|
||||
:is-missing-tab="isMissingTab"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { value = 'N/A', label } = defineProps<{
|
||||
const { value = 'N/A', label = 'N/A' } = defineProps<{
|
||||
label: string
|
||||
value?: string | number
|
||||
}>()
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
<div class="flex mt-3 text-sm">
|
||||
<div class="flex gap-6 ml-1">
|
||||
<SearchFilterDropdown
|
||||
v-model:model-value="searchMode"
|
||||
v-model:modelValue="searchMode"
|
||||
:options="filterOptions"
|
||||
:label="$t('g.filter')"
|
||||
/>
|
||||
<SearchFilterDropdown
|
||||
v-model:model-value="sortField"
|
||||
v-model:modelValue="sortField"
|
||||
:options="availableSortOptions"
|
||||
:label="$t('g.sort')"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class="px-4 py-2 flex items-center"
|
||||
>
|
||||
<TabMenu
|
||||
v-model:active-index="activeTabIndex"
|
||||
v-model:activeIndex="activeTabIndex"
|
||||
:model="tabs"
|
||||
class="w-full border-none"
|
||||
:pt="{
|
||||
|
||||
@@ -34,7 +34,7 @@ const updateWidgets = () => {
|
||||
const widget = widgetState.widget
|
||||
|
||||
// Early exit for non-visible widgets
|
||||
if (!widget.isVisible() || !widgetState.active) {
|
||||
if (!widget.isVisible()) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
severity="secondary"
|
||||
text
|
||||
data-testid="bypass-button"
|
||||
class="hover:dark-theme:bg-charcoal-600 hover:bg-[#E7E6E6]"
|
||||
class="hover:dark-theme:bg-charcoal-300 hover:bg-[#E7E6E6]"
|
||||
@click="toggleBypass"
|
||||
>
|
||||
<template #icon>
|
||||
|
||||
@@ -59,7 +59,7 @@ import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.
|
||||
import { ComponentWidget } from '@/scripts/domWidget'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const { widget, history } = defineProps<{
|
||||
const { widget, history = '[]' } = defineProps<{
|
||||
widget?: ComponentWidget<string>
|
||||
history: string
|
||||
}>()
|
||||
|
||||
@@ -124,43 +124,50 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
useEventListener(document, 'mousedown', (event) => {
|
||||
if (!isDOMWidget(widget) || !widgetState.visible || !widget.element.blur) {
|
||||
return
|
||||
}
|
||||
if (!widget.element.contains(event.target as HTMLElement)) {
|
||||
widget.element.blur()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!isDOMWidget(widget)) {
|
||||
return
|
||||
// Set up event listeners only after the widget is mounted and visible
|
||||
const setupDOMEventListeners = () => {
|
||||
if (!isDOMWidget(widget) || !widgetState.visible) return
|
||||
|
||||
if (widget.element.blur) {
|
||||
useEventListener(document, 'mousedown', (event) => {
|
||||
if (!widget.element.contains(event.target as HTMLElement)) {
|
||||
widget.element.blur()
|
||||
}
|
||||
})
|
||||
}
|
||||
useEventListener(
|
||||
widget.element,
|
||||
widget.options.selectOn ?? ['focus', 'click'],
|
||||
() => {
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners when widget becomes visible
|
||||
watch(
|
||||
() => widgetState.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
setupDOMEventListeners()
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const inputSpec = widget.node.constructor.nodeData
|
||||
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
|
||||
|
||||
// Mount DOM element when widget is or becomes visible
|
||||
const mountElementIfVisible = () => {
|
||||
if (!(widgetState.visible && isDOMWidget(widget) && widgetElement.value)) {
|
||||
return
|
||||
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
|
||||
// Only append if not already a child
|
||||
if (!widgetElement.value.contains(widget.element)) {
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
}
|
||||
// Only append if not already a child
|
||||
if (widgetElement.value.contains(widget.element)) {
|
||||
return
|
||||
}
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
|
||||
// Check on mount - but only after next tick to ensure visibility is calculated
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
|
||||
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg">
|
||||
<!-- Auto Update Setting -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t('install.settings.autoUpdate') }}
|
||||
</h3>
|
||||
<p class="text-neutral-400 mt-1">
|
||||
<p class="text-sm text-neutral-400 mt-1">
|
||||
{{ $t('install.settings.autoUpdateDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -32,10 +32,14 @@
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t('install.settings.allowMetrics') }}
|
||||
</h3>
|
||||
<p class="text-neutral-400">
|
||||
<p class="text-sm text-neutral-400 mt-1">
|
||||
{{ $t('install.settings.allowMetricsDescription') }}
|
||||
</p>
|
||||
<a href="#" @click.prevent="showMetricsInfo">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm text-blue-400 hover:text-blue-300 mt-1 inline-block"
|
||||
@click.prevent="showMetricsInfo"
|
||||
>
|
||||
{{ $t('install.settings.learnMoreAboutData') }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -47,9 +51,7 @@
|
||||
<Dialog
|
||||
v-model:visible="showDialog"
|
||||
modal
|
||||
dismissable-mask
|
||||
:header="$t('install.settings.dataCollectionDialog.title')"
|
||||
class="select-none"
|
||||
>
|
||||
<div class="text-neutral-300">
|
||||
<h4 class="font-medium mb-2">
|
||||
@@ -108,7 +110,11 @@
|
||||
</ul>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="https://comfy.org/privacy" target="_blank">
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,130 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
|
||||
>
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
{{ $t('install.gpuPicker.title') }}
|
||||
</h2>
|
||||
<div class="flex flex-col gap-6 w-[600px] h-[30rem] select-none">
|
||||
<!-- Installation Path Section -->
|
||||
<div class="grow flex flex-col gap-4 text-neutral-300">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.gpuSelection.selectGpu') }}
|
||||
</h2>
|
||||
|
||||
<!-- GPU Selection buttons - takes up remaining space and centers content -->
|
||||
<div class="flex-1 flex gap-8 justify-center items-center">
|
||||
<!-- Apple Metal / NVIDIA -->
|
||||
<HardwareOption
|
||||
v-if="platform === 'darwin'"
|
||||
:image-path="'assets/images/apple-mps-logo.png'"
|
||||
placeholder-text="Apple Metal"
|
||||
subtitle="Apple Metal"
|
||||
:value="'mps'"
|
||||
:selected="selected === 'mps'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('mps')"
|
||||
/>
|
||||
<HardwareOption
|
||||
v-else
|
||||
:image-path="'assets/images/nvidia-logo-square.jpg'"
|
||||
placeholder-text="NVIDIA"
|
||||
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
|
||||
:value="'nvidia'"
|
||||
:selected="selected === 'nvidia'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('nvidia')"
|
||||
/>
|
||||
<!-- CPU -->
|
||||
<HardwareOption
|
||||
placeholder-text="CPU"
|
||||
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
|
||||
:value="'cpu'"
|
||||
:selected="selected === 'cpu'"
|
||||
@click="pickGpu('cpu')"
|
||||
/>
|
||||
<!-- Manual Install -->
|
||||
<HardwareOption
|
||||
placeholder-text="Manual Install"
|
||||
:subtitle="$t('install.gpuPicker.manualSubtitle')"
|
||||
:value="'unsupported'"
|
||||
:selected="selected === 'unsupported'"
|
||||
@click="pickGpu('unsupported')"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-1 text-neutral-400">
|
||||
{{ $t('install.gpuSelection.selectGpuDescription') }}:
|
||||
</p>
|
||||
|
||||
<div class="pt-12 px-24 h-16">
|
||||
<div v-show="showRecommendedBadge" class="flex items-center gap-2">
|
||||
<Tag
|
||||
:value="$t('install.gpuPicker.recommended')"
|
||||
class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
|
||||
/>
|
||||
<i-lucide:badge-check class="text-neutral-300 text-lg" />
|
||||
<!-- GPU Selection buttons -->
|
||||
<div
|
||||
class="flex gap-2 text-center transition-opacity"
|
||||
:class="{ selected: selected }"
|
||||
>
|
||||
<!-- NVIDIA -->
|
||||
<div
|
||||
v-if="platform !== 'darwin'"
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'nvidia' }"
|
||||
role="button"
|
||||
@click="pickGpu('nvidia')"
|
||||
>
|
||||
<img
|
||||
class="m-12"
|
||||
alt="NVIDIA logo"
|
||||
width="196"
|
||||
height="32"
|
||||
src="/assets/images/nvidia-logo.svg"
|
||||
/>
|
||||
</div>
|
||||
<!-- MPS -->
|
||||
<div
|
||||
v-if="platform === 'darwin'"
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'mps' }"
|
||||
role="button"
|
||||
@click="pickGpu('mps')"
|
||||
>
|
||||
<img
|
||||
class="rounded-lg hover-brighten"
|
||||
alt="Apple Metal Performance Shaders Logo"
|
||||
width="292"
|
||||
ratio
|
||||
src="/assets/images/apple-mps-logo.png"
|
||||
/>
|
||||
</div>
|
||||
<!-- Manual configuration -->
|
||||
<div
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'unsupported' }"
|
||||
role="button"
|
||||
@click="pickGpu('unsupported')"
|
||||
>
|
||||
<img
|
||||
class="m-12"
|
||||
alt="Manual configuration"
|
||||
width="196"
|
||||
src="/assets/images/manual-configuration.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details on selected GPU -->
|
||||
<p v-if="selected === 'nvidia'" class="m-1">
|
||||
<Tag icon="pi pi-check" severity="success" :value="'CUDA'" />
|
||||
{{ $t('install.gpuSelection.nvidiaDescription') }}
|
||||
</p>
|
||||
|
||||
<p v-if="selected === 'mps'" class="m-1">
|
||||
<Tag icon="pi pi-check" severity="success" :value="'MPS'" />
|
||||
{{ $t('install.gpuSelection.mpsDescription') }}
|
||||
</p>
|
||||
|
||||
<div v-if="selected === 'unsupported'" class="text-neutral-300">
|
||||
<p class="m-1">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
{{ $t('install.gpuSelection.customSkipsPython') }}
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>
|
||||
{{ $t('install.gpuSelection.customComfyNeedsPython') }}
|
||||
</strong>
|
||||
</li>
|
||||
<li>{{ $t('install.gpuSelection.customManualVenv') }}</li>
|
||||
<li>{{ $t('install.gpuSelection.customInstallRequirements') }}</li>
|
||||
<li>{{ $t('install.gpuSelection.customMayNotWork') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="selected === 'cpu'">
|
||||
<p class="m-1">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
{{ $t('install.gpuSelection.cpuModeDescription') }}
|
||||
</p>
|
||||
<p class="m-1">
|
||||
{{ $t('install.gpuSelection.cpuModeDescription2') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-neutral-300 px-24">
|
||||
<p v-show="descriptionText" class="leading-relaxed">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
<div
|
||||
class="transition-opacity flex gap-3 h-0"
|
||||
:class="{
|
||||
'opacity-40': selected && selected !== 'cpu'
|
||||
}"
|
||||
>
|
||||
<ToggleSwitch
|
||||
v-model="cpuMode"
|
||||
input-id="cpu-mode"
|
||||
class="-translate-y-40"
|
||||
/>
|
||||
<label for="cpu-mode" class="select-none">
|
||||
{{ $t('install.gpuSelection.enableCpuMode') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -68,12 +132,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import Tag from 'primevue/tag'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HardwareOption from '@/components/install/HardwareOption.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cpuMode = computed({
|
||||
get: () => selected.value === 'cpu',
|
||||
set: (value) => {
|
||||
selected.value = value ? 'cpu' : null
|
||||
}
|
||||
})
|
||||
const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
required: true
|
||||
})
|
||||
@@ -81,23 +153,55 @@ const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
const electron = electronAPI()
|
||||
const platform = electron.getPlatform()
|
||||
|
||||
const showRecommendedBadge = computed(
|
||||
() => selected.value === 'mps' || selected.value === 'nvidia'
|
||||
)
|
||||
|
||||
const descriptionKeys = {
|
||||
mps: 'appleMetal',
|
||||
nvidia: 'nvidia',
|
||||
cpu: 'cpu',
|
||||
unsupported: 'manual'
|
||||
} as const
|
||||
|
||||
const descriptionText = computed(() => {
|
||||
const key = selected.value ? descriptionKeys[selected.value] : undefined
|
||||
return st(`install.gpuPicker.${key}Description`, '')
|
||||
})
|
||||
|
||||
const pickGpu = (value: TorchDeviceType) => {
|
||||
selected.value = value
|
||||
const pickGpu = (value: typeof selected.value) => {
|
||||
const newValue = selected.value === value ? null : value
|
||||
selected.value = newValue
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-tag {
|
||||
--p-tag-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hover-brighten {
|
||||
@apply transition-colors;
|
||||
transition-property: filter, box-shadow;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(107%) contrast(105%);
|
||||
box-shadow: 0 0 0.25rem #ffffff79;
|
||||
}
|
||||
}
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-neutral-900 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
div.selected {
|
||||
.gpu-button:not(.selected) {
|
||||
@apply opacity-50 hover:opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
.gpu-button {
|
||||
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800/50 hover:bg-neutral-800/75 transition-colors;
|
||||
|
||||
&.selected {
|
||||
@apply opacity-100 bg-neutral-700/50 hover:bg-neutral-700/60;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
@apply pointer-events-none opacity-40;
|
||||
}
|
||||
|
||||
.p-card-header {
|
||||
@apply text-center grow;
|
||||
}
|
||||
|
||||
.p-card-body {
|
||||
@apply text-center pt-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
|
||||
import HardwareOption from './HardwareOption.vue'
|
||||
|
||||
const meta: Meta<typeof HardwareOption> = {
|
||||
title: 'Desktop/Components/HardwareOption',
|
||||
component: HardwareOption,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [{ name: 'dark', value: '#1a1a1a' }]
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
selected: { control: 'boolean' },
|
||||
imagePath: { control: 'text' },
|
||||
placeholderText: { control: 'text' },
|
||||
subtitle: { control: 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AppleMetalSelected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AppleMetalUnselected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const CPUOption: Story = {
|
||||
args: {
|
||||
placeholderText: 'CPU',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'cpu',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const ManualInstall: Story = {
|
||||
args: {
|
||||
placeholderText: 'Manual Install',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'unsupported',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const NvidiaSelected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/nvidia-logo-square.jpg',
|
||||
placeholderText: 'NVIDIA',
|
||||
subtitle: 'NVIDIA',
|
||||
value: 'nvidia',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Recommended Badge -->
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
|
||||
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
|
||||
)
|
||||
"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- Icon/Logo Area - Rounded square container -->
|
||||
<div
|
||||
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="imagePath"
|
||||
:src="imagePath"
|
||||
:alt="placeholderText"
|
||||
class="w-full h-full object-cover"
|
||||
style="object-position: 57% center"
|
||||
draggable="false"
|
||||
/>
|
||||
<span v-else class="text-xl font-medium text-neutral-400">
|
||||
{{ placeholderText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div v-if="subtitle" class="text-center mt-4">
|
||||
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface Props {
|
||||
imagePath?: string
|
||||
placeholderText: string
|
||||
subtitle?: string
|
||||
value: TorchDeviceType
|
||||
selected?: boolean
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
|
||||
<!-- Back button -->
|
||||
<Button
|
||||
v-if="currentStep !== '1'"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
icon="pi pi-arrow-left"
|
||||
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
|
||||
@click="$emit('previous')"
|
||||
/>
|
||||
<div v-else></div>
|
||||
|
||||
<!-- Step indicators in center -->
|
||||
<StepList class="flex justify-center items-center gap-3 select-none">
|
||||
<Step value="1" :pt="stepPassthrough">
|
||||
{{ $t('install.gpu') }}
|
||||
</Step>
|
||||
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
|
||||
{{ $t('install.installLocation') }}
|
||||
</Step>
|
||||
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
|
||||
{{ $t('install.desktopSettings') }}
|
||||
</Step>
|
||||
</StepList>
|
||||
|
||||
<!-- Next/Install button -->
|
||||
<Button
|
||||
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
|
||||
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
|
||||
:pt="{
|
||||
label: { class: 'text-neutral-900 font-inter font-black' }
|
||||
}"
|
||||
:disabled="!canProceed"
|
||||
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PassThrough } from '@primevue/core'
|
||||
import Button from 'primevue/button'
|
||||
import Step, { type StepPassThroughOptions } from 'primevue/step'
|
||||
import StepList from 'primevue/steplist'
|
||||
|
||||
defineProps<{
|
||||
/** Current step index as string ('1', '2', '3', '4') */
|
||||
currentStep: string
|
||||
/** Whether the user can proceed to the next step */
|
||||
canProceed: boolean
|
||||
/** Whether the location step should be disabled */
|
||||
disableLocationStep: boolean
|
||||
/** Whether the migration step should be disabled */
|
||||
disableMigrationStep: boolean
|
||||
/** Whether the settings step should be disabled */
|
||||
disableSettingsStep: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
previous: []
|
||||
next: []
|
||||
install: []
|
||||
}>()
|
||||
|
||||
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
|
||||
root: { class: 'flex-none p-0 m-0' },
|
||||
header: ({ context }) => ({
|
||||
class: [
|
||||
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
|
||||
context.active
|
||||
? 'bg-brand-yellow w-8 rounded-sm'
|
||||
: 'bg-neutral-700 w-2.5',
|
||||
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
|
||||
].join(' ')
|
||||
}),
|
||||
number: { class: 'hidden' },
|
||||
title: { class: 'hidden' }
|
||||
}
|
||||
</script>
|
||||
@@ -1,148 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import InstallLocationPicker from './InstallLocationPicker.vue'
|
||||
|
||||
const meta: Meta<typeof InstallLocationPicker> = {
|
||||
title: 'Desktop/Components/InstallLocationPicker',
|
||||
component: InstallLocationPicker,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
// Mock electron API
|
||||
;(window as any).electronAPI = {
|
||||
getSystemPaths: () =>
|
||||
Promise.resolve({
|
||||
defaultInstallPath: '/Users/username/ComfyUI'
|
||||
}),
|
||||
validateInstallPath: () =>
|
||||
Promise.resolve({
|
||||
isValid: true,
|
||||
exists: false,
|
||||
canWrite: true,
|
||||
freeSpace: 100000000000,
|
||||
requiredSpace: 10000000000,
|
||||
isNonDefaultDrive: false
|
||||
}),
|
||||
validateComfyUISource: () =>
|
||||
Promise.resolve({
|
||||
isValid: true
|
||||
}),
|
||||
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
|
||||
}
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default story with accordion expanded
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8">
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story with different background to test transparency
|
||||
export const OnNeutral900: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-900 p-8">
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story with debug overlay showing background colors
|
||||
export const DebugBackgrounds: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8 relative">
|
||||
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
|
||||
<div>Parent bg: neutral-950 (#0a0a0a)</div>
|
||||
<div>Accordion content: bg-transparent</div>
|
||||
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
|
||||
</div>
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,215 +1,103 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
|
||||
<div class="flex flex-col gap-6 w-[600px]">
|
||||
<!-- Installation Path Section -->
|
||||
<div class="grow flex flex-col gap-6 text-neutral-300">
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
{{ $t('install.locationPicker.title') }}
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.chooseInstallationLocation') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-center text-neutral-400 px-12">
|
||||
{{ $t('install.locationPicker.subtitle') }}
|
||||
<p class="text-neutral-400 my-0">
|
||||
{{ $t('install.installLocationDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- Path Input -->
|
||||
<div class="flex gap-2 px-12">
|
||||
<InputText
|
||||
v-model="installPath"
|
||||
:placeholder="$t('install.locationPicker.pathPlaceholder')"
|
||||
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validatePath"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder-open"
|
||||
severity="secondary"
|
||||
class="bg-neutral-700 hover:bg-neutral-600 border-0"
|
||||
@click="browsePath"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<IconField class="flex-1">
|
||||
<InputText
|
||||
v-model="installPath"
|
||||
class="w-full"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validatePath"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<InputIcon
|
||||
v-tooltip.top="$t('install.installLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</IconField>
|
||||
<Button icon="pi pi-folder" class="w-12" @click="browsePath" />
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
|
||||
<Message
|
||||
v-if="pathError"
|
||||
severity="error"
|
||||
class="whitespace-pre-line w-full"
|
||||
>
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
<Message v-if="pathExists" severity="warn" class="w-full">
|
||||
{{ $t('install.pathExists') }}
|
||||
</Message>
|
||||
<Message v-if="nonDefaultDrive" severity="warn" class="w-full">
|
||||
{{ $t('install.nonDefaultDrive') }}
|
||||
</Message>
|
||||
<Message v-if="pathError" severity="error" class="whitespace-pre-line">
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
<Message v-if="pathExists" severity="warn">
|
||||
{{ $t('install.pathExists') }}
|
||||
</Message>
|
||||
<Message v-if="nonDefaultDrive" severity="warn">
|
||||
{{ $t('install.nonDefaultDrive') }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- System Paths Info -->
|
||||
<div class="bg-neutral-800 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium mt-0 mb-3 text-neutral-100">
|
||||
{{ $t('install.systemLocations') }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-folder text-neutral-400" />
|
||||
<span class="text-neutral-400">App Data:</span>
|
||||
<span class="text-neutral-200">{{ appData }}</span>
|
||||
<span
|
||||
v-tooltip="$t('install.appDataLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-desktop text-neutral-400" />
|
||||
<span class="text-neutral-400">App Path:</span>
|
||||
<span class="text-neutral-200">{{ appPath }}</span>
|
||||
<span
|
||||
v-tooltip="$t('install.appPathLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Sections using PrimeVue Accordion -->
|
||||
<Accordion
|
||||
v-model:value="activeAccordionIndex"
|
||||
:multiple="true"
|
||||
class="location-picker-accordion"
|
||||
:pt="{
|
||||
root: 'bg-transparent border-0',
|
||||
panel: {
|
||||
root: 'border-0 mb-0'
|
||||
},
|
||||
header: {
|
||||
root: 'border-0',
|
||||
content:
|
||||
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
|
||||
toggleicon: 'text-xs order-first mr-0'
|
||||
},
|
||||
content: {
|
||||
root: 'bg-transparent border-0',
|
||||
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>
|
||||
{{ $t('install.locationPicker.migrateFromExisting') }}
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<MigrationPicker
|
||||
v-model:source-path="migrationSourcePath"
|
||||
v-model:migration-item-ids="migrationItemIds"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<AccordionPanel value="1">
|
||||
<AccordionHeader>
|
||||
{{ $t('install.locationPicker.chooseDownloadServers') }}
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<template
|
||||
v-for="([item, modelValue], index) in mirrors"
|
||||
:key="item.settingId + item.mirror"
|
||||
>
|
||||
<Divider v-if="index > 0" class="my-8" />
|
||||
|
||||
<MirrorItem
|
||||
v-model="modelValue.value"
|
||||
:item="item"
|
||||
@state-change="validationStates[index] = $event"
|
||||
/>
|
||||
</template>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { type ModelRef, computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
|
||||
import {
|
||||
PYPI_MIRROR,
|
||||
PYTHON_MIRROR,
|
||||
type UVMirror
|
||||
} from '@/constants/uvMirrors'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const installPath = defineModel<string>('installPath', { required: true })
|
||||
const pathError = defineModel<string>('pathError', { required: true })
|
||||
const migrationSourcePath = defineModel<string>('migrationSourcePath')
|
||||
const migrationItemIds = defineModel<string[]>('migrationItemIds')
|
||||
const pythonMirror = defineModel<string>('pythonMirror', {
|
||||
default: ''
|
||||
})
|
||||
const pypiMirror = defineModel<string>('pypiMirror', {
|
||||
default: ''
|
||||
})
|
||||
const torchMirror = defineModel<string>('torchMirror', {
|
||||
default: ''
|
||||
})
|
||||
|
||||
const { device } = defineProps<{ device: TorchDeviceType | null }>()
|
||||
|
||||
const pathExists = ref(false)
|
||||
const nonDefaultDrive = ref(false)
|
||||
const appData = ref('')
|
||||
const appPath = ref('')
|
||||
const inputTouched = ref(false)
|
||||
|
||||
// Accordion state - array of active panel values
|
||||
const activeAccordionIndex = ref<string[] | undefined>(undefined)
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
// Mirror configuration logic
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.NightlyCpu,
|
||||
fallbackMirror: TorchMirrorUrl.NightlyCpu
|
||||
}
|
||||
case 'nvidia':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
settingId,
|
||||
mirror: PYPI_MIRROR.mirror,
|
||||
fallbackMirror: PYPI_MIRROR.fallbackMirror
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userIsInChina = ref(false)
|
||||
const useFallbackMirror = (mirror: UVMirror) => ({
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
})
|
||||
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
|
||||
(
|
||||
[
|
||||
[PYTHON_MIRROR, pythonMirror],
|
||||
[PYPI_MIRROR, pypiMirror],
|
||||
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
|
||||
] as [UVMirror, ModelRef<string>][]
|
||||
).map(([item, modelValue]) => [
|
||||
userIsInChina.value ? useFallbackMirror(item) : item,
|
||||
modelValue
|
||||
])
|
||||
)
|
||||
|
||||
const validationStates = ref<ValidationState[]>(
|
||||
mirrors.value.map(() => ValidationState.IDLE)
|
||||
)
|
||||
|
||||
// Get default install path on component mount
|
||||
// Get system paths on component mount
|
||||
onMounted(async () => {
|
||||
const paths = await electron.getSystemPaths()
|
||||
appData.value = paths.appData
|
||||
appPath.value = paths.appPath
|
||||
installPath.value = paths.defaultInstallPath
|
||||
|
||||
await validatePath(paths.defaultInstallPath)
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
const validatePath = async (path: string | undefined) => {
|
||||
@@ -263,52 +151,3 @@ const onFocus = async () => {
|
||||
await validatePath(installPath.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
@apply px-12;
|
||||
|
||||
.p-accordionpanel {
|
||||
@apply border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-radius 0.5s ease;
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
.p-accordionheader-toggle-icon {
|
||||
&::before {
|
||||
content: '\e933';
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader-toggle-icon {
|
||||
&::before {
|
||||
content: '\e902';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MigrationPicker from './MigrationPicker.vue'
|
||||
|
||||
const meta: Meta<typeof MigrationPicker> = {
|
||||
title: 'Desktop/Components/MigrationPicker',
|
||||
component: MigrationPicker,
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' }
|
||||
]
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
;(window as any).electronAPI = {
|
||||
validateComfyUISource: () => Promise.resolve({ isValid: true }),
|
||||
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
|
||||
}
|
||||
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { MigrationPicker },
|
||||
setup() {
|
||||
const sourcePath = ref('')
|
||||
const migrationItemIds = ref<string[]>([])
|
||||
return { sourcePath, migrationItemIds }
|
||||
},
|
||||
template:
|
||||
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
<div class="flex flex-col gap-6 w-[600px]">
|
||||
<!-- Source Location Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.migrateFromExistingInstallation') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-neutral-400 my-0">
|
||||
{{ $t('install.migrationSourcePathDescription') }}
|
||||
</p>
|
||||
@@ -9,7 +13,7 @@
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
v-model="sourcePath"
|
||||
:placeholder="$t('install.locationPicker.migrationPathPlaceholder')"
|
||||
placeholder="Select existing ComfyUI installation (optional)"
|
||||
class="flex-1"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validateSource"
|
||||
@@ -23,7 +27,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Migration Options -->
|
||||
<div v-if="isValidSource" class="flex flex-col gap-4 p-4 rounded-lg">
|
||||
<div
|
||||
v-if="isValidSource"
|
||||
class="flex flex-col gap-4 bg-neutral-800 p-4 rounded-lg"
|
||||
>
|
||||
<h3 class="text-lg mt-0 font-medium text-neutral-100">
|
||||
{{ $t('install.selectItemsToMigrate') }}
|
||||
</h3>
|
||||
|
||||
121
src/components/install/MirrorsConfiguration.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<Panel
|
||||
:header="$t('install.settings.mirrorSettings')"
|
||||
toggleable
|
||||
:collapsed="!showMirrorInputs"
|
||||
pt:root="bg-neutral-800 border-none w-[600px]"
|
||||
>
|
||||
<template
|
||||
v-for="([item, modelValue], index) in mirrors"
|
||||
:key="item.settingId + item.mirror"
|
||||
>
|
||||
<Divider v-if="index > 0" />
|
||||
|
||||
<MirrorItem
|
||||
v-model="modelValue.value"
|
||||
:item="item"
|
||||
@state-change="validationStates[index] = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #icons>
|
||||
<i
|
||||
v-tooltip="validationStateTooltip"
|
||||
:class="{
|
||||
'pi pi-spin pi-spinner text-neutral-400':
|
||||
validationState === ValidationState.LOADING,
|
||||
'pi pi-check text-green-500':
|
||||
validationState === ValidationState.VALID,
|
||||
'pi pi-times text-red-500':
|
||||
validationState === ValidationState.INVALID
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TorchDeviceType,
|
||||
TorchMirrorUrl
|
||||
} from '@comfyorg/comfyui-electron-types'
|
||||
import Divider from 'primevue/divider'
|
||||
import Panel from 'primevue/panel'
|
||||
import { ModelRef, computed, onMounted, ref } from 'vue'
|
||||
|
||||
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
|
||||
import { PYPI_MIRROR, PYTHON_MIRROR, UVMirror } from '@/constants/uvMirrors'
|
||||
import { t } from '@/i18n'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
|
||||
|
||||
const showMirrorInputs = ref(false)
|
||||
const { device } = defineProps<{ device: TorchDeviceType | null }>()
|
||||
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
|
||||
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
|
||||
const torchMirror = defineModel<string>('torchMirror', { required: true })
|
||||
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.NightlyCpu,
|
||||
fallbackMirror: TorchMirrorUrl.NightlyCpu
|
||||
}
|
||||
case 'nvidia':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
settingId,
|
||||
mirror: PYPI_MIRROR.mirror,
|
||||
fallbackMirror: PYPI_MIRROR.fallbackMirror
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userIsInChina = ref(false)
|
||||
onMounted(async () => {
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
const useFallbackMirror = (mirror: UVMirror) => ({
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
})
|
||||
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
|
||||
(
|
||||
[
|
||||
[PYTHON_MIRROR, pythonMirror],
|
||||
[PYPI_MIRROR, pypiMirror],
|
||||
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
|
||||
] as [UVMirror, ModelRef<string>][]
|
||||
).map(([item, modelValue]) => [
|
||||
userIsInChina.value ? useFallbackMirror(item) : item,
|
||||
modelValue
|
||||
])
|
||||
)
|
||||
|
||||
const validationStates = ref<ValidationState[]>(
|
||||
mirrors.value.map(() => ValidationState.IDLE)
|
||||
)
|
||||
const validationState = computed(() => {
|
||||
return mergeValidationStates(validationStates.value)
|
||||
})
|
||||
const validationStateTooltip = computed(() => {
|
||||
switch (validationState.value) {
|
||||
case ValidationState.INVALID:
|
||||
return t('install.settings.mirrorsUnreachable')
|
||||
case ValidationState.VALID:
|
||||
return t('install.settings.mirrorsReachable')
|
||||
default:
|
||||
return t('install.settings.checkingMirrors')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 text-neutral-400 text-sm">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-neutral-100 mb-3 mt-0">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-full">
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t(`settings.${normalizedSettingId}.name`) }}
|
||||
</h3>
|
||||
<p class="my-1">
|
||||
<p class="text-sm text-neutral-400 mt-1">
|
||||
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -16,61 +16,18 @@
|
||||
"
|
||||
@state-change="validationState = $event"
|
||||
/>
|
||||
<div v-if="secondParagraph" class="mt-2">
|
||||
<a href="#" @click.prevent="showDialog = true">
|
||||
{{ $t('g.learnMore') }}
|
||||
</a>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="showDialog"
|
||||
modal
|
||||
dismissable-mask
|
||||
:header="$t(`settings.${normalizedSettingId}.urlFormatTitle`)"
|
||||
class="select-none max-w-3xl"
|
||||
>
|
||||
<div class="text-neutral-300">
|
||||
<p class="mt-1 whitespace-pre-wrap">{{ secondParagraph }}</p>
|
||||
<div class="mt-2 break-all">
|
||||
<span class="text-neutral-300 font-semibold">
|
||||
{{ EXAMPLE_URL_FIRST_PART }}
|
||||
</span>
|
||||
<span>{{ EXAMPLE_URL_SECOND_PART }}</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<p>
|
||||
{{ $t(`settings.${normalizedSettingId}.fileUrlDescription`) }}
|
||||
</p>
|
||||
<span class="text-neutral-300 font-semibold">
|
||||
{{ FILE_URL_SCHEME }}
|
||||
</span>
|
||||
<span>
|
||||
{{ EXAMPLE_FILE_URL }}
|
||||
</span>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import { UVMirror } from '@/constants/uvMirrors'
|
||||
import { st } from '@/i18n'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { checkMirrorReachable } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const FILE_URL_SCHEME = 'file://'
|
||||
const EXAMPLE_FILE_URL = '/C:/MyPythonInstallers/'
|
||||
const EXAMPLE_URL_FIRST_PART =
|
||||
'https://github.com/astral-sh/python-build-standalone/releases/download'
|
||||
const EXAMPLE_URL_SECOND_PART =
|
||||
'/20250902/cpython-3.12.11+20250902-x86_64-pc-windows-msvc-install_only.tar.gz'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: UVMirror
|
||||
}>()
|
||||
@@ -81,16 +38,11 @@ const emit = defineEmits<{
|
||||
|
||||
const modelValue = defineModel<string>('modelValue', { required: true })
|
||||
const validationState = ref<ValidationState>(ValidationState.IDLE)
|
||||
const showDialog = ref(false)
|
||||
|
||||
const normalizedSettingId = computed(() => {
|
||||
return normalizeI18nKey(item.settingId)
|
||||
})
|
||||
|
||||
const secondParagraph = computed(() =>
|
||||
st(`settings.${normalizedSettingId.value}.urlDescription`, '')
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
modelValue.value = item.mirror
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
v-model:modelValue="searchQuery"
|
||||
class="model-lib-search-box p-2 2xl:p-4"
|
||||
:placeholder="$t('g.searchModels') + '...'"
|
||||
@search="handleSearch"
|
||||
@@ -31,7 +31,7 @@
|
||||
<ElectronDownloadItems v-if="isElectron()" />
|
||||
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
class="model-lib-tree-explorer"
|
||||
:root="renderedRoot"
|
||||
>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<template #header>
|
||||
<div>
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
v-model:modelValue="searchQuery"
|
||||
class="node-lib-search-box p-2 2xl:p-4"
|
||||
:placeholder="$t('g.searchNodes') + '...'"
|
||||
filter-icon="pi pi-filter"
|
||||
@@ -106,7 +106,7 @@
|
||||
class="m-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
class="node-lib-tree-explorer"
|
||||
:root="renderedRoot"
|
||||
>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<ConfirmPopup />
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
v-model:activeIndex="galleryActiveIndex"
|
||||
:all-gallery-items="allGalleryItems"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
v-model:modelValue="searchQuery"
|
||||
class="workflows-search-box p-2 2xl:p-4"
|
||||
:placeholder="$t('g.searchWorkflows') + '...'"
|
||||
@search="handleSearch"
|
||||
@@ -32,7 +32,7 @@
|
||||
class="ml-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="dummyExpandedKeys"
|
||||
v-model:expandedKeys="dummyExpandedKeys"
|
||||
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
|
||||
:selection-keys="selectionKeys"
|
||||
>
|
||||
@@ -74,7 +74,7 @@
|
||||
class="ml-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="dummyExpandedKeys"
|
||||
v-model:expandedKeys="dummyExpandedKeys"
|
||||
:root="
|
||||
renderTreeNode(
|
||||
bookmarkedWorkflowsTree,
|
||||
@@ -96,7 +96,7 @@
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-if="workflowStore.persistedWorkflows.length > 0"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
|
||||
:selection-keys="selectionKeys"
|
||||
>
|
||||
@@ -114,7 +114,7 @@
|
||||
</div>
|
||||
<div v-else class="comfyui-workflows-search-panel">
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
:root="renderTreeNode(filteredRoot, WorkflowTreeType.Browse)"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
class="flex p-2 gap-2 workflow-tab group"
|
||||
class="flex p-2 gap-2 workflow-tab"
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@@ -11,13 +11,9 @@
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
class="group-hover:hidden absolute font-bold text-2xl top-1/2 left-1/2 -translate-1/2 z-10 bg-(--comfy-menu-secondary-bg) w-4"
|
||||
>•</span
|
||||
>
|
||||
<span v-if="shouldShowStatusIndicator" class="status-indicator">•</span>
|
||||
<Button
|
||||
class="close-button p-0 w-auto invisible"
|
||||
class="close-button p-0 w-auto"
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
@@ -178,6 +174,18 @@ onUnmounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.status-indicator {
|
||||
@apply absolute font-bold;
|
||||
font-size: 1.5rem;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.p-tooltip.workflow-tab-tooltip {
|
||||
z-index: 1200 !important;
|
||||
|
||||
@@ -360,6 +360,14 @@ onUpdated(() => {
|
||||
@apply visible;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton:hover) .status-indicator {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) .close-button {
|
||||
@apply invisible;
|
||||
}
|
||||
|
||||
:deep(.p-scrollpanel-content) {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
@@ -7,7 +6,6 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { AuthUserInfo } from '@/types/authTypes'
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
@@ -22,27 +20,6 @@ export const useCurrentUser = () => {
|
||||
() => !!isApiKeyLogin.value || firebaseUser.value !== null
|
||||
)
|
||||
|
||||
const resolvedUserInfo = computed<AuthUserInfo | null>(() => {
|
||||
if (isApiKeyLogin.value && apiKeyStore.currentUser) {
|
||||
return { id: apiKeyStore.currentUser.id }
|
||||
}
|
||||
|
||||
if (firebaseUser.value) {
|
||||
return { id: firebaseUser.value.uid }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const onUserResolved = (callback: (user: AuthUserInfo) => void) => {
|
||||
if (resolvedUserInfo.value) {
|
||||
callback(resolvedUserInfo.value)
|
||||
}
|
||||
|
||||
const stop = whenever(resolvedUserInfo, callback)
|
||||
return () => stop()
|
||||
}
|
||||
|
||||
const userDisplayName = computed(() => {
|
||||
if (isApiKeyLogin.value) {
|
||||
return apiKeyStore.currentUser?.name
|
||||
@@ -135,10 +112,8 @@ export const useCurrentUser = () => {
|
||||
userPhotoUrl,
|
||||
providerName,
|
||||
providerIcon,
|
||||
resolvedUserInfo,
|
||||
handleSignOut,
|
||||
handleSignIn,
|
||||
handleDeleteAccount,
|
||||
onUserResolved
|
||||
handleDeleteAccount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
const fitAddon = new FitAddon()
|
||||
const terminal = markRaw(
|
||||
new Terminal({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
convertEol: true
|
||||
})
|
||||
)
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
@@ -102,8 +102,6 @@ export function useSelectionToolboxPosition(
|
||||
|
||||
worldPosition.value = {
|
||||
x: unionBounds.x + unionBounds.width / 2,
|
||||
// createBounds() applied a default padding of 10px
|
||||
// so adjust Y to maintain visual consistency
|
||||
y: unionBounds.y - 10
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useElementBounding } from '@vueuse/core'
|
||||
|
||||
import type { LGraphCanvas, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
let sharedConverter: ReturnType<typeof useCanvasPositionConversion> | null =
|
||||
null
|
||||
import type { LGraphCanvas, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
* Convert between canvas and client positions
|
||||
@@ -18,7 +14,7 @@ export const useCanvasPositionConversion = (
|
||||
) => {
|
||||
const { left, top, update } = useElementBounding(canvasElement)
|
||||
|
||||
const clientPosToCanvasPos = (pos: Point): Point => {
|
||||
const clientPosToCanvasPos = (pos: Vector2): Vector2 => {
|
||||
const { offset, scale } = lgCanvas.ds
|
||||
return [
|
||||
(pos[0] - left.value) / scale - offset[0],
|
||||
@@ -26,7 +22,7 @@ export const useCanvasPositionConversion = (
|
||||
]
|
||||
}
|
||||
|
||||
const canvasPosToClientPos = (pos: Point): Point => {
|
||||
const canvasPosToClientPos = (pos: Vector2): Vector2 => {
|
||||
const { offset, scale } = lgCanvas.ds
|
||||
return [
|
||||
(pos[0] + offset[0]) * scale + left.value,
|
||||
@@ -40,10 +36,3 @@ export const useCanvasPositionConversion = (
|
||||
update
|
||||
}
|
||||
}
|
||||
|
||||
export function useSharedCanvasPositionConversion() {
|
||||
if (sharedConverter) return sharedConverter
|
||||
const lgCanvas = useCanvasStore().getCanvas()
|
||||
sharedConverter = useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
|
||||
return sharedConverter
|
||||
}
|
||||
|
||||
@@ -169,74 +169,6 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
: `$${minCost.toFixed(2)}-$${maxCost.toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
// ---- constants ----
|
||||
const SORA_SIZES = {
|
||||
BASIC: new Set(['720x1280', '1280x720']),
|
||||
PRO: new Set(['1024x1792', '1792x1024'])
|
||||
}
|
||||
const ALL_SIZES = new Set([...SORA_SIZES.BASIC, ...SORA_SIZES.PRO])
|
||||
|
||||
// ---- sora-2 pricing helpers ----
|
||||
function validateSora2Selection(
|
||||
modelRaw: string,
|
||||
duration: number,
|
||||
sizeRaw: string
|
||||
): string | undefined {
|
||||
const model = modelRaw?.toLowerCase() ?? ''
|
||||
const size = sizeRaw?.toLowerCase() ?? ''
|
||||
|
||||
if (!duration || Number.isNaN(duration)) return 'Set duration (4s / 8s / 12s)'
|
||||
if (!size) return 'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)'
|
||||
if (!ALL_SIZES.has(size))
|
||||
return 'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.'
|
||||
|
||||
if (model.includes('sora-2-pro')) return undefined
|
||||
|
||||
if (model.includes('sora-2') && !SORA_SIZES.BASIC.has(size))
|
||||
return 'sora-2 supports only 720x1280 or 1280x720'
|
||||
|
||||
if (!model.includes('sora-2')) return 'Unsupported model'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function perSecForSora2(modelRaw: string, sizeRaw: string): number {
|
||||
const model = modelRaw?.toLowerCase() ?? ''
|
||||
const size = sizeRaw?.toLowerCase() ?? ''
|
||||
|
||||
if (model.includes('sora-2-pro')) {
|
||||
return SORA_SIZES.PRO.has(size) ? 0.5 : 0.3
|
||||
}
|
||||
if (model.includes('sora-2')) return 0.1
|
||||
|
||||
return SORA_SIZES.PRO.has(size) ? 0.5 : 0.1
|
||||
}
|
||||
|
||||
function formatRunPrice(perSec: number, duration: number) {
|
||||
return `$${(perSec * duration).toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
// ---- pricing calculator ----
|
||||
const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
|
||||
const getWidgetValue = (name: string) =>
|
||||
String(node.widgets?.find((w) => w.name === name)?.value ?? '')
|
||||
|
||||
const model = getWidgetValue('model')
|
||||
const size = getWidgetValue('size')
|
||||
const duration = Number(
|
||||
node.widgets?.find((w) => ['duration', 'duration_s'].includes(w.name))
|
||||
?.value
|
||||
)
|
||||
|
||||
if (!model || !size || !duration) return 'Set model, duration & size'
|
||||
|
||||
const validationError = validateSora2Selection(model, duration, size)
|
||||
if (validationError) return validationError
|
||||
|
||||
const perSec = perSecForSora2(model, size)
|
||||
return formatRunPrice(perSec, duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static pricing data for API nodes, now supporting both strings and functions
|
||||
*/
|
||||
@@ -263,9 +195,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
FluxProKontextMaxNode: {
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
OpenAIVideoSora2: {
|
||||
displayPrice: sora2PricingCalculator
|
||||
},
|
||||
IdeogramV1: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const numImagesWidget = node.widgets?.find(
|
||||
@@ -1619,74 +1548,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
ByteDanceImageReferenceNode: {
|
||||
displayPrice: byteDanceVideoPricingCalculator
|
||||
},
|
||||
WanTextToVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'size'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const resolutionStr = String(resolutionWidget.value).toLowerCase()
|
||||
|
||||
const resKey = resolutionStr.includes('1080')
|
||||
? '1080p'
|
||||
: resolutionStr.includes('720')
|
||||
? '720p'
|
||||
: resolutionStr.includes('480')
|
||||
? '480p'
|
||||
: resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? ''
|
||||
|
||||
const pricePerSecond: Record<string, number> = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.1,
|
||||
'1080p': 0.15
|
||||
}
|
||||
|
||||
const pps = pricePerSecond[resKey]
|
||||
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
|
||||
|
||||
const cost = (pps * seconds).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
WanImageToVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).trim().toLowerCase()
|
||||
|
||||
const pricePerSecond: Record<string, number> = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.1,
|
||||
'1080p': 0.15
|
||||
}
|
||||
|
||||
const pps = pricePerSecond[resolution]
|
||||
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
|
||||
|
||||
const cost = (pps * seconds).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
WanTextToImageApi: {
|
||||
displayPrice: '$0.03/Run'
|
||||
},
|
||||
WanImageToImageApi: {
|
||||
displayPrice: '$0.03/Run'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1728,7 +1589,6 @@ export const useNodePricing = () => {
|
||||
MinimaxHailuoVideoNode: ['resolution', 'duration'],
|
||||
OpenAIDalle3: ['size', 'quality'],
|
||||
OpenAIDalle2: ['size', 'n'],
|
||||
OpenAIVideoSora2: ['model', 'size', 'duration'],
|
||||
OpenAIGPTImage1: ['quality', 'n'],
|
||||
IdeogramV1: ['num_images', 'turbo'],
|
||||
IdeogramV2: ['num_images', 'turbo'],
|
||||
@@ -1787,9 +1647,7 @@ export const useNodePricing = () => {
|
||||
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution']
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ref } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -28,19 +27,16 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
|
||||
|
||||
if (dndData.type === 'tree-explorer-node') {
|
||||
const node = dndData.data as RenderedTreeExplorerNode
|
||||
const conv = useSharedCanvasPositionConversion()
|
||||
const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY])
|
||||
|
||||
if (node.data instanceof ComfyNodeDefImpl) {
|
||||
const nodeDef = node.data
|
||||
const pos = [...basePos]
|
||||
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
|
||||
// Add an offset on y to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
} else if (node.data instanceof ComfyModelDef) {
|
||||
const model = node.data
|
||||
const pos = basePos
|
||||
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
|
||||
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
|
||||
let targetProvider: ModelNodeProvider | null = null
|
||||
let targetGraphNode: LGraphNode | null = null
|
||||
@@ -77,7 +73,11 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
|
||||
}
|
||||
} else if (node.data instanceof ComfyWorkflow) {
|
||||
const workflow = node.data
|
||||
await workflowService.insertWorkflow(workflow, { position: basePos })
|
||||
const position = comfyApp.clientPosToCanvasPos([
|
||||
loc.clientX,
|
||||
loc.clientY
|
||||
])
|
||||
await workflowService.insertWorkflow(workflow, { position })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,7 @@ export function useManagerState() {
|
||||
)
|
||||
|
||||
// Check command line args first (highest priority)
|
||||
// --enable-manager flag enables the manager (opposite of old --disable-manager)
|
||||
const hasEnableManager =
|
||||
systemStats.value?.system?.argv?.includes('--enable-manager')
|
||||
|
||||
// If --enable-manager is NOT present, manager is disabled
|
||||
if (!hasEnableManager) {
|
||||
if (systemStats.value?.system?.argv?.includes('--disable-manager')) {
|
||||
return ManagerUIState.DISABLED
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
export interface DialogAction {
|
||||
readonly label: string
|
||||
readonly action: 'openUrl' | 'close' | 'cancel'
|
||||
readonly url?: string
|
||||
readonly severity?: 'danger' | 'primary' | 'secondary' | 'warn'
|
||||
readonly returnValue: string
|
||||
}
|
||||
|
||||
interface DesktopDialog {
|
||||
readonly title: string
|
||||
readonly message: string
|
||||
readonly buttons: DialogAction[]
|
||||
}
|
||||
|
||||
export const DESKTOP_DIALOGS = {
|
||||
/** Shown when a corrupt venv is detected. */
|
||||
reinstallVenv: {
|
||||
title: 'Reinstall ComfyUI (Fresh Start)?',
|
||||
message: `Sorry, we can't launch ComfyUI because some installed packages aren't compatible.
|
||||
|
||||
Click Reinstall to restore ComfyUI and get back up and running.
|
||||
|
||||
Please note: if you've added custom nodes, you'll need to reinstall them after this process.`,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Learn More',
|
||||
action: 'openUrl',
|
||||
url: 'https://docs.comfy.org',
|
||||
returnValue: 'openDocs'
|
||||
},
|
||||
{
|
||||
label: 'Reinstall',
|
||||
action: 'close',
|
||||
severity: 'danger',
|
||||
returnValue: 'resetVenv'
|
||||
}
|
||||
]
|
||||
},
|
||||
/** A dialog that is shown when an invalid dialog ID is provided. */
|
||||
invalidDialog: {
|
||||
title: 'Invalid Dialog',
|
||||
message: `Invalid dialog ID was provided.`,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Close',
|
||||
action: 'cancel',
|
||||
returnValue: 'cancel'
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies { [K: string]: DesktopDialog }
|
||||
|
||||
/** The ID of a desktop dialog. */
|
||||
type DesktopDialogId = keyof typeof DESKTOP_DIALOGS
|
||||
|
||||
/**
|
||||
* Checks if {@link id} is a valid dialog ID.
|
||||
* @param id The string to check
|
||||
* @returns `true` if the ID is a valid dialog ID, otherwise `false`
|
||||
*/
|
||||
function isDialogId(id: unknown): id is DesktopDialogId {
|
||||
return typeof id === 'string' && id in DESKTOP_DIALOGS
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the dialog with the given ID.
|
||||
* @param dialogId The ID of the dialog to get
|
||||
* @returns The dialog with the given ID
|
||||
*/
|
||||
export function getDialog(
|
||||
dialogId: string | string[]
|
||||
): DesktopDialog & { id: DesktopDialogId } {
|
||||
const id = isDialogId(dialogId) ? dialogId : 'invalidDialog'
|
||||
return { id, ...structuredClone(DESKTOP_DIALOGS[id]) }
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
INodeOutputSlot,
|
||||
ISlotType,
|
||||
LLink,
|
||||
Point
|
||||
Vector2
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -557,7 +557,7 @@ app.registerExtension({
|
||||
}
|
||||
)
|
||||
|
||||
function isNodeAtPos(pos: Point) {
|
||||
function isNodeAtPos(pos: Vector2) {
|
||||
for (const n of app.graph.nodes) {
|
||||
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
|
||||
return true
|
||||
|
||||
@@ -2348,7 +2348,7 @@ export class LGraphCanvas
|
||||
if (
|
||||
ctrlOrMeta &&
|
||||
!e.altKey &&
|
||||
LiteGraph.leftMouseClickBehavior === 'panning'
|
||||
LiteGraph.canvasNavigationMode === 'legacy'
|
||||
) {
|
||||
this.#setupNodeSelectionDrag(e, pointer, node)
|
||||
|
||||
@@ -2616,8 +2616,8 @@ export class LGraphCanvas
|
||||
!pointer.onDrag &&
|
||||
this.allow_dragcanvas
|
||||
) {
|
||||
// allow dragging canvas based on leftMouseClickBehavior or read-only mode
|
||||
if (LiteGraph.leftMouseClickBehavior === 'panning' || this.read_only) {
|
||||
// allow dragging canvas if canvas is not in standard, or read-only (pan mode in standard)
|
||||
if (LiteGraph.canvasNavigationMode !== 'standard' || this.read_only) {
|
||||
pointer.onClick = () => this.processSelect(null, e)
|
||||
pointer.finally = () => (this.dragging_canvas = false)
|
||||
this.dragging_canvas = true
|
||||
@@ -3629,8 +3629,8 @@ export class LGraphCanvas
|
||||
e.ctrlKey || (e.metaKey && navigator.platform.includes('Mac'))
|
||||
const isZoomModifier = isCtrlOrMacMeta && !e.altKey && !e.shiftKey
|
||||
|
||||
if (isZoomModifier || LiteGraph.mouseWheelScroll === 'zoom') {
|
||||
// Zoom mode or modifier key pressed - use wheel for zoom
|
||||
if (isZoomModifier || LiteGraph.canvasNavigationMode === 'legacy') {
|
||||
// Legacy mode or standard mode with ctrl - use wheel for zoom
|
||||
if (isTrackpad) {
|
||||
// Trackpad gesture - use smooth scaling
|
||||
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
|
||||
@@ -3645,6 +3645,7 @@ export class LGraphCanvas
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY])
|
||||
}
|
||||
} else {
|
||||
// Standard mode without ctrl - use wheel / gestures to pan
|
||||
// Trackpads and mice work on significantly different scales
|
||||
const factor = isTrackpad ? 0.18 : 0.008_333
|
||||
|
||||
|
||||
@@ -304,14 +304,9 @@ export class LiteGraphGlobal {
|
||||
/**
|
||||
* "standard": change the dragging on left mouse button click to select, enable middle-click or spacebar+left-click dragging
|
||||
* "legacy": Enable dragging on left-click (original behavior)
|
||||
* "custom": Use leftMouseClickBehavior and mouseWheelScroll settings
|
||||
* @default "legacy"
|
||||
*/
|
||||
canvasNavigationMode: 'standard' | 'legacy' | 'custom' = 'legacy'
|
||||
|
||||
leftMouseClickBehavior: 'panning' | 'select' = 'panning'
|
||||
|
||||
mouseWheelScroll: 'panning' | 'zoom' = 'panning'
|
||||
canvasNavigationMode: 'standard' | 'legacy' = 'legacy'
|
||||
|
||||
/**
|
||||
* If `true`, widget labels and values will both be truncated (proportionally to size),
|
||||
|
||||
@@ -4,14 +4,11 @@ import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink, type ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
|
||||
import type {
|
||||
ISubgraphInput,
|
||||
IWidgetLocator
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
ISlotType,
|
||||
NodeId
|
||||
import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
type INodeInputSlot,
|
||||
type ISlotType,
|
||||
type NodeId
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
|
||||
import { NodeOutputSlot } from '@/lib/litegraph/src/node/NodeOutputSlot'
|
||||
@@ -81,10 +78,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const existingInput = this.inputs.find((i) => i.name == name)
|
||||
if (existingInput) {
|
||||
const linkId = subgraphInput.linkIds[0]
|
||||
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
|
||||
const { inputNode } = subgraph.links[linkId].resolve(subgraph)
|
||||
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
|
||||
if (widget)
|
||||
this.#setWidget(subgraphInput, existingInput, widget, input?.widget)
|
||||
if (widget) this.#setWidget(subgraphInput, existingInput, widget)
|
||||
return
|
||||
}
|
||||
const input = this.addInput(name, type)
|
||||
@@ -189,14 +185,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
subgraphInput.events.addEventListener(
|
||||
'input-connected',
|
||||
(e) => {
|
||||
() => {
|
||||
if (input._widget) return
|
||||
|
||||
const widget = subgraphInput._widget
|
||||
if (!widget) return
|
||||
|
||||
const widgetLocator = e.detail.input.widget
|
||||
this.#setWidget(subgraphInput, input, widget, widgetLocator)
|
||||
this.#setWidget(subgraphInput, input, widget)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -306,7 +301,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const widget = resolved.inputNode.getWidgetFromSlot(resolved.input)
|
||||
if (!widget) continue
|
||||
|
||||
this.#setWidget(subgraphInput, input, widget, resolved.input.widget)
|
||||
this.#setWidget(subgraphInput, input, widget)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -315,13 +310,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
#setWidget(
|
||||
subgraphInput: Readonly<SubgraphInput>,
|
||||
input: INodeInputSlot,
|
||||
widget: Readonly<IBaseWidget>,
|
||||
inputWidget: IWidgetLocator | undefined
|
||||
widget: Readonly<IBaseWidget>
|
||||
) {
|
||||
// Use the first matching widget
|
||||
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
|
||||
this
|
||||
)
|
||||
const targetWidget = toConcreteWidget(widget, this)
|
||||
const promotedWidget = targetWidget.createCopyForNode(this)
|
||||
|
||||
Object.assign(promotedWidget, {
|
||||
get name() {
|
||||
@@ -379,9 +372,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// NOTE: This code creates linked chains of prototypes for passing across
|
||||
// multiple levels of subgraphs. As part of this, it intentionally avoids
|
||||
// creating new objects. Have care when making changes.
|
||||
const backingInput =
|
||||
targetWidget.node.findInputSlot(widget.name, true)?.widget ?? {}
|
||||
input.widget ??= { name: subgraphInput.name }
|
||||
input.widget.name = subgraphInput.name
|
||||
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
|
||||
Object.setPrototypeOf(input.widget, backingInput)
|
||||
|
||||
input._widget = promotedWidget
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export class ComboWidget
|
||||
return typeof this.value === 'number' ? String(this.value) : this.value
|
||||
}
|
||||
|
||||
private getValues(node: LGraphNode): Values {
|
||||
#getValues(node: LGraphNode): Values {
|
||||
const { values } = this.options
|
||||
if (values == null) throw new Error('[ComboWidget]: values is required')
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ComboWidget
|
||||
* @param increment `true` if checking the use of the increment button, `false` for decrement
|
||||
* @returns `true` if the value is at the given index, otherwise `false`.
|
||||
*/
|
||||
private canUseButton(increment: boolean): boolean {
|
||||
#canUseButton(increment: boolean): boolean {
|
||||
const { values } = this.options
|
||||
// If using legacy duck-typed method, false is the most permissive return value
|
||||
if (typeof values === 'function') return false
|
||||
@@ -78,23 +78,23 @@ export class ComboWidget
|
||||
* Handles edge case where the value is both the first and last item in the list.
|
||||
*/
|
||||
override canIncrement(): boolean {
|
||||
return this.canUseButton(true)
|
||||
return this.#canUseButton(true)
|
||||
}
|
||||
|
||||
override canDecrement(): boolean {
|
||||
return this.canUseButton(false)
|
||||
return this.#canUseButton(false)
|
||||
}
|
||||
|
||||
override incrementValue(options: WidgetEventOptions): void {
|
||||
this.tryChangeValue(1, options)
|
||||
this.#tryChangeValue(1, options)
|
||||
}
|
||||
|
||||
override decrementValue(options: WidgetEventOptions): void {
|
||||
this.tryChangeValue(-1, options)
|
||||
this.#tryChangeValue(-1, options)
|
||||
}
|
||||
|
||||
private tryChangeValue(delta: number, options: WidgetEventOptions): void {
|
||||
const values = this.getValues(options.node)
|
||||
#tryChangeValue(delta: number, options: WidgetEventOptions): void {
|
||||
const values = this.#getValues(options.node)
|
||||
const indexedValues = toArray(values)
|
||||
|
||||
// avoids double click event
|
||||
@@ -128,7 +128,7 @@ export class ComboWidget
|
||||
if (x > width - 40) return this.incrementValue({ e, node, canvas })
|
||||
|
||||
// Otherwise, show dropdown menu
|
||||
const values = this.getValues(node)
|
||||
const values = this.#getValues(node)
|
||||
const values_list = toArray(values)
|
||||
|
||||
// Handle center click - show dropdown menu
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IButtonWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { BaseWidget, type DrawWidgetOptions } from './BaseWidget'
|
||||
|
||||
class DisconnectedWidget extends BaseWidget<IButtonWidget> {
|
||||
constructor(widget: IButtonWidget) {
|
||||
super(widget, new LGraphNode('DisconnectedPlaceholder'))
|
||||
this.disabled = true
|
||||
}
|
||||
|
||||
override drawWidget(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ width, showText = true }: DrawWidgetOptions
|
||||
) {
|
||||
ctx.save()
|
||||
this.drawWidgetShape(ctx, { width, showText })
|
||||
if (showText) {
|
||||
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
override onClick() {}
|
||||
|
||||
override get _displayValue() {
|
||||
return 'Disconnected'
|
||||
}
|
||||
}
|
||||
const conf: IButtonWidget = {
|
||||
type: 'button',
|
||||
value: undefined,
|
||||
name: 'Disconnected',
|
||||
options: {},
|
||||
y: 0,
|
||||
clicked: false
|
||||
}
|
||||
export const disconnectedWidget = new DisconnectedWidget(conf)
|
||||
@@ -1,5 +1,333 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Float32Array [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float32Array [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float32Array [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
"color": "#6029aa",
|
||||
"flags": {},
|
||||
"font": undefined,
|
||||
"font_size": 14,
|
||||
"graph": [Circular],
|
||||
"id": 123,
|
||||
"isPointInside": [Function],
|
||||
"selected": undefined,
|
||||
"setDirtyCanvas": [Function],
|
||||
"title": "A group to test with",
|
||||
},
|
||||
],
|
||||
"_input_nodes": undefined,
|
||||
"_last_trigger_time": undefined,
|
||||
"_links": Map {},
|
||||
"_nodes": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_nodes_by_id": {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
},
|
||||
"_nodes_executable": [],
|
||||
"_nodes_in_order": [
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
"action_call": undefined,
|
||||
"action_triggered": undefined,
|
||||
"badgePosition": "top-left",
|
||||
"badges": [],
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
"console": undefined,
|
||||
"exec_version": undefined,
|
||||
"execute_triggered": undefined,
|
||||
"flags": {},
|
||||
"freeWidgetSpace": undefined,
|
||||
"gotFocusAt": undefined,
|
||||
"graph": [Circular],
|
||||
"has_errors": undefined,
|
||||
"id": 1,
|
||||
"ignore_remove": undefined,
|
||||
"inputs": [],
|
||||
"last_serialization": undefined,
|
||||
"locked": undefined,
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
"properties": {},
|
||||
"properties_info": [],
|
||||
"redraw_on_mouse": undefined,
|
||||
"removable": undefined,
|
||||
"resizable": undefined,
|
||||
"selected": undefined,
|
||||
"serialize_widgets": undefined,
|
||||
"showAdvanced": undefined,
|
||||
"strokeStyles": {
|
||||
"error": [Function],
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
"widgets_up": undefined,
|
||||
},
|
||||
],
|
||||
"_subgraphs": Map {},
|
||||
"_version": 3,
|
||||
"catch_errors": true,
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
"filter": undefined,
|
||||
"fixedtime": 0,
|
||||
"fixedtime_lapse": 0.01,
|
||||
"globaltime": 0,
|
||||
"id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
|
||||
"iteration": 0,
|
||||
"last_update_time": 0,
|
||||
"links": Map {},
|
||||
"list_of_graphcanvas": null,
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
"state": {
|
||||
"lastGroupId": 123,
|
||||
"lastLinkId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastRerouteId": 0,
|
||||
},
|
||||
"status": 1,
|
||||
"vars": {},
|
||||
"version": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredMinGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [],
|
||||
"_input_nodes": undefined,
|
||||
"_last_trigger_time": undefined,
|
||||
"_links": Map {},
|
||||
"_nodes": [],
|
||||
"_nodes_by_id": {},
|
||||
"_nodes_executable": [],
|
||||
"_nodes_in_order": [],
|
||||
"_subgraphs": Map {},
|
||||
"_version": 0,
|
||||
"catch_errors": true,
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
"filter": undefined,
|
||||
"fixedtime": 0,
|
||||
"fixedtime_lapse": 0.01,
|
||||
"globaltime": 0,
|
||||
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
|
||||
"iteration": 0,
|
||||
"last_update_time": 0,
|
||||
"links": Map {},
|
||||
"list_of_graphcanvas": null,
|
||||
"nodes_actioning": [],
|
||||
"nodes_executedAction": [],
|
||||
"nodes_executing": [],
|
||||
"revision": 0,
|
||||
"runningtime": 0,
|
||||
"starttime": 0,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastLinkId": 0,
|
||||
"lastNodeId": 0,
|
||||
"lastRerouteId": 0,
|
||||
},
|
||||
"status": 1,
|
||||
"vars": {},
|
||||
"version": 1,
|
||||
}
|
||||
`;
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
"Comfy_ExportWorkflowAPI": {
|
||||
"label": "تصدير سير العمل (تنسيق API)"
|
||||
},
|
||||
"Comfy_Feedback": {
|
||||
"label": "إرسال ملاحظات"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "تحويل التحديد إلى رسم فرعي"
|
||||
},
|
||||
@@ -167,6 +170,9 @@
|
||||
"Comfy_LoadDefaultWorkflow": {
|
||||
"label": "تحميل سير العمل الافتراضي"
|
||||
},
|
||||
"Comfy_Manager_CustomNodesManager": {
|
||||
"label": "تبديل مدير العقد المخصصة"
|
||||
},
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "تبديل شريط تقدم مدير العقد المخصصة"
|
||||
},
|
||||
@@ -285,4 +291,4 @@
|
||||
"label": "تبديل الشريط الجانبي لسير العمل",
|
||||
"tooltip": "سير العمل"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +325,7 @@
|
||||
"frontendNewer": "إصدار الواجهة الأمامية {frontendVersion} قد لا يكون متوافقاً مع الإصدار الخلفي {backendVersion}.",
|
||||
"frontendOutdated": "إصدار الواجهة الأمامية {frontendVersion} قديم. يتطلب الإصدار الخلفي {requiredVersion} أو أحدث.",
|
||||
"goToNode": "الانتقال إلى العقدة",
|
||||
"help": "مساعدة",
|
||||
"icon": "أيقونة",
|
||||
"imageFailedToLoad": "فشل تحميل الصورة",
|
||||
"imageUrl": "رابط الصورة",
|
||||
@@ -777,6 +778,7 @@
|
||||
"File": "ملف",
|
||||
"Fit Group To Contents": "ملائمة المجموعة للمحتويات",
|
||||
"Focus Mode": "وضع التركيز",
|
||||
"Give Feedback": "تقديم ملاحظات",
|
||||
"Group Selected Nodes": "تجميع العقد المحددة",
|
||||
"Help": "مساعدة",
|
||||
"Help Center": "مركز المساعدة",
|
||||
@@ -835,6 +837,7 @@
|
||||
"Toggle Terminal Bottom Panel": "تبديل لوحة الطرفية السفلية",
|
||||
"Toggle Theme (Dark/Light)": "تبديل السمة (داكن/فاتح)",
|
||||
"Toggle View Controls Bottom Panel": "تبديل لوحة عناصر التحكم في العرض السفلية",
|
||||
"Toggle the Custom Nodes Manager": "تبديل مدير العقد المخصصة",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "تبديل شريط تقدم مدير العقد المخصصة",
|
||||
"Undo": "تراجع",
|
||||
"Ungroup selected group nodes": "فك تجميع عقد المجموعة المحددة",
|
||||
@@ -928,6 +931,9 @@
|
||||
"upscale_diffusion": "انتشار التكبير",
|
||||
"upscaling": "تكبير",
|
||||
"utils": "أدوات مساعدة",
|
||||
"v1": "الإصدار 1",
|
||||
"v2": "الإصدار 2",
|
||||
"v3": "الإصدار 3",
|
||||
"video": "فيديو",
|
||||
"video_models": "نماذج الفيديو"
|
||||
},
|
||||
@@ -1687,4 +1693,4 @@
|
||||
"showMinimap": "إظهار الخريطة المصغرة",
|
||||
"zoomToFit": "تكبير لتناسب الشاشة"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7366,6 +7366,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveSVG": {
|
||||
"description": "يحفظ ملفات SVG على القرص.",
|
||||
"display_name": "حفظ SVG",
|
||||
"inputs": {
|
||||
"filename_prefix": {
|
||||
"name": "بادئة اسم الملف",
|
||||
"tooltip": "بادئة اسم الملف للحفظ. يمكن أن تتضمن معلومات تنسيق مثل %date:yyyy-MM-dd% أو %Empty Latent Image.width% لاستخدام قيم من العقد."
|
||||
},
|
||||
"svg": {
|
||||
"name": "ملف SVG"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveVideo": {
|
||||
"description": "يحفظ الصور المدخلة في مجلد مخرجات ComfyUI الخاص بك.",
|
||||
"display_name": "حفظ الفيديو",
|
||||
@@ -8644,4 +8657,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,10 @@
|
||||
"Topbar (2nd-row)": "شريط الأعلى (الصف الثاني)"
|
||||
}
|
||||
},
|
||||
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
|
||||
"name": "عتبة التكبير للرسم بجودة منخفضة",
|
||||
"tooltip": "عرض أشكال بجودة منخفضة عند التكبير للخارج"
|
||||
},
|
||||
"LiteGraph_Canvas_MaximumFps": {
|
||||
"name": "الحد الأقصى للإطارات في الثانية",
|
||||
"tooltip": "الحد الأقصى لعدد الإطارات في الثانية التي يسمح للرسم أن يعرضها. يحد من استخدام GPU على حساب السلاسة. إذا كانت 0، يتم استخدام معدل تحديث الشاشة. الافتراضي: 0"
|
||||
@@ -409,4 +413,4 @@
|
||||
"pysssss_SnapToGrid": {
|
||||
"name": "الالتصاق بالشبكة دائمًا"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,9 @@
|
||||
"Comfy_ExportWorkflowAPI": {
|
||||
"label": "Export Workflow (API Format)"
|
||||
},
|
||||
"Comfy_Feedback": {
|
||||
"label": "Give Feedback"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convert Selection to Subgraph"
|
||||
},
|
||||
@@ -257,9 +260,6 @@
|
||||
"Comfy_User_SignOut": {
|
||||
"label": "Sign Out"
|
||||
},
|
||||
"Experimental_ToggleVueNodes": {
|
||||
"label": "Experimental: Enable Vue Nodes"
|
||||
},
|
||||
"Workspace_CloseWorkflow": {
|
||||
"label": "Close Current Workflow"
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"calculatingDimensions": "Calculating dimensions",
|
||||
"import": "Import",
|
||||
"loadAllFolders": "Load All Folders",
|
||||
"logoAlt": "ComfyUI Logo",
|
||||
"refresh": "Refresh",
|
||||
"refreshNode": "Refresh Node",
|
||||
"terminal": "Terminal",
|
||||
@@ -407,27 +406,6 @@
|
||||
"migration": "Migration",
|
||||
"desktopSettings": "Desktop Settings",
|
||||
"chooseInstallationLocation": "Choose Installation Location",
|
||||
"gpuPicker": {
|
||||
"title": "Choose your hardware setup",
|
||||
"recommended": "RECOMMENDED",
|
||||
"nvidiaSubtitle": "NVIDIA CUDA",
|
||||
"cpuSubtitle": "CPU Mode",
|
||||
"manualSubtitle": "Manual Setup",
|
||||
"appleMetalDescription": "Leverages your Mac's GPU for faster speed and a better overall experience",
|
||||
"nvidiaDescription": "Use your NVIDIA GPU with CUDA acceleration for the best performance.",
|
||||
"cpuDescription": "Use CPU mode for compatibility when GPU acceleration is not available",
|
||||
"manualDescription": "Configure ComfyUI manually for advanced setups or unsupported hardware"
|
||||
},
|
||||
"locationPicker": {
|
||||
"title": "Choose where to install ComfyUI",
|
||||
"subtitle": "Pick a folder for ComfyUI's files. We'll also set up Python there automatically.",
|
||||
"pathPlaceholder": "/Users/username/Documents/ComfyUI",
|
||||
"migrationPathPlaceholder": "Select existing ComfyUI installation (optional)",
|
||||
"migrateFromExisting": "Migrate from existing installation",
|
||||
"migrateDescription": "Copy or link your existing models, custom nodes, and configurations from a previous ComfyUI installation.",
|
||||
"chooseDownloadServers": "Choose download servers manually",
|
||||
"downloadServersDescription": "Select specific mirror servers for downloading Python, PyPI packages, and PyTorch based on your location."
|
||||
},
|
||||
"systemLocations": "System Locations",
|
||||
"failedToSelectDirectory": "Failed to select directory",
|
||||
"pathValidationFailed": "Failed to validate path",
|
||||
@@ -512,26 +490,18 @@
|
||||
"metricsDisabled": "Metrics Disabled",
|
||||
"updateConsent": "You previously opted in to reporting crashes. We are now tracking event-based metrics to help identify bugs and improve the app. No personal identifiable information is collected."
|
||||
},
|
||||
"desktopStart": {
|
||||
"initialising": "Initialising..."
|
||||
},
|
||||
"serverStart": {
|
||||
"title": "Starting ComfyUI",
|
||||
"troubleshoot": "Troubleshoot",
|
||||
"reportIssue": "Report Issue",
|
||||
"openLogs": "Open Logs",
|
||||
"showTerminal": "Show Terminal",
|
||||
"copySelectionTooltip": "Copy selection",
|
||||
"copyAllTooltip": "Copy all",
|
||||
"errorMessage": "Unable to start ComfyUI Desktop",
|
||||
"installation": {
|
||||
"title": "Installing ComfyUI"
|
||||
},
|
||||
"process": {
|
||||
"initial-state": "Loading...",
|
||||
"python-setup": "Setting up Python Environment...",
|
||||
"starting-server": "Starting ComfyUI server...",
|
||||
"ready": "Loading Human Interface",
|
||||
"ready": "Finishing...",
|
||||
"error": "Unable to start ComfyUI Desktop"
|
||||
}
|
||||
},
|
||||
@@ -1103,7 +1073,7 @@
|
||||
"queue": "Queue Panel"
|
||||
},
|
||||
"menuLabels": {
|
||||
"File": "File",
|
||||
"Workflow": "Workflow",
|
||||
"Edit": "Edit",
|
||||
"View": "View",
|
||||
"Help": "Help",
|
||||
@@ -1122,6 +1092,7 @@
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
|
||||
"Browse Templates": "Browse Templates",
|
||||
"Delete Selected Items": "Delete Selected Items",
|
||||
"Fit view to selected nodes": "Fit view to selected nodes",
|
||||
"Zoom to fit": "Zoom to fit",
|
||||
"Lock Canvas": "Lock Canvas",
|
||||
"Move Selected Nodes Down": "Move Selected Nodes Down",
|
||||
@@ -1130,9 +1101,8 @@
|
||||
"Move Selected Nodes Up": "Move Selected Nodes Up",
|
||||
"Reset View": "Reset View",
|
||||
"Resize Selected Nodes": "Resize Selected Nodes",
|
||||
"Node Links": "Node Links",
|
||||
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
|
||||
"Canvas Toggle Lock": "Canvas Toggle Lock",
|
||||
"Minimap": "Minimap",
|
||||
"Pin/Unpin Selected Items": "Pin/Unpin Selected Items",
|
||||
"Bypass/Unbypass Selected Nodes": "Bypass/Unbypass Selected Nodes",
|
||||
"Collapse/Expand Selected Nodes": "Collapse/Expand Selected Nodes",
|
||||
@@ -1148,6 +1118,7 @@
|
||||
"Duplicate Current Workflow": "Duplicate Current Workflow",
|
||||
"Export": "Export",
|
||||
"Export (API)": "Export (API)",
|
||||
"Give Feedback": "Give Feedback",
|
||||
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
|
||||
"Exit Subgraph": "Exit Subgraph",
|
||||
"Fit Group To Contents": "Fit Group To Contents",
|
||||
@@ -1166,11 +1137,10 @@
|
||||
"Custom Nodes Manager": "Custom Nodes Manager",
|
||||
"Custom Nodes (Legacy)": "Custom Nodes (Legacy)",
|
||||
"Manager Menu (Legacy)": "Manager Menu (Legacy)",
|
||||
"Install Missing": "Install Missing",
|
||||
"Install Missing Custom Nodes": "Install Missing Custom Nodes",
|
||||
"Check for Custom Node Updates": "Check for Custom Node Updates",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
|
||||
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
|
||||
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
|
||||
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
|
||||
"Unload Models": "Unload Models",
|
||||
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
|
||||
@@ -1193,22 +1163,31 @@
|
||||
"Undo": "Undo",
|
||||
"Open Sign In Dialog": "Open Sign In Dialog",
|
||||
"Sign Out": "Sign Out",
|
||||
"Experimental: Enable Vue Nodes": "Experimental: Enable Vue Nodes",
|
||||
"Close Current Workflow": "Close Current Workflow",
|
||||
"Next Opened Workflow": "Next Opened Workflow",
|
||||
"Previous Opened Workflow": "Previous Opened Workflow",
|
||||
"Toggle Search Box": "Toggle Search Box",
|
||||
"Bottom Panel": "Bottom Panel",
|
||||
"Toggle Bottom Panel": "Toggle Bottom Panel",
|
||||
"Show Keybindings Dialog": "Show Keybindings Dialog",
|
||||
"Toggle Terminal Bottom Panel": "Toggle Terminal Bottom Panel",
|
||||
"Toggle Logs Bottom Panel": "Toggle Logs Bottom Panel",
|
||||
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
|
||||
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
|
||||
"Toggle Focus Mode": "Toggle Focus Mode",
|
||||
"Focus Mode": "Focus Mode",
|
||||
"Model Library": "Model Library",
|
||||
"Node Library": "Node Library",
|
||||
"Queue Panel": "Queue Panel",
|
||||
"Workflows": "Workflows"
|
||||
"Workflows": "Workflows",
|
||||
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
|
||||
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
|
||||
"Toggle Queue Sidebar": "Toggle Queue Sidebar",
|
||||
"Toggle Workflows Sidebar": "Toggle Workflows Sidebar",
|
||||
"sideToolbar_modelLibrary": "sideToolbar.modelLibrary",
|
||||
"sideToolbar_nodeLibrary": "sideToolbar.nodeLibrary",
|
||||
"sideToolbar_queue": "sideToolbar.queue",
|
||||
"sideToolbar_workflows": "sideToolbar.workflows"
|
||||
},
|
||||
"desktopMenu": {
|
||||
"reinstall": "Reinstall",
|
||||
@@ -1269,9 +1248,7 @@
|
||||
"API Nodes": "API Nodes",
|
||||
"Notification Preferences": "Notification Preferences",
|
||||
"3DViewer": "3DViewer",
|
||||
"Vue Nodes": "Vue Nodes",
|
||||
"Assets": "Assets",
|
||||
"Canvas Navigation": "Canvas Navigation"
|
||||
"Vue Nodes": "Vue Nodes"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
"listen": {
|
||||
@@ -1408,49 +1385,42 @@
|
||||
"noise": "noise",
|
||||
"sampling": "sampling",
|
||||
"schedulers": "schedulers",
|
||||
"audio": "audio",
|
||||
"conditioning": "conditioning",
|
||||
"loaders": "loaders",
|
||||
"guiders": "guiders",
|
||||
"api node": "api node",
|
||||
"video": "video",
|
||||
"ByteDance": "ByteDance",
|
||||
"image": "image",
|
||||
"preprocessors": "preprocessors",
|
||||
"utils": "utils",
|
||||
"string": "string",
|
||||
"advanced": "advanced",
|
||||
"guidance": "guidance",
|
||||
"loaders": "loaders",
|
||||
"model_merging": "model_merging",
|
||||
"model_patches": "model_patches",
|
||||
"chroma_radiance": "chroma_radiance",
|
||||
"attention_experiments": "attention_experiments",
|
||||
"conditioning": "conditioning",
|
||||
"flux": "flux",
|
||||
"hooks": "hooks",
|
||||
"combine": "combine",
|
||||
"cond single": "cond single",
|
||||
"context": "context",
|
||||
"controlnet": "controlnet",
|
||||
"inpaint": "inpaint",
|
||||
"scheduling": "scheduling",
|
||||
"create": "create",
|
||||
"video": "video",
|
||||
"mask": "mask",
|
||||
"deprecated": "deprecated",
|
||||
"debug": "debug",
|
||||
"model": "model",
|
||||
"latent": "latent",
|
||||
"audio": "audio",
|
||||
"3d": "3d",
|
||||
"ltxv": "ltxv",
|
||||
"sd3": "sd3",
|
||||
"sigmas": "sigmas",
|
||||
"api node": "api node",
|
||||
"BFL": "BFL",
|
||||
"model_patches": "model_patches",
|
||||
"unet": "unet",
|
||||
"Gemini": "Gemini",
|
||||
"text": "text",
|
||||
"gligen": "gligen",
|
||||
"video_models": "video_models",
|
||||
"sd": "sd",
|
||||
"Ideogram": "Ideogram",
|
||||
"v1": "v1",
|
||||
"v2": "v2",
|
||||
"v3": "v3",
|
||||
"postprocessing": "postprocessing",
|
||||
"transform": "transform",
|
||||
"batch": "batch",
|
||||
@@ -1460,44 +1430,34 @@
|
||||
"Kling": "Kling",
|
||||
"samplers": "samplers",
|
||||
"operations": "operations",
|
||||
"training": "training",
|
||||
"lotus": "lotus",
|
||||
"Luma": "Luma",
|
||||
"MiniMax": "MiniMax",
|
||||
"debug": "debug",
|
||||
"model": "model",
|
||||
"model_specific": "model_specific",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"cond pair": "cond pair",
|
||||
"photomaker": "photomaker",
|
||||
"Pika": "Pika",
|
||||
"PixVerse": "PixVerse",
|
||||
"utils": "utils",
|
||||
"primitive": "primitive",
|
||||
"qwen": "qwen",
|
||||
"Recraft": "Recraft",
|
||||
"edit_models": "edit_models",
|
||||
"Rodin": "Rodin",
|
||||
"Runway": "Runway",
|
||||
"animation": "animation",
|
||||
"api": "api",
|
||||
"save": "save",
|
||||
"upscale_diffusion": "upscale_diffusion",
|
||||
"clip": "clip",
|
||||
"Stability AI": "Stability AI",
|
||||
"stable_cascade": "stable_cascade",
|
||||
"3d_models": "3d_models",
|
||||
"style_model": "style_model",
|
||||
"Tripo": "Tripo",
|
||||
"Veo": "Veo",
|
||||
"Vidu": "Vidu",
|
||||
"camera": "camera",
|
||||
"Wan": "Wan"
|
||||
"sd": "sd",
|
||||
"Veo": "Veo"
|
||||
},
|
||||
"dataTypes": {
|
||||
"*": "*",
|
||||
"AUDIO": "AUDIO",
|
||||
"AUDIO_ENCODER": "AUDIO_ENCODER",
|
||||
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
|
||||
"AUDIO_RECORD": "AUDIO_RECORD",
|
||||
"BOOLEAN": "BOOLEAN",
|
||||
"CAMERA_CONTROL": "CAMERA_CONTROL",
|
||||
"CLIP": "CLIP",
|
||||
@@ -1508,7 +1468,6 @@
|
||||
"CONTROL_NET": "CONTROL_NET",
|
||||
"FLOAT": "FLOAT",
|
||||
"FLOATS": "FLOATS",
|
||||
"GEMINI_INPUT_FILES": "GEMINI_INPUT_FILES",
|
||||
"GLIGEN": "GLIGEN",
|
||||
"GUIDER": "GUIDER",
|
||||
"HOOK_KEYFRAMES": "HOOK_KEYFRAMES",
|
||||
@@ -1520,25 +1479,17 @@
|
||||
"LOAD_3D": "LOAD_3D",
|
||||
"LOAD_3D_ANIMATION": "LOAD_3D_ANIMATION",
|
||||
"LOAD3D_CAMERA": "LOAD3D_CAMERA",
|
||||
"LORA_MODEL": "LORA_MODEL",
|
||||
"LOSS_MAP": "LOSS_MAP",
|
||||
"LUMA_CONCEPTS": "LUMA_CONCEPTS",
|
||||
"LUMA_REF": "LUMA_REF",
|
||||
"MASK": "MASK",
|
||||
"MESH": "MESH",
|
||||
"MODEL": "MODEL",
|
||||
"MODEL_PATCH": "MODEL_PATCH",
|
||||
"MODEL_TASK_ID": "MODEL_TASK_ID",
|
||||
"NOISE": "NOISE",
|
||||
"OPENAI_CHAT_CONFIG": "OPENAI_CHAT_CONFIG",
|
||||
"OPENAI_INPUT_FILES": "OPENAI_INPUT_FILES",
|
||||
"PHOTOMAKER": "PHOTOMAKER",
|
||||
"PIXVERSE_TEMPLATE": "PIXVERSE_TEMPLATE",
|
||||
"RECRAFT_COLOR": "RECRAFT_COLOR",
|
||||
"RECRAFT_CONTROLS": "RECRAFT_CONTROLS",
|
||||
"RECRAFT_V3_STYLE": "RECRAFT_V3_STYLE",
|
||||
"RETARGET_TASK_ID": "RETARGET_TASK_ID",
|
||||
"RIG_TASK_ID": "RIG_TASK_ID",
|
||||
"SAMPLER": "SAMPLER",
|
||||
"SIGMAS": "SIGMAS",
|
||||
"STRING": "STRING",
|
||||
@@ -1549,7 +1500,6 @@
|
||||
"VAE": "VAE",
|
||||
"VIDEO": "VIDEO",
|
||||
"VOXEL": "VOXEL",
|
||||
"WAN_CAMERA_EMBEDDING": "WAN_CAMERA_EMBEDDING",
|
||||
"WEBCAM": "WEBCAM"
|
||||
},
|
||||
"maintenance": {
|
||||
@@ -1912,33 +1862,5 @@
|
||||
"showGroups": "Show Frames/Groups",
|
||||
"renderBypassState": "Render Bypass State",
|
||||
"renderErrorState": "Render Error State"
|
||||
},
|
||||
"assetBrowser": {
|
||||
"assets": "Assets",
|
||||
"browseAssets": "Browse Assets",
|
||||
"noAssetsFound": "No assets found",
|
||||
"tryAdjustingFilters": "Try adjusting your search or filters",
|
||||
"loadingModels": "Loading {type}...",
|
||||
"connectionError": "Please check your connection and try again",
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"searchAssetsPlaceholder": "Search assets...",
|
||||
"allModels": "All Models",
|
||||
"unknown": "Unknown",
|
||||
"fileFormats": "File formats",
|
||||
"baseModels": "Base models",
|
||||
"sortBy": "Sort by",
|
||||
"sortAZ": "A-Z",
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular"
|
||||
},
|
||||
"desktopDialogs": {
|
||||
"": {
|
||||
"title": "Invalid Dialog",
|
||||
"message": "Invalid dialog ID was provided.",
|
||||
"buttons": {
|
||||
"Close": "Close"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,34 +25,15 @@
|
||||
"custom": "custom"
|
||||
}
|
||||
},
|
||||
"Comfy_Assets_UseAssetAPI": {
|
||||
"name": "Use Asset API for model library",
|
||||
"tooltip": "Use new Asset API for model browsing"
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "Canvas background image",
|
||||
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
|
||||
},
|
||||
"Comfy_Canvas_LeftMouseClickBehavior": {
|
||||
"name": "Left Mouse Click Behavior",
|
||||
"options": {
|
||||
"Panning": "Panning",
|
||||
"Select": "Select"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_MouseWheelScroll": {
|
||||
"name": "Mouse Wheel Scroll",
|
||||
"options": {
|
||||
"Panning": "Panning",
|
||||
"Zoom in/out": "Zoom in/out"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_NavigationMode": {
|
||||
"name": "Navigation Mode",
|
||||
"name": "Canvas Navigation Mode",
|
||||
"options": {
|
||||
"Standard (New)": "Standard (New)",
|
||||
"Drag Navigation": "Drag Navigation",
|
||||
"Custom": "Custom"
|
||||
"Drag Navigation": "Drag Navigation"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_SelectionToolbox": {
|
||||
@@ -362,6 +343,14 @@
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "Validate workflows"
|
||||
},
|
||||
"Comfy_VueNodes_Enabled": {
|
||||
"name": "Enable Vue node rendering",
|
||||
"tooltip": "Render nodes as Vue components instead of canvas elements. Experimental feature."
|
||||
},
|
||||
"Comfy_VueNodes_Widgets": {
|
||||
"name": "Enable Vue widgets",
|
||||
"tooltip": "Render widgets as Vue components within Vue nodes."
|
||||
},
|
||||
"Comfy_WidgetControlMode": {
|
||||
"name": "Widget control mode",
|
||||
"tooltip": "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",
|
||||
@@ -399,9 +388,6 @@
|
||||
"Comfy_Workflow_SortNodeIdOnSave": {
|
||||
"name": "Sort node IDs when saving workflow"
|
||||
},
|
||||
"Comfy_Workflow_WarnBlueprintOverwrite": {
|
||||
"name": "Require confirmation to overwrite an existing subgraph blueprint"
|
||||
},
|
||||
"Comfy_Workflow_WorkflowTabsPosition": {
|
||||
"name": "Opened workflows position",
|
||||
"options": {
|
||||
@@ -410,14 +396,14 @@
|
||||
"Topbar (2nd-row)": "Topbar (2nd-row)"
|
||||
}
|
||||
},
|
||||
"LiteGraph_Canvas_MaximumFps": {
|
||||
"name": "Maximum FPS",
|
||||
"tooltip": "The maximum frames per second that the canvas is allowed to render. Caps GPU usage at the cost of smoothness. If 0, the screen refresh rate is used. Default: 0"
|
||||
},
|
||||
"LiteGraph_Canvas_MinFontSizeForLOD": {
|
||||
"name": "Zoom Node Level of Detail - font size threshold",
|
||||
"tooltip": "Controls when the nodes switch to low quality LOD rendering. Uses font size in pixels to determine when to switch. Set to 0 to disable. Values 1-24 set the minimum font size threshold for LOD - higher values (24px) = switch nodes to simplified rendering sooner when zooming out, lower values (1px) = maintain full node quality longer."
|
||||
},
|
||||
"LiteGraph_Canvas_MaximumFps": {
|
||||
"name": "Maximum FPS",
|
||||
"tooltip": "The maximum frames per second that the canvas is allowed to render. Caps GPU usage at the cost of smoothness. If 0, the screen refresh rate is used. Default: 0"
|
||||
},
|
||||
"LiteGraph_ContextMenu_Scaling": {
|
||||
"name": "Scale node combo widget menus (lists) when zoomed in"
|
||||
},
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
"Comfy_ExportWorkflowAPI": {
|
||||
"label": "Exportar flujo de trabajo (formato API)"
|
||||
},
|
||||
"Comfy_Feedback": {
|
||||
"label": "Dar retroalimentación"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir selección en subgrafo"
|
||||
},
|
||||
@@ -306,4 +309,4 @@
|
||||
"label": "Alternar Barra Lateral de Flujos de Trabajo",
|
||||
"tooltip": "Flujos de Trabajo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,9 +324,11 @@
|
||||
"feedback": "Retroalimentación",
|
||||
"filter": "Filtrar",
|
||||
"findIssues": "Encontrar problemas",
|
||||
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
|
||||
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
|
||||
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
|
||||
"goToNode": "Ir al nodo",
|
||||
"help": "Ayuda",
|
||||
"icon": "Icono",
|
||||
"imageFailedToLoad": "Falló la carga de la imagen",
|
||||
"imageUrl": "URL de la imagen",
|
||||
@@ -731,7 +733,9 @@
|
||||
"Bottom Panel": "Panel inferior",
|
||||
"Browse Templates": "Explorar plantillas",
|
||||
"Bypass/Unbypass Selected Nodes": "Evitar/No evitar nodos seleccionados",
|
||||
"Canvas Toggle Link Visibility": "Alternar visibilidad de enlace en lienzo",
|
||||
"Canvas Toggle Lock": "Alternar bloqueo en lienzo",
|
||||
"Canvas Toggle Minimap": "Lienzo: Alternar minimapa",
|
||||
"Check for Custom Node Updates": "Buscar actualizaciones de nodos personalizados",
|
||||
"Check for Updates": "Buscar actualizaciones",
|
||||
"Clear Pending Tasks": "Borrar tareas pendientes",
|
||||
@@ -756,6 +760,8 @@
|
||||
"Export": "Exportar",
|
||||
"Export (API)": "Exportar (API)",
|
||||
"Fit Group To Contents": "Ajustar grupo a contenidos",
|
||||
"Fit view to selected nodes": "Ajustar vista a los nodos seleccionados",
|
||||
"Give Feedback": "Dar retroalimentación",
|
||||
"Group Selected Nodes": "Agrupar nodos seleccionados",
|
||||
"Help": "Ayuda",
|
||||
"Increase Brush Size in MaskEditor": "Aumentar tamaño del pincel en MaskEditor",
|
||||
@@ -802,11 +808,18 @@
|
||||
"Show Settings Dialog": "Mostrar diálogo de configuración",
|
||||
"Sign Out": "Cerrar sesión",
|
||||
"Toggle Essential Bottom Panel": "Alternar panel inferior esencial",
|
||||
"Toggle Bottom Panel": "Alternar panel inferior",
|
||||
"Toggle Focus Mode": "Alternar modo de enfoque",
|
||||
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
|
||||
"Toggle Model Library Sidebar": "Alternar barra lateral de la biblioteca de modelos",
|
||||
"Toggle Node Library Sidebar": "Alternar barra lateral de la biblioteca de nodos",
|
||||
"Toggle Queue Sidebar": "Alternar barra lateral de la cola",
|
||||
"Toggle Search Box": "Alternar caja de búsqueda",
|
||||
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
|
||||
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
|
||||
"Toggle View Controls Bottom Panel": "Alternar panel inferior de controles de vista",
|
||||
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
|
||||
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
|
||||
"Undo": "Deshacer",
|
||||
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
|
||||
@@ -815,6 +828,7 @@
|
||||
"Unlock Canvas": "Desbloquear lienzo",
|
||||
"Unpack the selected Subgraph": "Desempaquetar el Subgrafo seleccionado",
|
||||
"View": "Ver",
|
||||
"Workflow": "Flujo de trabajo",
|
||||
"Workflows": "Flujos de trabajo",
|
||||
"Zoom In": "Acercar",
|
||||
"Zoom Out": "Alejar",
|
||||
@@ -825,7 +839,11 @@
|
||||
"renderBypassState": "Mostrar estado de omisión",
|
||||
"renderErrorState": "Mostrar estado de error",
|
||||
"showGroups": "Mostrar marcos/grupos",
|
||||
"showLinks": "Mostrar enlaces"
|
||||
"showLinks": "Mostrar enlaces",
|
||||
"sideToolbar_modelLibrary": "sideToolbar.bibliotecaDeModelos",
|
||||
"sideToolbar_nodeLibrary": "sideToolbar.bibliotecaDeNodos",
|
||||
"sideToolbar_queue": "sideToolbar.cola",
|
||||
"sideToolbar_workflows": "sideToolbar.flujosDeTrabajo"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "No mostrar esto de nuevo",
|
||||
@@ -902,6 +920,9 @@
|
||||
"upscale_diffusion": "difusión_de_escalado",
|
||||
"upscaling": "escalado",
|
||||
"utils": "utilidades",
|
||||
"v1": "v1",
|
||||
"v2": "v2",
|
||||
"v3": "v3",
|
||||
"video": "video",
|
||||
"video_models": "modelos_de_video"
|
||||
},
|
||||
|
||||
@@ -7366,6 +7366,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveSVG": {
|
||||
"description": "Guardar archivos SVG en el disco.",
|
||||
"display_name": "Guardar SVG",
|
||||
"inputs": {
|
||||
"filename_prefix": {
|
||||
"name": "prefijo_de_archivo",
|
||||
"tooltip": "El prefijo para el archivo a guardar. Esto puede incluir información de formato como %date:yyyy-MM-dd% o %Empty Latent Image.width% para incluir valores de los nodos."
|
||||
},
|
||||
"svg": {
|
||||
"name": "svg"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveVideo": {
|
||||
"description": "Guarda las imágenes de entrada en tu directorio de salida de ComfyUI.",
|
||||
"display_name": "Guardar video",
|
||||
@@ -8644,4 +8657,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,14 @@
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "Validar flujos de trabajo"
|
||||
},
|
||||
"Comfy_VueNodes_Enabled": {
|
||||
"name": "Habilitar renderizado de nodos Vue",
|
||||
"tooltip": "Renderiza los nodos como componentes Vue en lugar de elementos canvas. Función experimental."
|
||||
},
|
||||
"Comfy_VueNodes_Widgets": {
|
||||
"name": "Habilitar widgets de Vue",
|
||||
"tooltip": "Renderiza los widgets como componentes de Vue dentro de los nodos de Vue."
|
||||
},
|
||||
"Comfy_WidgetControlMode": {
|
||||
"name": "Modo de control del widget",
|
||||
"options": {
|
||||
@@ -388,6 +396,10 @@
|
||||
"Topbar (2nd-row)": "Barra superior (2ª fila)"
|
||||
}
|
||||
},
|
||||
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
|
||||
"name": "Umbral de renderizado de baja calidad al hacer zoom",
|
||||
"tooltip": "Renderiza formas de baja calidad cuando se aleja"
|
||||
},
|
||||
"LiteGraph_Canvas_MaximumFps": {
|
||||
"name": "FPS máximo",
|
||||
"tooltip": "La cantidad máxima de cuadros por segundo que se permite renderizar en el lienzo. Limita el uso de la GPU a costa de la suavidad. Si es 0, se utiliza la tasa de refresco de la pantalla. Predeterminado: 0"
|
||||
@@ -409,4 +421,4 @@
|
||||
"pysssss_SnapToGrid": {
|
||||
"name": "Siempre ajustar a la cuadrícula"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,9 @@
|
||||
"Comfy_ExportWorkflowAPI": {
|
||||
"label": "Exporter le flux de travail (format API)"
|
||||
},
|
||||
"Comfy_Feedback": {
|
||||
"label": "Retour d'information"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir la sélection en sous-graphe"
|
||||
},
|
||||
@@ -306,4 +309,4 @@
|
||||
"label": "Basculer la barre latérale des flux de travail",
|
||||
"tooltip": "Flux de travail"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,9 +324,11 @@
|
||||
"feedback": "Commentaires",
|
||||
"filter": "Filtrer",
|
||||
"findIssues": "Trouver des problèmes",
|
||||
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
|
||||
"frontendNewer": "La version du frontend {frontendVersion} peut ne pas être compatible avec la version du backend {backendVersion}.",
|
||||
"frontendOutdated": "La version du frontend {frontendVersion} est obsolète. Le backend requiert la version {requiredVersion} ou supérieure.",
|
||||
"goToNode": "Aller au nœud",
|
||||
"help": "Aide",
|
||||
"icon": "Icône",
|
||||
"imageFailedToLoad": "Échec du chargement de l'image",
|
||||
"imageUrl": "URL de l'image",
|
||||
@@ -731,7 +733,9 @@
|
||||
"Bottom Panel": "Panneau inférieur",
|
||||
"Browse Templates": "Parcourir les modèles",
|
||||
"Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nœuds sélectionnés",
|
||||
"Canvas Toggle Link Visibility": "Basculer la visibilité du lien de la toile",
|
||||
"Canvas Toggle Lock": "Basculer le verrouillage de la toile",
|
||||
"Canvas Toggle Minimap": "Basculer la mini-carte du canevas",
|
||||
"Check for Custom Node Updates": "Vérifier les mises à jour des nœuds personnalisés",
|
||||
"Check for Updates": "Vérifier les mises à jour",
|
||||
"Clear Pending Tasks": "Effacer les tâches en attente",
|
||||
@@ -756,6 +760,8 @@
|
||||
"Export": "Exporter",
|
||||
"Export (API)": "Exporter (API)",
|
||||
"Fit Group To Contents": "Ajuster le groupe au contenu",
|
||||
"Fit view to selected nodes": "Ajuster la vue aux nœuds sélectionnés",
|
||||
"Give Feedback": "Donnez votre avis",
|
||||
"Group Selected Nodes": "Grouper les nœuds sélectionnés",
|
||||
"Help": "Aide",
|
||||
"Increase Brush Size in MaskEditor": "Augmenter la taille du pinceau dans MaskEditor",
|
||||
@@ -802,11 +808,18 @@
|
||||
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
|
||||
"Sign Out": "Se déconnecter",
|
||||
"Toggle Essential Bottom Panel": "Basculer le panneau inférieur essentiel",
|
||||
"Toggle Bottom Panel": "Basculer le panneau inférieur",
|
||||
"Toggle Focus Mode": "Basculer le mode focus",
|
||||
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
|
||||
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
|
||||
"Toggle Node Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de nœuds",
|
||||
"Toggle Queue Sidebar": "Afficher/Masquer la barre latérale de la file d’attente",
|
||||
"Toggle Search Box": "Basculer la boîte de recherche",
|
||||
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
|
||||
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
|
||||
"Toggle View Controls Bottom Panel": "Basculer le panneau inférieur des contrôles d’affichage",
|
||||
"Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
|
||||
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
|
||||
"Undo": "Annuler",
|
||||
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
|
||||
@@ -815,6 +828,7 @@
|
||||
"Unlock Canvas": "Déverrouiller le canevas",
|
||||
"Unpack the selected Subgraph": "Décompresser le Subgraph sélectionné",
|
||||
"View": "Afficher",
|
||||
"Workflow": "Flux de travail",
|
||||
"Workflows": "Flux de travail",
|
||||
"Zoom In": "Zoom avant",
|
||||
"Zoom Out": "Zoom arrière",
|
||||
@@ -825,7 +839,12 @@
|
||||
"renderBypassState": "Afficher l'état de contournement",
|
||||
"renderErrorState": "Afficher l'état d'erreur",
|
||||
"showGroups": "Afficher les cadres/groupes",
|
||||
"showLinks": "Afficher les liens"
|
||||
"showLinks": "Afficher les liens",
|
||||
"Zoom Out": "Zoom arrière",
|
||||
"sideToolbar_modelLibrary": "Bibliothèque de modèles",
|
||||
"sideToolbar_nodeLibrary": "Bibliothèque de nœuds",
|
||||
"sideToolbar_queue": "File d'attente",
|
||||
"sideToolbar_workflows": "Flux de travail"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "Ne plus afficher ce message",
|
||||
@@ -902,6 +921,9 @@
|
||||
"upscale_diffusion": "diffusion_de_mise_à_l'échelle",
|
||||
"upscaling": "mise_à_l'échelle",
|
||||
"utils": "utilitaires",
|
||||
"v1": "v1",
|
||||
"v2": "v2",
|
||||
"v3": "v3",
|
||||
"video": "vidéo",
|
||||
"video_models": "modèles_vidéo"
|
||||
},
|
||||
|
||||
@@ -7366,6 +7366,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveSVG": {
|
||||
"description": "Enregistrer les fichiers SVG sur le disque.",
|
||||
"display_name": "Enregistrer SVG",
|
||||
"inputs": {
|
||||
"filename_prefix": {
|
||||
"name": "préfixe_nom_fichier",
|
||||
"tooltip": "Le préfixe pour le fichier à enregistrer. Cela peut inclure des informations de formatage telles que %date:yyyy-MM-dd% ou %Empty Latent Image.width% pour inclure des valeurs provenant des nœuds."
|
||||
},
|
||||
"svg": {
|
||||
"name": "svg"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveVideo": {
|
||||
"description": "Enregistre les images d'entrée dans votre répertoire de sortie ComfyUI.",
|
||||
"display_name": "Enregistrer la vidéo",
|
||||
@@ -8644,4 +8657,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,14 @@
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "Valider les flux de travail"
|
||||
},
|
||||
"Comfy_VueNodes_Enabled": {
|
||||
"name": "Activer le rendu des nœuds Vue",
|
||||
"tooltip": "Rendre les nœuds comme composants Vue au lieu d’éléments canvas. Fonctionnalité expérimentale."
|
||||
},
|
||||
"Comfy_VueNodes_Widgets": {
|
||||
"name": "Activer les widgets Vue",
|
||||
"tooltip": "Rendre les widgets comme composants Vue à l'intérieur des nœuds Vue."
|
||||
},
|
||||
"Comfy_WidgetControlMode": {
|
||||
"name": "Mode de contrôle du widget",
|
||||
"options": {
|
||||
@@ -388,6 +396,10 @@
|
||||
"Topbar (2nd-row)": "Barre supérieure (2ème rangée)"
|
||||
}
|
||||
},
|
||||
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
|
||||
"name": "Seuil de zoom pour le rendu de faible qualité",
|
||||
"tooltip": "Rendre des formes de faible qualité lorsqu'on est dézoomé"
|
||||
},
|
||||
"LiteGraph_Canvas_MaximumFps": {
|
||||
"name": "FPS maximum",
|
||||
"tooltip": "Le nombre maximum d'images par seconde que le canevas est autorisé à rendre. Limite l'utilisation du GPU au détriment de la fluidité. Si 0, le taux de rafraîchissement de l'écran est utilisé. Par défaut : 0"
|
||||
@@ -409,4 +421,4 @@
|
||||
"pysssss_SnapToGrid": {
|
||||
"name": "Toujours aligner sur la grille"
|
||||
}
|
||||
}
|
||||
}
|
||||