feat: Add more Storybook stories for UI components
@@ -67,9 +67,9 @@ This is critical for better file inspection:
|
||||
|
||||
Use git locally for much faster analysis:
|
||||
|
||||
1. Get list of changed files: `git diff --name-only "$BASE_SHA" > changed_files.txt`
|
||||
2. Get the full diff: `git diff "$BASE_SHA" > pr_diff.txt`
|
||||
3. Get detailed file changes with status: `git diff --name-status "$BASE_SHA" > file_changes.txt`
|
||||
1. Get list of changed files: `git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt`
|
||||
2. Get the full diff: `git diff "origin/$BASE_BRANCH" > pr_diff.txt`
|
||||
3. Get detailed file changes with status: `git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt`
|
||||
|
||||
### Step 1.5: Create Analysis Cache
|
||||
|
||||
|
||||
2
.gitattributes
vendored
@@ -13,4 +13,4 @@
|
||||
|
||||
# Generated files
|
||||
src/types/comfyRegistryTypes.ts linguist-generated=true
|
||||
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
|
||||
src/types/generatedManagerTypes.ts linguist-generated=true
|
||||
|
||||
109
.github/workflows/backport.yaml
vendored
@@ -4,25 +4,10 @@ on:
|
||||
pull_request_target:
|
||||
types: [closed, labeled]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to backport'
|
||||
required: true
|
||||
type: string
|
||||
force_rerun:
|
||||
description: 'Force rerun even if backports exist'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
backport:
|
||||
if: >
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'needs-backport')) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-backport')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -30,35 +15,6 @@ jobs:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Validate inputs for manual triggers
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
# Validate PR number format
|
||||
if ! [[ "${{ inputs.pr_number }}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Invalid PR number format. Must be a positive integer."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate PR exists and is merged
|
||||
if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged')
|
||||
if [ "$MERGED" != "true" ]; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate PR has needs-backport label
|
||||
if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label."
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -73,7 +29,7 @@ jobs:
|
||||
id: check-existing
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# Check for existing backport PRs for this PR number
|
||||
EXISTING_BACKPORTS=$(gh pr list --state all --search "backport-${PR_NUMBER}-to" --json title,headRefName,baseRefName | jq -r '.[].headRefName')
|
||||
@@ -83,13 +39,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# For manual triggers with force_rerun, proceed anyway
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_rerun }}" = "true" ]; then
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "::warning::Force rerun requested - existing backports will be updated"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found existing backport PRs:"
|
||||
echo "$EXISTING_BACKPORTS"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
@@ -101,17 +50,8 @@ jobs:
|
||||
run: |
|
||||
# Extract version labels (e.g., "1.24", "1.22")
|
||||
VERSIONS=""
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
# For manual triggers, get labels from the PR
|
||||
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
|
||||
else
|
||||
# For automatic triggers, extract from PR event
|
||||
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
|
||||
LABELS=$(echo "$LABELS" | jq -r '.[].name')
|
||||
fi
|
||||
|
||||
for label in $LABELS; do
|
||||
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
|
||||
for label in $(echo "$LABELS" | jq -r '.[].name'); do
|
||||
# Match version labels like "1.24" (major.minor only)
|
||||
if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
|
||||
# Validate the branch exists before adding to list
|
||||
@@ -135,20 +75,12 @@ jobs:
|
||||
if: steps.check-existing.outputs.skip != 'true'
|
||||
id: backport
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
run: |
|
||||
FAILED=""
|
||||
SUCCESS=""
|
||||
|
||||
# Get PR data for manual triggers
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,mergeCommit)
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
fi
|
||||
|
||||
for version in ${{ steps.versions.outputs.versions }}; do
|
||||
echo "::group::Backporting to core/${version}"
|
||||
@@ -201,18 +133,10 @@ jobs:
|
||||
if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
# Get PR data for manual triggers
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author)
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
else
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
fi
|
||||
|
||||
for backport in ${{ steps.backport.outputs.success }}; do
|
||||
IFS=':' read -r version branch <<< "${backport}"
|
||||
|
||||
@@ -241,16 +165,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit)
|
||||
PR_NUMBER="${{ inputs.pr_number }}"
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
|
||||
else
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
fi
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
IFS=':' read -r version reason conflicts <<< "${failure}"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
5
.gitignore
vendored
@@ -44,7 +44,6 @@ components.d.ts
|
||||
tests-ui/data/*
|
||||
tests-ui/ComfyUI_examples
|
||||
tests-ui/workflows/examples
|
||||
coverage/
|
||||
|
||||
# Browser tests
|
||||
/test-results/
|
||||
@@ -79,8 +78,8 @@ vite.config.mts.timestamp-*.mjs
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# MCP Servers
|
||||
.playwright-mcp/*
|
||||
|
||||
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
|
||||
@@ -9,7 +9,7 @@ module.exports = defineConfig({
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr'],
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
|
||||
@@ -15,32 +15,21 @@ const config: StorybookConfig = {
|
||||
async viteFinal(config) {
|
||||
// Use dynamic import to avoid CJS deprecation warning
|
||||
const { mergeConfig } = await import('vite')
|
||||
const { default: tailwindcss } = await import('@tailwindcss/vite')
|
||||
|
||||
// Filter out any plugins that might generate import maps
|
||||
if (config.plugins) {
|
||||
config.plugins = config.plugins
|
||||
// Type guard: ensure we have valid plugin objects with names
|
||||
.filter(
|
||||
(plugin): plugin is NonNullable<typeof plugin> & { name: string } => {
|
||||
return (
|
||||
plugin !== null &&
|
||||
plugin !== undefined &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
typeof plugin.name === 'string'
|
||||
)
|
||||
}
|
||||
)
|
||||
// Business logic: filter out import-map plugins
|
||||
.filter((plugin) => !plugin.name.includes('import-map'))
|
||||
config.plugins = config.plugins.filter((plugin: any) => {
|
||||
if (plugin && plugin.name && plugin.name.includes('import-map')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return mergeConfig(config, {
|
||||
// Replace plugins entirely to avoid inheritance issues
|
||||
plugins: [
|
||||
// Only include plugins we explicitly need for Storybook
|
||||
tailwindcss(),
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { definePreset } from '@primevue/themes'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import { setup } from '@storybook/vue3'
|
||||
import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import type { Preview } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -9,9 +9,11 @@ import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
import '@/assets/css/style.css'
|
||||
import { i18n } from '@/i18n'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import '../src/assets/css/style.css'
|
||||
import { i18n } from '../src/i18n'
|
||||
import '../src/lib/litegraph/public/css/litegraph.css'
|
||||
import { useWidgetStore } from '../src/stores/widgetStore'
|
||||
import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore'
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
semantic: {
|
||||
@@ -23,11 +25,13 @@ const ComfyUIPreset = definePreset(Aura, {
|
||||
// Setup Vue app for Storybook
|
||||
setup((app) => {
|
||||
app.directive('tooltip', Tooltip)
|
||||
|
||||
// Create Pinia instance
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
|
||||
// Initialize stores
|
||||
useColorPaletteStore(pinia)
|
||||
useWidgetStore(pinia)
|
||||
|
||||
app.use(i18n)
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
@@ -46,8 +50,8 @@ setup((app) => {
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
// Theme and dialog decorator
|
||||
export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
// Dark theme decorator
|
||||
export const withTheme = (Story: any, context: any) => {
|
||||
const theme = context.globals.theme || 'light'
|
||||
|
||||
// Apply theme class to document root
|
||||
@@ -59,9 +63,8 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
document.body.classList.remove('dark-theme')
|
||||
}
|
||||
|
||||
return Story(context.args, context)
|
||||
return Story()
|
||||
}
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
|
||||
70
CODEOWNERS
@@ -1,61 +1,17 @@
|
||||
# Desktop/Electron
|
||||
/src/types/desktop/ @webfiltered
|
||||
/src/constants/desktopDialogs.ts @webfiltered
|
||||
/src/constants/desktopMaintenanceTasks.ts @webfiltered
|
||||
/src/stores/electronDownloadStore.ts @webfiltered
|
||||
/src/extensions/core/electronAdapter.ts @webfiltered
|
||||
/src/views/DesktopDialogView.vue @webfiltered
|
||||
/src/components/install/ @webfiltered
|
||||
/src/components/maintenance/ @webfiltered
|
||||
/vite.electron.config.mts @webfiltered
|
||||
# Admins
|
||||
* @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
# Maintainers
|
||||
*.md @Comfy-Org/comfy_maintainer
|
||||
/tests-ui/ @Comfy-Org/comfy_maintainer
|
||||
/browser_tests/ @Comfy-Org/comfy_maintainer
|
||||
/.env_example @Comfy-Org/comfy_maintainer
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
# Translations (AIGODLIKE team + shinshin86)
|
||||
/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
# Load 3D extension
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88
|
||||
|
||||
# Assets
|
||||
/src/platform/assets/ @arjansingh
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
# Mask Editor extension
|
||||
/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"id":"4412323e-2509-4258-8abc-68ddeea8f9e1","revision":0,"last_node_id":39,"last_link_id":29,"nodes":[{"id":37,"type":"KSampler","pos":[3635.923095703125,870.237548828125],"size":[428,437],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"MODEL","link":null},{"localized_name":"positive","name":"positive","type":"CONDITIONING","link":null},{"localized_name":"negative","name":"negative","type":"CONDITIONING","link":null},{"localized_name":"latent_image","name":"latent_image","type":"LATENT","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"localized_name":"LATENT","name":"LATENT","type":"LATENT","links":null}],"properties":{"Node name for S&R":"KSampler"},"widgets_values":[0,"randomize",20,8,"euler","simple",1]},{"id":38,"type":"VAEDecode","pos":[4164.01611328125,925.5230712890625],"size":[193.25,107],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"samples","name":"samples","type":"LATENT","link":null},{"localized_name":"vae","name":"vae","type":"VAE","link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"VAEDecode"}},{"id":39,"type":"CLIPTextEncode","pos":[3259.289794921875,927.2508544921875],"size":[239.9375,155],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"clip","name":"clip","type":"CLIP","link":null},{"localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":null}],"properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":[""]}],"links":[],"groups":[],"config":{},"extra":{"ds":{"scale":1.1576250000000001,"offset":[-2808.366467322067,-478.34316506594797]}},"version":0.4}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { Page, test as base } from '@playwright/test'
|
||||
|
||||
export class UserSelectPage {
|
||||
constructor(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class SettingDialog {
|
||||
constructor(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
class SidebarTab {
|
||||
constructor(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class Topbar {
|
||||
private readonly menuLocator: Locator
|
||||
|
||||
@@ -12,10 +12,9 @@ export const webSocketFixture = base.extend<{
|
||||
// so we can look it up to trigger messages
|
||||
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
|
||||
window.WebSocket = class extends window.WebSocket {
|
||||
constructor(
|
||||
...rest: ConstructorParameters<typeof window.WebSocket>
|
||||
) {
|
||||
super(...rest)
|
||||
constructor() {
|
||||
// @ts-expect-error
|
||||
super(...arguments)
|
||||
store[this.url] = this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { backupPath } from './utils/backupUtils'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { ReadOnlyRect } from '../../src/lib/litegraph/src/interfaces'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
interface FitToViewOptions {
|
||||
selectionOnly?: boolean
|
||||
zoom?: number
|
||||
padding?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantly fits the canvas view to graph content without waiting for UI animation.
|
||||
*
|
||||
* Lives outside the shared fixture to keep the default ComfyPage interactions user-oriented.
|
||||
*/
|
||||
export async function fitToViewInstant(
|
||||
comfyPage: ComfyPage,
|
||||
options: FitToViewOptions = {}
|
||||
) {
|
||||
const { selectionOnly = false, zoom = 0.75, padding = 10 } = options
|
||||
|
||||
const rectangles = await comfyPage.page.evaluate<
|
||||
ReadOnlyRect[] | null,
|
||||
{ selectionOnly: boolean }
|
||||
>(
|
||||
({ selectionOnly }) => {
|
||||
const app = window['app']
|
||||
if (!app?.canvas) return null
|
||||
|
||||
const canvas = app.canvas
|
||||
const items = (() => {
|
||||
if (selectionOnly && canvas.selectedItems?.size) {
|
||||
return Array.from(canvas.selectedItems)
|
||||
}
|
||||
try {
|
||||
return Array.from(canvas.positionableItems ?? [])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
if (!items.length) return null
|
||||
|
||||
const rects: ReadOnlyRect[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const rect = item?.boundingRect
|
||||
if (!rect) continue
|
||||
|
||||
const x = Number(rect[0])
|
||||
const y = Number(rect[1])
|
||||
const width = Number(rect[2])
|
||||
const height = Number(rect[3])
|
||||
|
||||
rects.push([x, y, width, height] as const)
|
||||
}
|
||||
|
||||
return rects.length ? rects : null
|
||||
},
|
||||
{ selectionOnly }
|
||||
)
|
||||
|
||||
if (!rectangles || rectangles.length === 0) return
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const [x, y, width, height] of rectangles) {
|
||||
minX = Math.min(minX, Number(x))
|
||||
minY = Math.min(minY, Number(y))
|
||||
maxX = Math.max(maxX, Number(x) + Number(width))
|
||||
maxY = Math.max(maxY, Number(y) + Number(height))
|
||||
}
|
||||
|
||||
const hasFiniteBounds =
|
||||
Number.isFinite(minX) &&
|
||||
Number.isFinite(minY) &&
|
||||
Number.isFinite(maxX) &&
|
||||
Number.isFinite(maxY)
|
||||
|
||||
if (!hasFiniteBounds) return
|
||||
|
||||
const bounds: ReadOnlyRect = [
|
||||
minX - padding,
|
||||
minY - padding,
|
||||
maxX - minX + 2 * padding,
|
||||
maxY - minY + 2 * padding
|
||||
]
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds, zoom }) => {
|
||||
const app = window['app']
|
||||
if (!app?.canvas) return
|
||||
|
||||
const canvas = app.canvas
|
||||
canvas.ds.fitToBounds(bounds, { zoom })
|
||||
canvas.setDirty(true, true)
|
||||
},
|
||||
{ bounds, zoom }
|
||||
)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ManageGroupNode {
|
||||
footer: Locator
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
import path from 'path'
|
||||
|
||||
import type {
|
||||
import {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '../../src/platform/workflow/templates/types/template'
|
||||
|
||||
@@ -29,9 +29,9 @@ test.describe('Actionbar', () => {
|
||||
|
||||
// Intercept the prompt queue endpoint
|
||||
let promptNumber = 0
|
||||
await comfyPage.page.route('**/api/prompt', async (route, req) => {
|
||||
comfyPage.page.route('**/api/prompt', async (route, req) => {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
await route.fulfill({
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
prompt_id: promptNumber,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
ComfyPage,
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { Page, expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { Locator, expect } from '@playwright/test'
|
||||
|
||||
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { SettingParams } from '../../src/platform/settings/types'
|
||||
import { SettingParams } from '../../src/platform/settings/types'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Topbar commands', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.describe('Group Node', () => {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Position } from '@vueuse/core'
|
||||
import { Locator, expect } from '@playwright/test'
|
||||
import { Position } from '@vueuse/core'
|
||||
|
||||
import {
|
||||
type ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
testComfySnapToGridGridSize
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
@@ -1013,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 |
@@ -1,7 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Remote COMBO Widget', () => {
|
||||
const mockOptions = ['d', 'c', 'b', 'a']
|
||||
|
||||
@@ -160,9 +160,7 @@ test.describe.skip('Queue sidebar', () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nextFrame()
|
||||
await expect(
|
||||
comfyPage.menu.queueTab.getGalleryImage(firstImage)
|
||||
).toBeVisible()
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
|
||||
})
|
||||
|
||||
test('maintains active gallery item when new tasks are added', async ({
|
||||
@@ -176,9 +174,7 @@ test.describe.skip('Queue sidebar', () => {
|
||||
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
|
||||
await newTask.waitFor({ state: 'visible' })
|
||||
// The active gallery item should still be the initial image
|
||||
await expect(
|
||||
comfyPage.menu.queueTab.getGalleryImage(firstImage)
|
||||
).toBeVisible()
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Gallery navigation', () => {
|
||||
@@ -200,9 +196,7 @@ test.describe.skip('Queue sidebar', () => {
|
||||
delay: 256
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(
|
||||
comfyPage.menu.queueTab.getGalleryImage(end)
|
||||
).toBeVisible()
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { Page, expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { SystemStats } from '../../src/schemas/apiSchema'
|
||||
import { SystemStats } from '../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Version Mismatch Warnings', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('NodeHeader', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled')
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../../helpers/fitToView'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('Slot bounding box not available')
|
||||
return {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Link Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
})
|
||||
|
||||
test('should show a link dragging out from a slot when dragging on a slot', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
|
||||
const samplerNode = samplerNodes[0]
|
||||
const outputSlot = await samplerNode.getOutput(0)
|
||||
await outputSlot.removeLinks()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const slotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`)
|
||||
await expect(slotLocator).toBeVisible()
|
||||
|
||||
const start = await getCenter(slotLocator)
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas bounding box not available')
|
||||
|
||||
// Arbitrary value
|
||||
const dragTarget = {
|
||||
x: start.x + 180,
|
||||
y: start.y - 140
|
||||
}
|
||||
|
||||
await comfyMouse.move(start)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
try {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-dragging-link.png'
|
||||
)
|
||||
} finally {
|
||||
await comfyMouse.drop()
|
||||
}
|
||||
})
|
||||
|
||||
test('should create a link when dropping on a compatible slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
|
||||
expect(vaeNodes.length).toBeGreaterThan(0)
|
||||
const vaeNode = vaeNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
expect(await vaeInput.getLinkCount()).toBe(1)
|
||||
|
||||
const linkDetails = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return null
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return null
|
||||
|
||||
const linkId = source.outputs[0]?.links?.[0]
|
||||
if (linkId == null) return null
|
||||
|
||||
const link = graph.links[linkId]
|
||||
if (!link) return null
|
||||
|
||||
return {
|
||||
originId: link.origin_id,
|
||||
originSlot: link.origin_slot,
|
||||
targetId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
}
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails).toMatchObject({
|
||||
originId: samplerNode.id,
|
||||
originSlot: 0,
|
||||
targetId: vaeNode.id,
|
||||
targetSlot: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('should not create a link when slot types are incompatible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(clipNodes.length).toBeGreaterThan(0)
|
||||
const clipNode = clipNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const clipInput = await clipNode.getInput(0)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(clipNode.id), 0, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await clipInput.getLinkCount()).toBe(0)
|
||||
|
||||
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return 0
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return 0
|
||||
|
||||
return source.outputs[0]?.links?.length ?? 0
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(graphLinkCount).toBe(0)
|
||||
})
|
||||
|
||||
test('should not create a link when dropping onto a slot on the same node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const samplerInput = await samplerNode.getInput(3)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await samplerInput.getLinkCount()).toBe(0)
|
||||
|
||||
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return 0
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return 0
|
||||
|
||||
return source.outputs[0]?.links?.length ?? 0
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(graphLinkCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 53 KiB |
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
const modifiers = [
|
||||
{ key: 'Control', name: 'ctrl' },
|
||||
{ key: 'Shift', name: 'shift' }
|
||||
] as const
|
||||
|
||||
for (const { key: modifier, name } of modifiers) {
|
||||
test(`should allow selecting multiple nodes with ${name}+click`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Empty Latent Image').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.page.getByText('KSampler').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
|
||||
})
|
||||
|
||||
test(`should allow de-selecting nodes with ${name}+click`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Load Checkpoint').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const BYPASS_HOTKEY = 'Control+b'
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
|
||||
test.describe('Vue Node Bypass', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
await checkpointNode.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
const ksamplerNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'KSampler'
|
||||
})
|
||||
|
||||
await checkpointNode.getByText('Load Checkpoint').click()
|
||||
await ksamplerNode.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path'
|
||||
import type { Plugin } from 'vite'
|
||||
import { Plugin } from 'vite'
|
||||
|
||||
interface ShimResult {
|
||||
code: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import glob from 'fast-glob'
|
||||
import fs from 'fs-extra'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite'
|
||||
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
|
||||
|
||||
interface ImportMapSource {
|
||||
name: string
|
||||
|
||||
@@ -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,49 +24,35 @@ export default defineConfig([
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['./**/*.{ts,mts}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
},
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
projectService: true,
|
||||
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: {
|
||||
@@ -75,29 +60,13 @@ export default defineConfig([
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/prefer-as-const': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'@typescript-eslint/no-import-type-side-effects': 'error',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'always'
|
||||
}
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'vue/no-restricted-class': ['error', '/^dark:/'],
|
||||
'vue/multi-word-component-names': 'off', // TODO: fix
|
||||
'vue/no-template-shadow': 'off', // TODO: fix
|
||||
/* Toggle on to do additional until we can clean up existing violations.
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/no-unused-properties': 'error',
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/no-useless-v-bind': 'error',
|
||||
// */
|
||||
'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',
|
||||
@@ -167,13 +136,5 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['tests-ui/**/*'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ disallowTypeAnnotations: false }
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
...storybook.configs['flat/recommended']
|
||||
]
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="stylesheet" type="text/css" href="user.css" />
|
||||
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
|
||||
|
||||
<!-- Fullscreen mode on mobile browsers -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<!-- Fullscreen mode on iOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<!-- Status bar style (eg. black or transparent) -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
|
||||
@@ -22,12 +22,10 @@ const config: KnipConfig = {
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated manager types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'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(' ')}`
|
||||
]
|
||||
}
|
||||
|
||||
32
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.28.0",
|
||||
"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",
|
||||
@@ -27,8 +27,6 @@
|
||||
"preview": "nx preview",
|
||||
"lint": "eslint src --cache",
|
||||
"lint:fix": "eslint src --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:no-cache": "eslint src",
|
||||
"lint:fix:no-cache": "eslint src --fix",
|
||||
"knip": "knip --cache",
|
||||
@@ -40,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",
|
||||
@@ -66,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",
|
||||
@@ -81,24 +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-component-type-helpers": "^3.0.7",
|
||||
"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"
|
||||
},
|
||||
|
||||
1051
pnpm-lock.yaml
generated
@@ -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,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 {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Cross-browser async utilities for scheduling tasks during browser idle time
|
||||
* with proper fallbacks for browsers that don't support requestIdleCallback.
|
||||
*
|
||||
* Implementation based on:
|
||||
* https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts
|
||||
*/
|
||||
|
||||
interface IdleDeadline {
|
||||
didTimeout: boolean
|
||||
timeRemaining(): number
|
||||
}
|
||||
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation function that handles the actual scheduling logic.
|
||||
* Uses feature detection to determine whether to use native requestIdleCallback
|
||||
* or fall back to setTimeout-based implementation.
|
||||
*/
|
||||
let _runWhenIdle: (
|
||||
targetWindow: any,
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
/**
|
||||
* Execute the callback during the next browser idle period.
|
||||
* Falls back to setTimeout-based scheduling in browsers without native support.
|
||||
*/
|
||||
export let runWhenGlobalIdle: (
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: any = globalThis
|
||||
|
||||
if (
|
||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||
typeof safeGlobal.cancelIdleCallback !== 'function'
|
||||
) {
|
||||
// Fallback implementation for browsers without native support (e.g., Safari)
|
||||
_runWhenIdle = (_targetWindow, runner, _timeout?) => {
|
||||
setTimeout(() => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate IdleDeadline - give 15ms window (one frame at ~64fps)
|
||||
const end = Date.now() + 15
|
||||
const deadline: IdleDeadline = {
|
||||
didTimeout: true,
|
||||
timeRemaining() {
|
||||
return Math.max(0, end - Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
runner(Object.freeze(deadline))
|
||||
})
|
||||
|
||||
let disposed = false
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Native requestIdleCallback implementation
|
||||
_runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => {
|
||||
const handle: number = targetWindow.requestIdleCallback(
|
||||
runner,
|
||||
typeof timeout === 'number' ? { timeout } : undefined
|
||||
)
|
||||
|
||||
let disposed = false
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
targetWindow.cancelIdleCallback(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runWhenGlobalIdle = (runner, timeout) =>
|
||||
_runWhenIdle(globalThis, runner, timeout)
|
||||
})()
|
||||
@@ -21,8 +21,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, watchEffect } from 'vue'
|
||||
import { CSSProperties, computed, watchEffect } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
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) {
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import Panel from 'primevue/panel'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
import { useElementHover, useEventListener } from '@vueuse/core'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import Button from 'primevue/button'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Ref, computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
@@ -47,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
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { IDisposable } from '@xterm/xterm'
|
||||
import { Ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
import { until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Ref, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
|
||||
import { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
|
||||
@@ -47,8 +47,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import type { MenuState } from 'primevue/menu'
|
||||
import Menu from 'primevue/menu'
|
||||
import Menu, { MenuState } from 'primevue/menu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
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"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
|
||||
import type { CustomExtension, VueExtension } from '@/types/extensionTypes'
|
||||
import { CustomExtension, VueExtension } from '@/types/extensionTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
extension: VueExtension | CustomExtension
|
||||
|
||||
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)"
|
||||
/>
|
||||
@@ -44,7 +44,7 @@ import FormRadioGroup from '@/components/common/FormRadioGroup.vue'
|
||||
import InputKnob from '@/components/common/InputKnob.vue'
|
||||
import InputSlider from '@/components/common/InputSlider.vue'
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import type { FormItem } from '@/platform/settings/types'
|
||||
import { FormItem } from '@/platform/settings/types'
|
||||
|
||||
const formValue = defineModel<any>('formValue')
|
||||
const props = defineProps<{
|
||||
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<img
|
||||
v-if="cachedSrc"
|
||||
v-show="isImageLoaded"
|
||||
ref="imageRef"
|
||||
:src="cachedSrc"
|
||||
:alt="alt"
|
||||
@@ -77,8 +77,8 @@ const shouldLoad = computed(() => isIntersecting.value)
|
||||
|
||||
watch(
|
||||
shouldLoad,
|
||||
async (shouldLoadVal) => {
|
||||
if (shouldLoadVal && src && !cachedSrc.value && !hasError.value) {
|
||||
async (shouldLoad) => {
|
||||
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
|
||||
try {
|
||||
const cachedMedia = await getCachedMedia(src)
|
||||
if (cachedMedia.error) {
|
||||
@@ -93,7 +93,7 @@ watch(
|
||||
console.warn('Failed to load cached media:', error)
|
||||
cachedSrc.value = src
|
||||
}
|
||||
} else if (!shouldLoadVal) {
|
||||
} else if (!shouldLoad) {
|
||||
if (cachedSrc.value?.startsWith('blob:')) {
|
||||
releaseUrl(src)
|
||||
}
|
||||
|
||||
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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
import type { PrimeVueSeverity } from '@/types/primeVueTypes'
|
||||
import { PrimeVueSeverity } from '@/types/primeVueTypes'
|
||||
|
||||
const {
|
||||
disabled,
|
||||
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
|
||||
@@ -9,8 +9,10 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { InjectKeyHandleEditLabelFunction } from '@/types/treeExplorerTypes'
|
||||
import {
|
||||
InjectKeyHandleEditLabelFunction,
|
||||
RenderedTreeExplorerNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
// Create a mock i18n instance
|
||||
const i18n = createI18n({
|
||||
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,13 +59,14 @@ import { useI18n } from 'vue-i18n'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/composables/useManagerState'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
|
||||
import PackInstallButton from './manager/button/PackInstallButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
@@ -137,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()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -29,7 +28,7 @@ const defaultMockTaskLogs = [
|
||||
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
|
||||
]
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
taskLogs: [...defaultMockTaskLogs],
|
||||
succeededTasksLogs: [...defaultMockTaskLogs],
|
||||
@@ -88,7 +88,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
useComfyManagerStore,
|
||||
useManagerProgressDialogStore
|
||||
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
} from '@/stores/comfyManagerStore'
|
||||
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const progressDialogContent = useManagerProgressDialogStore()
|
||||
@@ -43,11 +43,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { compare } from 'semver'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { compareVersions } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
@@ -68,7 +68,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compare(b, a) // Reversed for descending order
|
||||
return compareVersions(b, a) // Reversed for descending order
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
|
||||
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
|
||||
import ApiKeyForm from './signin/ApiKeyForm.vue'
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { Form, FormSubmitEvent } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -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"
|
||||
@@ -143,24 +143,24 @@ import IconButton from '@/components/button/IconButton.vue'
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ManagerNavSidebar from '@/components/dialog/content/manager/ManagerNavSidebar.vue'
|
||||
import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.vue'
|
||||
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
|
||||
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
|
||||
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
|
||||
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence'
|
||||
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
|
||||
import { useRegistrySearch } from '@/composables/useRegistrySearch'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import ManagerNavSidebar from '@/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue'
|
||||
import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanel.vue'
|
||||
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
|
||||
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
|
||||
import RegistrySearchBar from '@/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue'
|
||||
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
|
||||
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import type { TabItem } from '@/types/comfyManagerTypes'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { initialTab } = defineProps<{
|
||||
initialTab?: ManagerTab
|
||||
82
src/components/dialog/content/manager/ManagerHeader.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tag from 'primevue/tag'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import ManagerHeader from './ManagerHeader.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: enMessages
|
||||
}
|
||||
})
|
||||
|
||||
describe('ManagerHeader', () => {
|
||||
const createWrapper = () => {
|
||||
return mount(ManagerHeader, {
|
||||
global: {
|
||||
plugins: [createPinia(), PrimeVue, i18n],
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
},
|
||||
components: {
|
||||
Tag
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the component title', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe(
|
||||
enMessages.manager.discoverCommunityContent
|
||||
)
|
||||
})
|
||||
|
||||
it('displays the legacy manager UI tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.exists()).toBe(true)
|
||||
expect(tag.text()).toContain(enMessages.manager.legacyManagerUI)
|
||||
})
|
||||
|
||||
it('applies info severity to the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.classes()).toContain('p-tag-info')
|
||||
})
|
||||
|
||||
it('displays info icon in the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const icon = wrapper.find('.pi-info-circle')
|
||||
expect(icon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has cursor-help class on the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.classes()).toContain('cursor-help')
|
||||
})
|
||||
|
||||
it('has proper structure with flex container', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const flexContainer = wrapper.find('.flex.justify-end.ml-auto.pr-4')
|
||||
expect(flexContainer.exists()).toBe(true)
|
||||
|
||||
const tag = flexContainer.find('[data-pc-name="tag"]')
|
||||
expect(tag.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
25
src/components/dialog/content/manager/ManagerHeader.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-lg font-normal text-left">
|
||||
{{ $t('manager.discoverCommunityContent') }}
|
||||
</h2>
|
||||
<div class="flex justify-end ml-auto pr-4 pl-2">
|
||||
<Tag
|
||||
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
:value="$t('manager.legacyManagerUI')"
|
||||
class="cursor-help ml-2"
|
||||
:pt="{
|
||||
root: { class: 'text-xs' }
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
</script>
|
||||
@@ -32,7 +32,7 @@ import Listbox from 'primevue/listbox'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import type { TabItem } from '@/types/comfyManagerTypes'
|
||||
|
||||
defineProps<{
|
||||
tabs: TabItem[]
|
||||
@@ -169,7 +169,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import type {
|
||||
import {
|
||||
ConflictDetail,
|
||||
ConflictDetectionResult
|
||||
} from '@/types/conflictDetectionTypes'
|
||||
@@ -19,7 +19,7 @@
|
||||
import Message from 'primevue/message'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { ImportFailedKey } from '@/types/importFailedTypes'
|
||||
|
||||
type PackVersionStatus = components['schemas']['NodeVersionStatus']
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
@@ -35,7 +34,7 @@ const mockInstalledPacks = {
|
||||
|
||||
const mockIsPackEnabled = vi.fn(() => true)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
installedPacks: mockInstalledPacks,
|
||||
isPackInstalled: (id: string) =>
|
||||
@@ -43,13 +43,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { valid as validSemver } from 'semver'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import PackVersionSelectorPopover from '@/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const TRUNCATED_HASH_LENGTH = 7
|
||||
|
||||
@@ -81,9 +81,7 @@ const installedVersion = computed(() => {
|
||||
'nightly'
|
||||
|
||||
// If Git hash, truncate to 7 characters
|
||||
return validSemver(version)
|
||||
? version
|
||||
: version.slice(0, TRUNCATED_HASH_LENGTH)
|
||||
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)
|
||||
})
|
||||
|
||||
const toggleVersionSelector = (event: Event) => {
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -64,7 +63,7 @@ vi.mock('@/services/comfyRegistryService', () => ({
|
||||
}))
|
||||
|
||||
// Mock the manager store
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
installPack: {
|
||||
call: mockInstallPack,
|
||||
@@ -84,7 +84,6 @@ import { whenever } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { valid as validSemver } from 'semver'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -93,10 +92,11 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { useComfyRegistryService } from '@/services/comfyRegistryService'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
|
||||
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
|
||||
type ManagerDatabaseSource =
|
||||
@@ -142,7 +142,7 @@ onMounted(() => {
|
||||
getInitialSelectedVersion() ?? SelectedVersionValues.LATEST
|
||||
selectedVersion.value =
|
||||
// Use NIGHTLY when version is a Git hash
|
||||
validSemver(initialVersion) ? initialVersion : SelectedVersionValues.NIGHTLY
|
||||
isSemVer(initialVersion) ? initialVersion : SelectedVersionValues.NIGHTLY
|
||||
})
|
||||
|
||||
const getInitialSelectedVersion = () => {
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
@@ -8,7 +7,7 @@ import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
|
||||
import PackEnableToggle from './PackEnableToggle.vue'
|
||||
|
||||
@@ -33,7 +32,7 @@ const mockNodePack = {
|
||||
const mockIsPackEnabled = vi.fn()
|
||||
const mockEnablePack = { call: vi.fn().mockResolvedValue(undefined) }
|
||||
const mockDisablePack = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
isPackEnabled: mockIsPackEnabled,
|
||||
enablePack: mockEnablePack,
|
||||
@@ -36,10 +36,10 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
|
||||
|
||||
const TOGGLE_DEBOUNCE_MS = 256
|
||||
|
||||