Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ec282d4eab Complete Storybook issue tracking system with documentation
Co-authored-by: snomiao <7323030+snomiao@users.noreply.github.com>
2025-08-20 10:29:21 +00:00
copilot-swe-agent[bot]
ae7f6b8143 Add Storybook issue template and tracking documentation
Co-authored-by: snomiao <7323030+snomiao@users.noreply.github.com>
2025-08-20 10:28:05 +00:00
copilot-swe-agent[bot]
90c09db919 Initial plan 2025-08-20 10:22:03 +00:00
50 changed files with 378 additions and 4556 deletions

View File

@@ -1,5 +1,6 @@
name: Bug Report
description: 'Report something that is not working correctly'
title: '[Bug]: '
labels: ['Potential Bug']
type: Bug

View File

@@ -1,6 +1,7 @@
name: Feature Request
description: Report a problem or limitation you're experiencing
labels: []
title: '[Feature]: '
labels: ['enhancement']
type: Feature
body:

View File

@@ -0,0 +1,96 @@
name: Storybook Improvement
description: 'Report Storybook-related improvements, new stories, or configuration issues'
title: '[Storybook]: '
labels: ['area:storybook', 'enhancement']
type: Storybook
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label: I have checked the existing Storybook documentation in `.storybook/`
required: true
- label: I have searched existing issues to make sure this isn't a duplicate
required: true
- label: I have checked if this relates to any existing Storybook PRs
- type: dropdown
id: category
attributes:
label: Category
description: What type of Storybook improvement is this?
options:
- Component Stories - Add new stories for existing components
- Story Enhancement - Improve existing stories
- Configuration - Storybook configuration improvements
- Visual Testing - Chromatic/visual regression testing
- Documentation - Storybook documentation improvements
- Build/Performance - Build optimizations or performance improvements
- Theme/Styling - Theme support or styling improvements
- Tools/Addons - Storybook addons or tool integration
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: What improvement are you suggesting?
description: Describe the Storybook improvement you'd like to see
placeholder: |
Example: "Add comprehensive stories for the NodeWidget component covering all widget types (text, number, combo, etc.) to improve component development and testing."
validations:
required: true
- type: textarea
id: context
attributes:
label: Use Case & Context
description: Why is this improvement needed? What problem does it solve?
placeholder: |
- Current state of the component/story
- What's missing or could be improved
- How this would benefit development workflow
validations:
required: true
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this improvement?
options:
- Low - Nice to have enhancement
- Medium - Would improve development workflow
- High - Important for component quality/testing
- Critical - Needed for proper Storybook functionality
validations:
required: true
- type: textarea
id: components
attributes:
label: Components Affected
description: Which components or areas would be affected by this improvement?
placeholder: |
- NodeWidget
- Settings components
- All button components
- etc.
- type: textarea
id: implementation
attributes:
label: Implementation Ideas (Optional)
description: Any ideas on how this could be implemented?
placeholder: |
- Specific stories to create
- Configuration changes needed
- Dependencies or tools required
- type: textarea
id: examples
attributes:
label: Examples or References
description: Any examples from other projects, screenshots, or links that help illustrate the improvement

View File

@@ -162,9 +162,6 @@ jobs:
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Install Wrangler
run: npm install -g wrangler
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
@@ -180,35 +177,11 @@ jobs:
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
id: cloudflare-deploy
if: always()
run: |
# Retry logic for wrangler deploy (3 attempts)
RETRY_COUNT=0
MAX_RETRIES=3
SUCCESS=false
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
if npx wrangler pages deploy ComfyUI_frontend/playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
SUCCESS=true
echo "Deployment successful on attempt $RETRY_COUNT"
else
echo "Deployment failed on attempt $RETRY_COUNT"
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
echo "Retrying in 10 seconds..."
sleep 10
fi
fi
done
if [ $SUCCESS = false ]; then
echo "All deployment attempts failed"
exit 1
fi
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ComfyUI_frontend/playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}
- name: Save deployment info for summary
if: always()

View File

@@ -93,44 +93,6 @@ export const WithVariant: Story = {
## Development Tips
## ComfyUI Storybook Guidelines
### Scope When to Create Stories
- **PrimeVue components**:
No need to create stories. Just refer to the official PrimeVue documentation.
- **Custom shared components (design system components)**:
Always create stories. These components are built in collaboration with designers, and Storybook serves as both documentation and a communication tool.
- **Container components (logic-heavy)**:
Do not create stories. Only the underlying pure UI components should be included in Storybook.
### Maintenance Philosophy
- Stories are lightweight and generally stable.
Once created, they rarely need updates unless:
- The design changes
- New props (e.g. size, color variants) are introduced
- For existing usage patterns, simply copy real code examples into Storybook to create stories.
### File Placement
- Keep `*.stories.ts` files at the **same level as the component** (similar to test files).
- This makes it easier to check usage examples without navigating to another directory.
### Developer/Designer Workflow
- **UI vs Container**: Separate pure UI components from container components.
Only UI components should live in Storybook.
- **Communication Tool**: Storybook is not just about code quality—it enables designers and developers to see:
- Which props exist
- What cases are covered
- How variants (e.g. size, colors) look in isolation
- **Example**:
`PackActionButton.vue` wraps a PrimeVue button with additional logic.
→ Only create a story for the base UI button, not for the wrapper.
### Suggested Workflow
1. Use PrimeVue docs for standard components
2. Use Storybook for **shared/custom components** that define our design system
3. Keep story files alongside components
4. When in doubt, focus on components reused across the app or those that need to be showcased to designers
### Best Practices
1. **Keep Stories Simple**: Each story should demonstrate one specific use case
@@ -173,7 +135,6 @@ export const WithLongText: Story = {
- **`main.ts`**: Core Storybook configuration and Vite integration
- **`preview.ts`**: Global decorators, parameters, and Vue app setup
- **`manager.ts`**: Storybook UI customization (if needed)
- **`preview-head.html`**: Injects custom HTML into the `<head>` of every Storybook iframe (used for global styles, fonts, or fixes for iframe-specific issues)
## Chromatic Visual Testing
@@ -209,22 +170,4 @@ This Storybook setup includes:
- PrimeVue component library integration
- Proper alias resolution for `@/` imports
## Icon Usage in Storybook
In this project, the `<i-lucide:... />` syntax from unplugin-icons is not supported in Storybook.
**Example:**
```vue
<script setup lang="ts">
import { Trophy, Settings } from 'lucide-vue-next'
</script>
<template>
<Trophy :size="16" class="text-neutral" />
<Settings :size="16" class="text-neutral" />
</template>
```
This approach ensures icons render correctly in Storybook and remain consistent with the rest of the app.
For component-specific examples, see the NodePreview stories in `src/components/node/`.

62
STORYBOOK_ISSUE_SYSTEM.md Normal file
View File

@@ -0,0 +1,62 @@
# Storybook Issue Tracking System
This directory contains resources for tracking and organizing all Storybook-related work in the ComfyUI Frontend repository.
## 📋 Components
### 1. Issue Template (`.github/ISSUE_TEMPLATE/storybook-improvement.yaml`)
A structured GitHub issue template specifically for Storybook-related improvements and requests.
**Features:**
- Categorizes improvements (Component Stories, Configuration, Visual Testing, etc.)
- Priority levels (Low → Critical)
- Component impact tracking
- Implementation ideas and examples
**Usage:** When creating new issues related to Storybook, use this template to ensure consistent formatting and complete information.
### 2. Tracking Documentation (`STORYBOOK_TRACKING_ISSUE.md`)
Comprehensive documentation listing all 27+ Storybook-related PRs, organized by category.
**Contains:**
- Current status overview
- PRs organized by category (Setup, Stories, Themes, Config, etc.)
- Upcoming priorities roadmap
- Contribution guidelines
- Resource links
## 🔧 How to Use
### For New Storybook Issues
1. Go to [GitHub Issues → New Issue](https://github.com/Comfy-Org/ComfyUI_frontend/issues/new/choose)
2. Select "Storybook Improvement" template
3. Fill out the structured form
4. Add `area:storybook` label if not automatically applied
### For Tracking Progress
1. Reference the tracking documentation in `STORYBOOK_TRACKING_ISSUE.md`
2. Create a GitHub issue using this content as the body
3. Use labels: `area:storybook`, `tracking`
4. Pin the issue for easy access
### For Contributors
1. Check existing tracking issue for current priorities
2. Follow guidelines in `.storybook/README.md` and `.storybook/CLAUDE.md`
3. Reference the tracking issue number in related PRs
4. Update tracking documentation when completing work
## 📚 Related Resources
- **Storybook Documentation**: `.storybook/README.md`
- **Developer Guidelines**: `.storybook/CLAUDE.md`
- **Component Examples**: `src/components/*/\*.stories.ts`
- **Visual Testing**: Chromatic integration in CI/CD workflows
## 🎯 Purpose
This system helps:
- **Organize** all Storybook-related work in one place
- **Track** progress across multiple PRs and initiatives
- **Prioritize** improvements based on impact and urgency
- **Facilitate** collaboration between contributors
- **Maintain** comprehensive documentation of Storybook evolution

121
STORYBOOK_TRACKING_ISSUE.md Normal file
View File

@@ -0,0 +1,121 @@
# 📚 Storybook Development Tracking
This issue serves as a central hub for tracking all Storybook-related PRs and improvements in the ComfyUI Frontend repository.
## 🎯 Overview
Storybook is a crucial part of our component development workflow, enabling:
- Component isolation and development
- Visual documentation and testing
- Automated visual regression testing with Chromatic
- Design system development and maintenance
## 📈 Current Status
**Storybook Setup**: ✅ Complete
**Component Coverage**: 🔄 In Progress
**Visual Testing**: ✅ Integrated with Chromatic
**Documentation**: ✅ Comprehensive guides available
## 📋 Storybook PRs by Category
### 🏗️ Initial Setup & Infrastructure
- **#4861** - [feat] Add Storybook setup and NodePreview story *(merged)*
- Complete Storybook v8 setup with Vue 3 + Vite
- Chromatic integration for visual testing
- Comprehensive documentation and guidelines
### 📖 Component Stories & Documentation
- **#4999** - [feat] 100+ more Stories for Common Components *(open)*
- 76 story variants across 11 components
- Covers STATIC → SIMPLE_PROPS → INTERACTIVE → COMPLEX tiers
- **#5034** - [feat] Add Storybook configuration and settings panel stories *(open)*
- Settings panel components with all input types
- Responsive design and accessibility features
- **#5098** - [feat] Add comprehensive Storybook stories for custom UI components *(open)*
- 12 custom UI components with interactive testing
- Button, input, and layout component stories
- **#5122** - [docs] Add Storybook documentation *(open)*
- Enhanced `.storybook/README.md` with comprehensive guidelines
### 🎨 Theme & Visual Improvements
- **#5088** - [feat] Add dark theme support for Storybook *(merged)*
- Dark theme toggle with persistence
- Smooth transitions and proper styling
### 🔧 Configuration & Build Optimizations
- **#5117** - [ci] Enhance CI/CD caching across all workflows *(open)*
- Improved caching for Storybook builds
- **#5118** - [ci] Add retry logic to wrangler page deploy step *(open)*
- Stability improvements for Storybook deployment
### 🚀 Features & Enhancements
- **#5119** - [feat] Add enhanced filter UI components *(open)*
- SearchBox integration and improved MultiSelect
- **#5096** - [fix] Resolve breadcrumb and workflow tabs layout conflict *(open)*
- Layout improvements affecting Storybook stories
- **#5113** - [fix] Reposition TaskItem info *(open)*
- Component fixes that impact Storybook examples
### 🔨 Technical Improvements & Fixes
- **#5106** - Fix/widget ordering consistency *(open)*
- Node widget improvements affecting stories
- **#5109** - Fix CopyToClipboard Issue *(open)*
- Component fixes relevant to Storybook examples
- **#5092** - Add support for high-resolution wheel events *(open)*
- Input handling improvements
- **#5115** - Fix: Shift+Click+Drag from outputs with Subgraph outputs *(open)*
- Node interaction improvements
- **#5114** - Remove duplicate semantic labeling from issue templates *(open)*
- Issue template improvements
- **#5102** - [fix] Invoke onRemove callback in LGraphNode.removeWidget method *(merged)*
- Widget system improvements
- **#5099** - Remove PR checks workflows *(merged)*
- CI/CD cleanup
- **#5103** - Update to latest version of workflow icon *(merged)*
- Icon updates affecting stories
- **#5107** - [ci] Add caching support to format and knip commands *(merged)*
- Build optimization improvements
- **#5108** - [refactor] Remove obsolete Kontext Edit Button *(merged)*
- Component cleanup
- **#5110** - [chore] Ignore ./claude/settings.json *(merged)*
- Development environment improvements
- **#5112** - [docs] Update browser tests README *(merged)*
- Testing documentation improvements
- **#4908** - Modal Component & Custom UI Components *(merged)*
- Foundation UI components used in stories
## 🎯 Upcoming Priorities
### High Priority
- [ ] Complete component story coverage for all major UI components
- [ ] Implement comprehensive visual regression testing
- [ ] Improve Storybook build performance and caching
### Medium Priority
- [ ] Add interactive component documentation
- [ ] Enhance theme switching and customization
- [ ] Improve mobile responsiveness of stories
### Low Priority
- [ ] Add more sophisticated mock data patterns
- [ ] Implement component testing automation
- [ ] Explore advanced Storybook addons
## 🔄 How to Contribute
1. **Creating New Stories**: Follow guidelines in `.storybook/README.md` and `.storybook/CLAUDE.md`
2. **Improving Existing Stories**: Use the Storybook Improvement issue template
3. **Documentation**: Update relevant documentation when adding features
4. **Testing**: Ensure all stories build and render correctly
## 📚 Resources
- **Storybook Documentation**: `.storybook/README.md`
- **Developer Guidelines**: `.storybook/CLAUDE.md`
- **Component Examples**: `src/components/*/\*.stories.ts`
- **Visual Testing**: Chromatic integration in CI/CD
---
*This issue is automatically maintained. Please reference this issue number when working on Storybook-related improvements.*

15
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.6",
"version": "1.26.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.6",
"version": "1.26.5",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -84,7 +84,6 @@
"identity-obj-proxy": "^3.0.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
"storybook": "^9.1.1",
@@ -12225,16 +12224,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-vue-next": {
"version": "0.540.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.540.0.tgz",
"integrity": "sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.26.6",
"version": "1.26.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -84,8 +84,7 @@
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1",
"lucide-vue-next": "^0.540.0"
"zod-to-json-schema": "^3.24.1"
},
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,145 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
const meta: Meta<typeof IconButton> = {
title: 'Components/Button/IconButton',
component: IconButton,
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: ['sm', 'md']
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent']
},
onClick: { action: 'clicked' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconButton, Trophy },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Trophy :size="16" />
</IconButton>
`
}),
args: {
type: 'primary',
size: 'md'
}
}
export const Secondary: Story = {
render: (args) => ({
components: { IconButton, Settings },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Settings :size="16" />
</IconButton>
`
}),
args: {
type: 'secondary',
size: 'md'
}
}
export const Transparent: Story = {
render: (args) => ({
components: { IconButton, X },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<X :size="16" />
</IconButton>
`
}),
args: {
type: 'transparent',
size: 'md'
}
}
export const Small: Story = {
render: (args) => ({
components: { IconButton, Bell },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Bell :size="12" />
</IconButton>
`
}),
args: {
type: 'secondary',
size: 'sm'
}
}
export const AllVariants: Story = {
render: () => ({
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconButton type="primary" size="sm" @click="() => {}">
<Trophy :size="12" />
</IconButton>
<IconButton type="primary" size="md" @click="() => {}">
<Trophy :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="secondary" size="sm" @click="() => {}">
<Settings :size="12" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Settings :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="transparent" size="sm" @click="() => {}">
<X :size="12" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<X :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="primary" size="md" @click="() => {}">
<Bell :size="16" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Heart :size="16" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<Download :size="16" />
</IconButton>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -1,35 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
import IconGroup from './IconGroup.vue'
const meta: Meta<typeof IconGroup> = {
title: 'Components/Button/IconGroup',
component: IconGroup,
parameters: {
layout: 'centered'
}
}
export default meta
type Story = StoryObj<typeof IconGroup>
export const Basic: Story = {
render: () => ({
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
template: `
<IconGroup>
<IconButton @click="console.log('Hello World!!')">
<Heart :size="16" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<Download :size="16" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<ExternalLink :size="16" />
</IconButton>
</IconGroup>
`
})
}

View File

@@ -1,221 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
ChevronLeft,
ChevronRight,
Download,
Package,
Save,
Settings,
Trash2,
X
} from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
const meta: Meta<typeof IconTextButton> = {
title: 'Components/Button/IconTextButton',
component: IconTextButton,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text'
},
size: {
control: { type: 'select' },
options: ['sm', 'md']
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent']
},
iconPosition: {
control: { type: 'select' },
options: ['left', 'right']
},
onClick: { action: 'clicked' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconTextButton, Package },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Package :size="16" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Deploy',
type: 'primary',
size: 'md'
}
}
export const Secondary: Story = {
render: (args) => ({
components: { IconTextButton, Settings },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Settings :size="16" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Settings',
type: 'secondary',
size: 'md'
}
}
export const Transparent: Story = {
render: (args) => ({
components: { IconTextButton, X },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<X :size="16" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Cancel',
type: 'transparent',
size: 'md'
}
}
export const WithIconRight: Story = {
render: (args) => ({
components: { IconTextButton, ChevronRight },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<ChevronRight :size="16" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Next',
type: 'primary',
size: 'md',
iconPosition: 'right'
}
}
export const Small: Story = {
render: (args) => ({
components: { IconTextButton, Save },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Save :size="12" />
</template>
</IconTextButton>
`
}),
args: {
label: 'Save',
type: 'primary',
size: 'sm'
}
}
export const AllVariants: Story = {
render: () => ({
components: {
IconTextButton,
Download,
Settings,
Trash2,
ChevronRight,
ChevronLeft,
Save
},
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
<template #icon>
<Download :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
<template #icon>
<Download :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
<template #icon>
<Settings :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
<template #icon>
<Settings :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
<template #icon>
<Trash2 :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
<template #icon>
<Trash2 :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
<template #icon>
<ChevronRight :size="16" />
</template>
</IconTextButton>
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
<template #icon>
<ChevronLeft :size="16" />
</template>
</IconTextButton>
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
<template #icon>
<Save :size="16" />
</template>
</IconTextButton>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -1,50 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ScrollText } from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
import MoreButton from './MoreButton.vue'
const meta: Meta<typeof MoreButton> = {
title: 'Components/Button/MoreButton',
component: MoreButton,
parameters: {
layout: 'centered'
},
argTypes: {}
}
export default meta
type Story = StoryObj<typeof MoreButton>
export const Basic: Story = {
render: () => ({
components: { MoreButton, IconTextButton, Download, ScrollText },
template: `
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
<MoreButton>
<template #default="{ close }">
<IconTextButton
type="secondary"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<Download />
</template>
</IconTextButton>
<IconTextButton
type="primary"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<ScrollText />
</template>
</IconTextButton>
</template>
</MoreButton>
</div>
`
})
}

View File

@@ -1,83 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TextButton from './TextButton.vue'
const meta: Meta<typeof TextButton> = {
title: 'Components/Button/TextButton',
component: TextButton,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Click me'
},
size: {
control: { type: 'select' },
options: ['sm', 'md'],
defaultValue: 'md'
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent'],
defaultValue: 'primary'
},
onClick: { action: 'clicked' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
label: 'Primary Button',
type: 'primary',
size: 'md'
}
}
export const Secondary: Story = {
args: {
label: 'Secondary Button',
type: 'secondary',
size: 'md'
}
}
export const Transparent: Story = {
args: {
label: 'Transparent Button',
type: 'transparent',
size: 'md'
}
}
export const Small: Story = {
args: {
label: 'Small Button',
type: 'primary',
size: 'sm'
}
}
export const AllVariants: Story = {
render: () => ({
components: { TextButton },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<TextButton label="Primary Small" type="primary" size="sm" @click="() => {}" />
<TextButton label="Primary Medium" type="primary" size="md" @click="() => {}" />
</div>
<div class="flex gap-2 items-center">
<TextButton label="Secondary Small" type="secondary" size="sm" @click="() => {}" />
<TextButton label="Secondary Medium" type="secondary" size="md" @click="() => {}" />
</div>
<div class="flex gap-2 items-center">
<TextButton label="Transparent Small" type="transparent" size="sm" @click="() => {}" />
<TextButton label="Transparent Medium" type="transparent" size="md" @click="() => {}" />
</div>
</div>
`
})
}

View File

@@ -1,665 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Folder,
Heart,
Info,
MoreVertical,
Star,
Upload
} from 'lucide-vue-next'
import { ref } from 'vue'
import IconButton from '../button/IconButton.vue'
import SquareChip from '../chip/SquareChip.vue'
import CardBottom from './CardBottom.vue'
import CardContainer from './CardContainer.vue'
import CardDescription from './CardDescription.vue'
import CardTitle from './CardTitle.vue'
import CardTop from './CardTop.vue'
interface CardStoryArgs {
// CardContainer props
containerRatio: 'square' | 'portrait' | 'tallPortrait'
maxWidth: number
minWidth: number
// CardTop props
topRatio: 'square' | 'landscape'
// Content props
showTopLeft: boolean
showTopRight: boolean
showBottomLeft: boolean
showBottomRight: boolean
showTitle: boolean
showDescription: boolean
title: string
description: string
// Visual props
backgroundColor: string
showImage: boolean
imageUrl: string
// Tag props
tags: string[]
showFileSize: boolean
fileSize: string
showFileType: boolean
fileType: string
}
const meta: Meta<CardStoryArgs> = {
title: 'Components/Card/Card',
argTypes: {
containerRatio: {
control: 'select',
options: ['square', 'portrait', 'tallPortrait'],
description: 'Card container aspect ratio'
},
maxWidth: {
control: { type: 'range', min: 200, max: 600, step: 10 },
description: 'Maximum width in pixels'
},
minWidth: {
control: { type: 'range', min: 150, max: 400, step: 10 },
description: 'Minimum width in pixels'
},
topRatio: {
control: 'select',
options: ['square', 'landscape'],
description: 'Top section aspect ratio'
},
showTopLeft: {
control: 'boolean',
description: 'Show top-left slot content'
},
showTopRight: {
control: 'boolean',
description: 'Show top-right slot content'
},
showBottomLeft: {
control: 'boolean',
description: 'Show bottom-left slot content'
},
showBottomRight: {
control: 'boolean',
description: 'Show bottom-right slot content'
},
showTitle: {
control: 'boolean',
description: 'Show card title'
},
showDescription: {
control: 'boolean',
description: 'Show card description'
},
title: {
control: 'text',
description: 'Card title text'
},
description: {
control: 'text',
description: 'Card description text'
},
backgroundColor: {
control: 'color',
description: 'Background color for card top'
},
showImage: {
control: 'boolean',
description: 'Show image instead of color background'
},
imageUrl: {
control: 'text',
description: 'Image URL for card top'
},
tags: {
control: 'object',
description: 'Tags to display (array of strings)'
},
showFileSize: {
control: 'boolean',
description: 'Show file size tag'
},
fileSize: {
control: 'text',
description: 'File size text'
},
showFileType: {
control: 'boolean',
description: 'Show file type tag'
},
fileType: {
control: 'text',
description: 'File type text'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
const createCardTemplate = (args: CardStoryArgs) => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download,
Star,
Upload,
MoreVertical
},
setup() {
const favorited = ref(false)
const toggleFavorite = () => {
favorited.value = !favorited.value
}
return {
args,
favorited,
toggleFavorite
}
},
template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<CardContainer
:ratio="args.containerRatio"
:max-width="args.maxWidth"
:min-width="args.minWidth"
>
<template #top>
<CardTop :ratio="args.topRatio">
<template #default>
<div
v-if="!args.showImage"
class="w-full h-full"
:style="{ backgroundColor: args.backgroundColor }"
></div>
<img
v-else
:src="args.imageUrl || 'https://via.placeholder.com/400'"
class="w-full h-full object-cover"
alt="Card image"
/>
</template>
<template v-if="args.showTopLeft" #top-left>
<SquareChip label="Featured" />
</template>
<template v-if="args.showTopRight" #top-right>
<IconButton
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info clicked')"
>
<Info :size="16" />
</IconButton>
<IconButton
class="!bg-white/90"
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
@click="toggleFavorite"
>
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" />
</IconButton>
</template>
<template v-if="args.showBottomLeft" #bottom-left>
<SquareChip label="New" />
</template>
<template v-if="args.showBottomRight" #bottom-right>
<SquareChip v-if="args.showFileType" :label="args.fileType" />
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
</template>
</SquareChip>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
`
})
export const Default: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
maxWidth: 300,
minWidth: 200,
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Model Name',
description:
'This is a detailed description of the model that can span multiple lines',
backgroundColor: '#3b82f6',
showImage: false,
imageUrl: '',
tags: ['LoRA', 'SDXL'],
showFileSize: true,
fileSize: '1.2 MB',
showFileType: true,
fileType: 'safetensors'
}
}
export const SquareCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
maxWidth: 400,
minWidth: 250,
topRatio: 'landscape',
showTopLeft: false,
showTopRight: true,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Workflow Bundle',
description:
'Complete workflow for image generation with all necessary nodes',
backgroundColor: '#10b981',
showImage: false,
imageUrl: '',
tags: ['Workflow'],
showFileSize: true,
fileSize: '245 KB',
showFileType: true,
fileType: 'json'
}
}
export const TallPortraitCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
maxWidth: 280,
minWidth: 180,
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Premium Model',
description:
'High-quality photorealistic model trained on professional photography',
backgroundColor: '#8b5cf6',
showImage: false,
imageUrl: '',
tags: ['SD 1.5', 'Checkpoint'],
showFileSize: true,
fileSize: '2.1 GB',
showFileType: true,
fileType: 'ckpt'
}
}
export const ImageCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
maxWidth: 350,
minWidth: 220,
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Generated Image',
description: 'Created with DreamShaper XL',
backgroundColor: '#3b82f6',
showImage: true,
imageUrl: 'https://picsum.photos/400/400',
tags: ['Output'],
showFileSize: true,
fileSize: '856 KB',
showFileType: true,
fileType: 'png'
}
}
export const MinimalCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
maxWidth: 300,
minWidth: 200,
topRatio: 'landscape',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: false,
title: 'Simple Card',
description: '',
backgroundColor: '#64748b',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const FullFeaturedCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
maxWidth: 320,
minWidth: 240,
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
showBottomLeft: true,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Ultimate Model Pack',
description:
'Complete collection with checkpoints, LoRAs, embeddings, and VAE models for professional use',
backgroundColor: '#ef4444',
showImage: false,
imageUrl: '',
tags: ['Bundle', 'Premium', 'SDXL'],
showFileSize: true,
fileSize: '5.4 GB',
showFileType: true,
fileType: 'pack'
}
}
export const GridOfCards: Story = {
render: () => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download
},
setup() {
const cards = ref([
{
id: 1,
title: 'Realistic Vision',
description: 'Photorealistic model for portraits',
color: 'from-blue-400 to-blue-600',
ratio: 'portrait' as const,
tags: ['SD 1.5'],
size: '2.1 GB'
},
{
id: 2,
title: 'DreamShaper XL',
description: 'Artistic style model with enhanced details',
color: 'from-purple-400 to-pink-600',
ratio: 'portrait' as const,
tags: ['SDXL'],
size: '6.5 GB'
},
{
id: 3,
title: 'Anime LoRA',
description: 'Character style LoRA',
color: 'from-green-400 to-teal-600',
ratio: 'portrait' as const,
tags: ['LoRA'],
size: '144 MB'
},
{
id: 4,
title: 'VAE Model',
description: 'Enhanced color VAE',
color: 'from-orange-400 to-red-600',
ratio: 'portrait' as const,
tags: ['VAE'],
size: '335 MB'
},
{
id: 5,
title: 'Workflow Bundle',
description: 'Complete workflow setup',
color: 'from-indigo-400 to-blue-600',
ratio: 'portrait' as const,
tags: ['Workflow'],
size: '45 KB'
},
{
id: 6,
title: 'Embedding Pack',
description: 'Negative embeddings collection',
color: 'from-yellow-400 to-orange-600',
ratio: 'portrait' as const,
tags: ['Embedding'],
size: '2.3 MB'
}
])
return { cards }
},
template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Model Gallery</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<CardContainer
v-for="card in cards"
:key="card.id"
:ratio="card.ratio"
:max-width="300"
:min-width="180"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full bg-gray-600"
:class="card.color"
></div>
</template>
<template #top-right>
<IconButton
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info:', card.title)"
>
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
<SquareChip
v-for="tag in card.tags"
:key="tag"
:label="tag"
>
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
</template>
</SquareChip>
<SquareChip :label="card.size" />
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
`
})
}
export const ResponsiveGrid: Story = {
render: () => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
SquareChip
},
setup() {
const generateCards = (
count: number,
ratio: 'square' | 'portrait' | 'tallPortrait'
) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
title: `Model ${i + 1}`,
description: `Description for model ${i + 1}`,
ratio,
color: `hsl(${(i * 60) % 360}, 70%, 60%)`
}))
}
const squareCards = ref(generateCards(4, 'square'))
const portraitCards = ref(generateCards(6, 'portrait'))
const tallCards = ref(generateCards(5, 'tallPortrait'))
return {
squareCards,
portraitCards,
tallCards
}
},
template: `
<div class="p-4 space-y-8 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Square Cards (1:1)</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<CardContainer
v-for="card in squareCards"
:key="card.id"
:ratio="card.ratio"
:max-width="400"
:min-width="200"
>
<template #top>
<CardTop ratio="landscape">
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Portrait Cards (2:3)</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<CardContainer
v-for="card in portraitCards"
:key="card.id"
:ratio="card.ratio"
:max-width="280"
:min-width="160"
>
<template #top>
<CardTop ratio="square">
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-2">
<CardTitle>{{ card.title }}</CardTitle>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Tall Portrait Cards (2:4)</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<CardContainer
v-for="card in tallCards"
:key="card.id"
:ratio="card.ratio"
:max-width="260"
:min-width="150"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</template>
<template #bottom-right>
<SquareChip :label="'#' + card.id" />
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -13,8 +13,8 @@ const {
maxWidth,
minWidth
} = defineProps<{
maxWidth?: number
minWidth?: number
maxWidth: number
minWidth: number
ratio?: 'square' | 'portrait' | 'tallPortrait'
}>()
@@ -31,12 +31,8 @@ const containerClasses = computed(() => {
return `${baseClasses} ${ratioClasses[ratio]}`
})
const containerStyle = computed(() =>
maxWidth || minWidth
? {
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}
: {}
)
const containerStyle = computed(() => ({
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}))
</script>

View File

@@ -1,36 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SquareChip from './SquareChip.vue'
const meta: Meta<typeof SquareChip> = {
title: 'Components/SquareChip',
component: SquareChip,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Tag'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const TagList: Story = {
render: () => ({
components: { SquareChip },
template: `
<div class="flex flex-wrap gap-2">
<SquareChip label="JavaScript" />
<SquareChip label="TypeScript" />
<SquareChip label="Vue.js" />
<SquareChip label="React" />
<SquareChip label="Node.js" />
<SquareChip label="Python" />
<SquareChip label="Docker" />
<SquareChip label="Kubernetes" />
</div>
`
})
}

View File

@@ -1,151 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
const meta: Meta<typeof MultiSelect> = {
title: 'Components/Input/MultiSelect',
component: MultiSelect,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text'
},
options: {
control: 'object'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref([])
const options = [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
]
return { selected, options, args }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
label="Select Frameworks"
v-bind="args"
/>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
`
})
}
export const WithPreselectedValues: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const options = [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
]
const selected = ref([options[0], options[1]])
return { selected, options }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
label="Select Languages"
/>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
</div>
</div>
`
})
}
export const MultipleSelectors: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
])
const projectOptions = ref([
{ name: 'Project A', value: 'proj-a' },
{ name: 'Project B', value: 'proj-b' },
{ name: 'Project C', value: 'proj-c' },
{ name: 'Project D', value: 'proj-d' }
])
const tagOptions = ref([
{ name: 'Frontend', value: 'frontend' },
{ name: 'Backend', value: 'backend' },
{ name: 'Database', value: 'database' },
{ name: 'DevOps', value: 'devops' },
{ name: 'Testing', value: 'testing' }
])
const selectedFrameworks = ref([])
const selectedProjects = ref([])
const selectedTags = ref([])
return {
frameworkOptions,
projectOptions,
tagOptions,
selectedFrameworks,
selectedProjects,
selectedTags
}
},
template: `
<div class="space-y-4">
<div class="flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
:options="frameworkOptions"
label="Select Frameworks"
/>
<MultiSelect
v-model="selectedProjects"
:options="projectOptions"
label="Select Projects"
/>
<MultiSelect
v-model="selectedTags"
:options="tagOptions"
label="Select Tags"
/>
</div>
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<h4 class="font-medium mb-2">Current Selection:</h4>
<div class="space-y-1 text-sm">
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
</div>
`
})
}

View File

@@ -9,41 +9,6 @@
:max-selected-labels="0"
:pt="pt"
>
<template
v-if="hasSearchBox || showSelectedCount || hasClearButton"
#header
>
<div class="p-2 flex flex-col gap-y-4 pb-0">
<SearchBox
v-if="hasSearchBox"
v-model="searchQuery"
:has-border="true"
:place-holder="searchPlaceholder"
/>
<div class="flex items-center justify-between">
<span
v-if="showSelectedCount"
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<TextButton
v-if="hasClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div>
<div class="h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div>
</template>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
@@ -77,7 +42,7 @@
</template>
</MultiSelect>
<!-- Selected count badge -->
<!-- Selected count badge (unchanged) -->
<div
v-if="selectedCount > 0"
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
@@ -93,41 +58,22 @@ import MultiSelect, {
} from 'primevue/multiselect'
import { computed } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue'
import TextButton from '../button/TextButton.vue'
type Option = { name: string; value: string }
interface Props {
/** Input label shown on the trigger button */
const { label, options } = defineProps<{
label?: string
/** Static options for the multiselect (when not using async search) */
options: Option[]
/** Show search box in the panel header */
hasSearchBox?: boolean
/** Show selected count text in the panel header */
showSelectedCount?: boolean
/** Show "Clear all" action in the panel header */
hasClearButton?: boolean
/** Placeholder for the search input */
searchPlaceholder?: string
}
const {
label,
options,
hasSearchBox = false,
showSelectedCount = false,
hasClearButton = false,
searchPlaceholder = 'Search...'
} = defineProps<Props>()
options: { name: string; value: string }[]
}>()
const selectedItems = defineModel<Option[]>({
const selectedItems = defineModel<{ name: string; value: string }[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery')
const selectedCount = computed(() => selectedItems.value.length)
/**
* Pure unstyled mode using only the PrimeVue PT API.
* All PrimeVue built-in checkboxes/headers are hidden via PT (no :deep hacks).
* Visual output matches the previous version exactly.
*/
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [
@@ -151,19 +97,19 @@ const pt = computed(() => ({
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden'
}),
header: { class: 'hidden' },
// Overlay & list visuals unchanged
overlay:
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700',
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100',
list: {
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
},
// Option row hover tone identical
option:
'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },

View File

@@ -1,33 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const meta: Meta<typeof SearchBox> = {
title: 'Components/Input/SearchBox',
component: SearchBox,
tags: ['autodocs'],
argTypes: {
placeHolder: {
control: 'text'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { SearchBox },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div>
<SearchBox v-model:="searchQuery" />
</div>
`
})
}

View File

@@ -1,6 +1,8 @@
<template>
<div :class="wrapperStyle">
<i-lucide:search :class="iconColorStyle" />
<div
class="flex w-full items-center rounded-lg px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800"
>
<i-lucide:search class="text-neutral" />
<InputText
v-model="searchQuery"
:placeholder="placeHolder || 'Search...'"
@@ -13,21 +15,10 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed, defineModel } from 'vue'
import { defineModel } from 'vue'
const { placeHolder, hasBorder = false } = defineProps<{
const { placeHolder } = defineProps<{
placeHolder?: string
hasBorder?: boolean
}>()
const searchQuery = defineModel<string>('')
const wrapperStyle = computed(() => {
return hasBorder
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
})
const iconColorStyle = computed(() => {
return !hasBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
})
</script>

View File

@@ -1,116 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ArrowUpDown } from 'lucide-vue-next'
import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
const meta: Meta<typeof SingleSelect> = {
title: 'Components/Input/SingleSelect',
component: SingleSelect,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' }
}
}
export default meta
export type Story = StoryObj<typeof meta>
const sampleOptions = [
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'Oldest', value: 'oldest' },
{ name: 'A → Z', value: 'az' },
{ name: 'Z → A', value: 'za' }
]
export const Default: Story = {
render: (args) => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const options = sampleOptions
return { selected, options, args }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" :label="args.label || 'Sorting Type'" />
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
</div>
</div>
`
}),
args: { label: 'Sorting Type' }
}
export const WithIcon: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
setup() {
const selected = ref<string | null>('popular')
const options = sampleOptions
return { selected, options }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon>
<ArrowUpDown :size="14" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected }}</p>
</div>
</div>
`
})
}
export const Preselected: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('newest')
const options = sampleOptions
return { selected, options }
},
template: `
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
setup() {
const options = sampleOptions
const a = ref<string | null>(null)
const b = ref<string | null>('popular')
const c = ref<string | null>('az')
return { options, a, b, c }
},
template: `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<SingleSelect v-model="a" :options="options" label="No Icon" />
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon>
<ArrowUpDown :size="14" />
</template>
</SingleSelect>
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -99,7 +99,7 @@ const pt = computed(() => ({
overlay: {
class: [
// dropdown panel
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700'
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg'
]
},
list: {

View File

@@ -224,7 +224,7 @@ const cancelledWithoutResults = computed(() => {
.task-item-details {
position: absolute;
top: 0.5rem;
bottom: 0;
padding: 0.6rem;
display: flex;
justify-content: space-between;

View File

@@ -59,10 +59,6 @@
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
class="w-[250px]"
:has-search-box="true"
:show-selected-count="true"
:has-clear-button="true"
label="Select Frameworks"
:options="frameworkOptions"
/>

View File

@@ -1,556 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Filter,
Folder,
Info,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
Puzzle,
Scroll,
Settings,
Upload,
X
} from 'lucide-vue-next'
import { provide, ref } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import RightSidePanel from '../panel/RightSidePanel.vue'
import BaseWidgetLayout from './BaseWidgetLayout.vue'
interface StoryArgs {
contentTitle: string
hasLeftPanel: boolean
hasRightPanel: boolean
hasHeader: boolean
hasContentFilter: boolean
hasHeaderRightArea: boolean
cardCount: number
}
const meta: Meta<StoryArgs> = {
title: 'Components/Widget/Layout/BaseWidgetLayout',
argTypes: {
contentTitle: {
control: 'text',
description: 'Title shown when no left panel is present'
},
hasLeftPanel: {
control: 'boolean',
description: 'Toggle left panel visibility'
},
hasRightPanel: {
control: 'boolean',
description: 'Toggle right panel visibility'
},
hasHeader: {
control: 'boolean',
description: 'Toggle header visibility'
},
hasContentFilter: {
control: 'boolean',
description: 'Toggle content filter visibility'
},
hasHeaderRightArea: {
control: 'boolean',
description: 'Toggle header right area visibility'
},
cardCount: {
control: { type: 'range', min: 0, max: 50, step: 1 },
description: 'Number of cards to display in content'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseWidgetLayout,
LeftSidePanel,
RightSidePanel,
SearchBox,
MultiSelect,
SingleSelect,
IconButton,
IconTextButton,
MoreButton,
CardContainer,
CardTop,
CardBottom,
SquareChip,
Settings,
Upload,
Download,
Scroll,
Info,
Filter,
Folder,
Puzzle,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
X
},
setup() {
const t = (k: string) => k
const onClose = () => {
console.log('OnClose invoked')
}
provide(OnCloseKey, onClose)
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed' },
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5' },
{ id: 'tag-sdxl', label: 'SDXL' },
{ id: 'tag-utility', label: 'Utility' }
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models' },
{ id: 'cat-nodes', label: 'Nodes' }
]
}
])
const selectedNavItem = ref<string | null>('installed')
const searchQuery = ref<string>('')
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
])
const projectOptions = ref([
{ name: 'Project A', value: 'proj-a' },
{ name: 'Project B', value: 'proj-b' },
{ name: 'Project C', value: 'proj-c' }
])
const sortOptions = ref([
{ name: 'Popular', value: 'popular' },
{ name: 'Latest', value: 'latest' },
{ name: 'A → Z', value: 'az' }
])
const selectedFrameworks = ref<string[]>([])
const selectedProjects = ref<string[]>([])
const selectedSort = ref<string>('popular')
return {
args,
t,
tempNavigation,
selectedNavItem,
searchQuery,
frameworkOptions,
projectOptions,
sortOptions,
selectedFrameworks,
selectedProjects,
selectedSort
}
},
template: `
<div>
<BaseWidgetLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
</template>
<!-- Header -->
<template v-if="args.hasHeader" #header>
<SearchBox
class="max-w-[384px]"
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
</template>
<!-- Header Right Area -->
<template v-if="args.hasHeaderRightArea" #header-right-area>
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
</template>
</IconTextButton>
<MoreButton>
<template #default="{ close }">
<IconTextButton
type="secondary"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
</template>
</IconTextButton>
<IconTextButton
type="primary"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
</template>
</IconTextButton>
</template>
</MoreButton>
</div>
</template>
<!-- Content Filter -->
<template v-if="args.hasContentFilter" #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
label="Select Frameworks"
:options="frameworkOptions"
/>
<MultiSelect
v-model="selectedProjects"
label="Select Projects"
:options="projectOptions"
/>
<SingleSelect
v-model="selectedSort"
label="Sorting Type"
:options="sortOptions"
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
</template>
</SingleSelect>
</div>
</template>
<!-- Content -->
<template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
<CardContainer
v-for="i in args.cardCount"
:key="i"
ratio="square"
>
<template #top>
<CardTop ratio="landscape">
<template #default>
<div class="w-full h-full bg-blue-500"></div>
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
</template>
</SquareChip>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom />
</template>
</CardContainer>
</div>
</template>
</BaseWidgetLayout>
<BaseWidgetLayout v-else :content-title="args.contentTitle || 'Content Title'">
<!-- Same content but WITH right panel -->
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
</template>
<!-- Header -->
<template v-if="args.hasHeader" #header>
<SearchBox
class="max-w-[384px]"
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
</template>
<!-- Header Right Area -->
<template v-if="args.hasHeaderRightArea" #header-right-area>
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
</template>
</IconTextButton>
<MoreButton>
<template #default="{ close }">
<IconTextButton
type="secondary"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
</template>
</IconTextButton>
<IconTextButton
type="primary"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
</template>
</IconTextButton>
</template>
</MoreButton>
</div>
</template>
<!-- Content Filter -->
<template v-if="args.hasContentFilter" #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
label="Select Frameworks"
:options="frameworkOptions"
/>
<MultiSelect
v-model="selectedProjects"
label="Select Projects"
:options="projectOptions"
/>
<SingleSelect
v-model="selectedSort"
label="Sorting Type"
:options="sortOptions"
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
</template>
</SingleSelect>
</div>
</template>
<!-- Content -->
<template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
<CardContainer
v-for="i in args.cardCount"
:key="i"
ratio="square"
>
<template #top>
<CardTop ratio="landscape">
<template #default>
<div class="w-full h-full bg-blue-500"></div>
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
</template>
</SquareChip>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom />
</template>
</CardContainer>
</div>
</template>
<!-- Right Panel - Only when hasRightPanel is true -->
<template #rightPanel>
<RightSidePanel />
</template>
</BaseWidgetLayout>
</div>
`
})
export const Default: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: true,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const BothPanels: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: true,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const LeftPanelOnly: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: true,
hasRightPanel: false,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const RightPanelOnly: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: false,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const NoPanels: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Content Title',
hasLeftPanel: false,
hasRightPanel: false,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 12
}
}
export const MinimalLayout: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Simple Content',
hasLeftPanel: false,
hasRightPanel: false,
hasHeader: false,
hasContentFilter: false,
hasHeaderRightArea: false,
cardCount: 6
}
}
export const NoContent: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Empty State',
hasLeftPanel: true,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 0
}
}
export const HeaderOnly: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Header Layout',
hasLeftPanel: false,
hasRightPanel: false,
hasHeader: true,
hasContentFilter: false,
hasHeaderRightArea: true,
cardCount: 8
}
}
export const FilterOnly: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Filter Layout',
hasLeftPanel: false,
hasRightPanel: false,
hasHeader: false,
hasContentFilter: true,
hasHeaderRightArea: false,
cardCount: 8
}
}
export const MaxContent: Story = {
render: (args: StoryArgs) => createStoryTemplate(args),
args: {
contentTitle: 'Full Content',
hasLeftPanel: true,
hasRightPanel: true,
hasHeader: true,
hasContentFilter: true,
hasHeaderRightArea: true,
cardCount: 50
}
}

View File

@@ -1,138 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
BarChart3,
Bell,
BookOpen,
FolderOpen,
GraduationCap,
Home,
LogOut,
MessageSquare,
Settings,
User,
Users
} from 'lucide-vue-next'
import { ref } from 'vue'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import NavItem from './NavItem.vue'
import NavTitle from './NavTitle.vue'
const meta: Meta = {
title: 'Components/Widget/Navigation',
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
export const NavigationItem: Story = {
render: () => ({
components: { NavItem },
template: `
<div class="space-y-2">
<NavItem>Dashboard</NavItem>
<NavItem>Projects</NavItem>
<NavItem>Messages</NavItem>
<NavItem>Settings</NavItem>
</div>
`
})
}
export const CustomNavigation: Story = {
render: () => ({
components: {
NavTitle,
NavItem,
Home,
FolderOpen,
BarChart3,
Users,
BookOpen,
GraduationCap,
MessageSquare,
Settings,
User,
Bell,
LogOut
},
template: `
<nav class="w-64 p-4 bg-white dark-theme:bg-zinc-800 rounded-lg">
<NavTitle title="Main Menu" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><Home :size="16" class="inline mr-2" />Dashboard</NavItem>
<NavItem :hasFolderIcon="false"><FolderOpen :size="16" class="inline mr-2" />Projects</NavItem>
<NavItem :hasFolderIcon="false"><BarChart3 :size="16" class="inline mr-2" />Analytics</NavItem>
<NavItem :hasFolderIcon="false"><Users :size="16" class="inline mr-2" />Team</NavItem>
</div>
<div class="mt-6">
<NavTitle title="Resources" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><BookOpen :size="16" class="inline mr-2" />Documentation</NavItem>
<NavItem :hasFolderIcon="false"><GraduationCap :size="16" class="inline mr-2" />Tutorials</NavItem>
<NavItem :hasFolderIcon="false"><MessageSquare :size="16" class="inline mr-2" />Community</NavItem>
</div>
</div>
<div class="mt-6">
<NavTitle title="Account" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><Settings :size="16" class="inline mr-2" />Settings</NavItem>
<NavItem :hasFolderIcon="false"><User :size="16" class="inline mr-2" />Profile</NavItem>
<NavItem :hasFolderIcon="false"><Bell :size="16" class="inline mr-2" />Notifications</NavItem>
<NavItem :hasFolderIcon="false"><LogOut :size="16" class="inline mr-2" />Logout</NavItem>
</div>
</div>
</nav>
`
})
}
export const LeftSidePanelDemo: Story = {
render: () => ({
components: { LeftSidePanel, FolderOpen },
setup() {
const navItems = [
{
title: 'Workspace',
items: [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'projects', label: 'Projects' },
{ id: 'workflows', label: 'Workflows' },
{ id: 'models', label: 'Models' }
]
},
{
title: 'Tools',
items: [
{ id: 'node-editor', label: 'Node Editor' },
{ id: 'image-browser', label: 'Image Browser' },
{ id: 'queue-manager', label: 'Queue Manager' },
{ id: 'extensions', label: 'Extensions' }
]
},
{ id: 'settings', label: 'Settings' }
]
const active = ref<string | null>(null)
return { navItems, active }
},
template: `
<div class="w-full h-[560px] flex">
<div class="w-64 rounded-lg">
<LeftSidePanel v-model="active" :nav-items="navItems">
<template #header-icon>
<FolderOpen :size="14" />
</template>
<template #header-title>
Navigation
</template>
</LeftSidePanel>
</div>
<div class="flex-1 p-3 text-sm bg-gray-50 dark-theme:bg-zinc-900 border-t border-zinc-200 dark-theme:border-zinc-700">
Active: {{ active ?? 'None' }}
</div>
</div>
`
})
}

View File

@@ -4,64 +4,32 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
export function useCopyToClipboard() {
const { copy, copied } = useClipboard()
const { copy, isSupported } = useClipboard()
const toast = useToast()
const showSuccessToast = () => {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
}
const showErrorToast = () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
textarea.setAttribute('aria-hidden', 'true')
textarea.setAttribute('tabindex', '-1')
textarea.style.width = '1px'
textarea.style.height = '1px'
document.body.appendChild(textarea)
textarea.select()
try {
// using legacy document.execCommand for fallback for old and linux browsers
const successful = document.execCommand('copy')
if (successful) {
showSuccessToast()
} else {
showErrorToast()
}
} catch (err) {
showErrorToast()
} finally {
textarea.remove()
}
}
const copyToClipboard = async (text: string) => {
try {
await copy(text)
if (copied.value) {
showSuccessToast()
} else {
// If VueUse copy failed, try fallback
fallbackCopy(text)
if (isSupported) {
try {
await copy(text)
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
} catch (err) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
} catch (err) {
// VueUse copy failed, try fallback
fallbackCopy(text)
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorNotSupported')
})
}
}

View File

@@ -58,26 +58,10 @@ const messages = {
ar: buildLocale(ar, arNodes, arCommands, arSettings)
}
function getDefaultLocale(): string {
const lang = navigator.language
// Handle Chinese variants specifically to distinguish between simplified and traditional
if (lang.startsWith('zh-')) {
return lang.startsWith('zh-TW') ||
lang.startsWith('zh-HK') ||
lang.startsWith('zh-Hant')
? 'zh-TW'
: 'zh'
}
// For other languages, use the existing logic
return lang.split('-')[0] || 'en'
}
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: getDefaultLocale(),
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

View File

@@ -54,10 +54,7 @@ export class CanvasPointer {
* After a flick gesture is complete, the automatic wheel events are sent with
* reduced frequency, but much higher deltaX and deltaY values.
*/
static trackpadMaxGap = 500
/** The maximum time in milliseconds to buffer a high-res wheel event. */
static maxHighResBufferTime = 10
static trackpadMaxGap = 200
/** The element this PointerState should capture input against when dragging. */
element: Element
@@ -93,23 +90,8 @@ export class CanvasPointer {
/** The last pointerup event for the primary button */
eUp?: CanvasPointerEvent
/** Currently detected input device type */
detectedDevice: 'mouse' | 'trackpad' = 'mouse'
/** Timestamp of last wheel event for cooldown tracking */
lastWheelEventTime: number = 0
/** Flag to track if we've received the first wheel event */
hasReceivedWheelEvent: boolean = false
/** Buffered Linux wheel event awaiting confirmation */
bufferedLinuxEvent?: WheelEvent
/** Timestamp when Linux event was buffered */
bufferedLinuxEventTime: number = 0
/** Timer ID for Linux buffer clearing */
linuxBufferTimeoutId?: ReturnType<typeof setTimeout>
/** The last pointermove event that was treated as a trackpad gesture. */
lastTrackpadEvent?: WheelEvent
/**
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
@@ -291,179 +273,33 @@ export class CanvasPointer {
delete this.onDragStart
}
/**
* Checks if the given wheel event is part of a continued trackpad gesture.
* @param e The wheel event to check
* @returns `true` if the event is part of a continued trackpad gesture, otherwise `false`
*/
#isContinuationOfGesture(e: WheelEvent): boolean {
const { lastTrackpadEvent } = this
if (!lastTrackpadEvent) return false
return (
e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap
)
}
/**
* Checks if the given wheel event is part of a trackpad gesture.
* This method now uses the new device detection internally for improved accuracy.
* @param e The wheel event to check
* @returns `true` if the event is part of a trackpad gesture, otherwise `false`
*/
isTrackpadGesture(e: WheelEvent): boolean {
// Use the new device detection
const now = performance.now()
const timeSinceLastEvent = Math.max(0, now - this.lastWheelEventTime)
this.lastWheelEventTime = now
if (this.#isHighResWheelEvent(e, now)) {
this.detectedDevice = 'mouse'
} else if (this.#isWithinCooldown(timeSinceLastEvent)) {
if (this.#shouldBufferLinuxEvent(e)) {
this.#bufferLinuxEvent(e, now)
}
} else {
this.#updateDeviceMode(e, now)
this.hasReceivedWheelEvent = true
}
return this.detectedDevice === 'trackpad'
}
/**
* Validates buffered high res wheel events and switches to mouse mode if pattern matches.
* @returns `true` if switched to mouse mode
*/
#isHighResWheelEvent(event: WheelEvent, now: number): boolean {
if (!this.bufferedLinuxEvent || this.bufferedLinuxEventTime <= 0) {
return false
}
const timeSinceBuffer = now - this.bufferedLinuxEventTime
if (timeSinceBuffer > CanvasPointer.maxHighResBufferTime) {
this.#clearLinuxBuffer()
return false
}
if (
event.deltaX === 0 &&
this.#isLinuxWheelPattern(this.bufferedLinuxEvent.deltaY, event.deltaY)
) {
this.#clearLinuxBuffer()
if (this.#isContinuationOfGesture(e)) {
this.lastTrackpadEvent = e
return true
}
return false
}
/**
* Checks if we're within the cooldown period where mode switching is disabled.
*/
#isWithinCooldown(timeSinceLastEvent: number): boolean {
const isFirstEvent = !this.hasReceivedWheelEvent
const cooldownExpired = timeSinceLastEvent >= CanvasPointer.trackpadMaxGap
return !isFirstEvent && !cooldownExpired
}
/**
* Updates the device mode based on event patterns.
*/
#updateDeviceMode(event: WheelEvent, now: number): void {
if (this.#isTrackpadPattern(event)) {
this.detectedDevice = 'trackpad'
} else if (this.#isMousePattern(event)) {
this.detectedDevice = 'mouse'
} else if (
this.detectedDevice === 'trackpad' &&
this.#shouldBufferLinuxEvent(event)
) {
this.#bufferLinuxEvent(event, now)
}
}
/**
* Clears the buffered Linux wheel event and associated timer.
*/
#clearLinuxBuffer(): void {
this.bufferedLinuxEvent = undefined
this.bufferedLinuxEventTime = 0
if (this.linuxBufferTimeoutId !== undefined) {
clearTimeout(this.linuxBufferTimeoutId)
this.linuxBufferTimeoutId = undefined
}
}
/**
* Checks if the event matches trackpad input patterns.
* @param event The wheel event to check
*/
#isTrackpadPattern(event: WheelEvent): boolean {
// Two-finger panning: non-zero deltaX AND deltaY
if (event.deltaX !== 0 && event.deltaY !== 0) return true
// Pinch-to-zoom: ctrlKey with small deltaY
if (event.ctrlKey && Math.abs(event.deltaY) < 10) return true
return false
}
/**
* Checks if the event matches mouse wheel input patterns.
* @param event The wheel event to check
*/
#isMousePattern(event: WheelEvent): boolean {
const absoluteDeltaY = Math.abs(event.deltaY)
// Primary threshold for switching from trackpad to mouse
if (absoluteDeltaY > 80) return true
// Secondary threshold when already in mouse mode
return (
absoluteDeltaY >= 60 &&
event.deltaX === 0 &&
this.detectedDevice === 'mouse'
)
}
/**
* Checks if the event should be buffered as a potential Linux wheel event.
* @param event The wheel event to check
*/
#shouldBufferLinuxEvent(event: WheelEvent): boolean {
const absoluteDeltaY = Math.abs(event.deltaY)
const isInLinuxRange = absoluteDeltaY >= 10 && absoluteDeltaY < 60
const isVerticalOnly = event.deltaX === 0
const hasIntegerDelta = Number.isInteger(event.deltaY)
return (
this.detectedDevice === 'trackpad' &&
isInLinuxRange &&
isVerticalOnly &&
hasIntegerDelta
)
}
/**
* Buffers a potential Linux wheel event for later confirmation.
* @param event The event to buffer
* @param now The current timestamp
*/
#bufferLinuxEvent(event: WheelEvent, now: number): void {
if (this.linuxBufferTimeoutId !== undefined) {
clearTimeout(this.linuxBufferTimeoutId)
}
this.bufferedLinuxEvent = event
this.bufferedLinuxEventTime = now
// Set timeout to clear buffer after 10ms
this.linuxBufferTimeoutId = setTimeout(() => {
this.#clearLinuxBuffer()
}, CanvasPointer.maxHighResBufferTime)
}
/**
* Checks if two deltaY values follow a Linux wheel pattern (divisibility).
* @param deltaY1 The first deltaY value
* @param deltaY2 The second deltaY value
*/
#isLinuxWheelPattern(deltaY1: number, deltaY2: number): boolean {
const absolute1 = Math.abs(deltaY1)
const absolute2 = Math.abs(deltaY2)
if (absolute1 === 0 || absolute2 === 0) return false
if (absolute1 === absolute2) return true
// Check if one value is a multiple of the other
return absolute1 % absolute2 === 0 || absolute2 % absolute1 === 0
const threshold = CanvasPointer.trackpadThreshold
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
}
/**

View File

@@ -36,7 +36,6 @@ import type {
INodeSlot,
INodeSlotContextItem,
ISlotType,
LinkNetwork,
LinkSegment,
NullableProperties,
Point,
@@ -2576,27 +2575,16 @@ export class LGraphCanvas
} else if (!node.flags.collapsed) {
const { inputs, outputs } = node
function hasRelevantOutputLinks(
output: INodeOutputSlot,
network: LinkNetwork
): boolean {
const outputLinks = [
...(output.links ?? []),
...[...(output._floatingLinks ?? new Set())]
]
return outputLinks.some(
(linkId) =>
typeof linkId === 'number' && network.getLink(linkId) !== undefined
)
}
// Outputs
if (outputs) {
for (const [i, output] of outputs.entries()) {
const link_pos = node.getOutputPos(i)
if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) {
// Drag multiple output links
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
if (
e.shiftKey &&
(output.links?.length || output._floatingLinks?.size)
) {
linkConnector.moveOutputLink(graph, output)
this.#linkConnectorDrop()
return
@@ -3498,11 +3486,8 @@ export class LGraphCanvas
// Detect if this is a trackpad gesture or mouse wheel
const isTrackpad = this.pointer.isTrackpadGesture(e)
const isCtrlOrMacMeta =
e.ctrlKey || (e.metaKey && navigator.platform.includes('Mac'))
const isZoomModifier = isCtrlOrMacMeta && !e.altKey && !e.shiftKey
if (isZoomModifier || LiteGraph.canvasNavigationMode === 'legacy') {
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
// Legacy mode or standard mode with ctrl - use wheel for zoom
if (isTrackpad) {
// Trackpad gesture - use smooth scaling

View File

@@ -314,27 +314,6 @@ export class LinkConnector {
this.outputLinks.push(link)
try {
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
if (!(network instanceof Subgraph)) {
console.warn(
'Subgraph output link found in non-subgraph network.'
)
continue
}
const output = network.outputs.at(link.target_slot)
if (!output) throw new Error('No subgraph output found for link.')
const renderLink = new ToOutputFromIoNodeLink(
network,
network.outputNode,
output
)
renderLink.fromDirection = LinkDirection.NONE
renderLinks.push(renderLink)
continue
}
const renderLink = new MovingOutputLink(
network,
link,

View File

@@ -187,7 +187,7 @@ export { LGraphButton, type LGraphButtonOptions } from './LGraphButton'
export { MovingOutputLink } from './canvas/MovingOutputLink'
export { ToOutputRenderLink } from './canvas/ToOutputRenderLink'
export { ToInputFromIoNodeLink } from './canvas/ToInputFromIoNodeLink'
export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
export type { TWidgetType, IWidgetOptions } from './types/widgets'
export {
findUsedSubgraphIds,
getDirectSubgraphIds,

File diff suppressed because it is too large Load Diff

View File

@@ -272,7 +272,6 @@
"category": "الفئة",
"choose_file_to_upload": "اختر ملفاً للرفع",
"clear": "مسح",
"clearAll": "مسح الكل",
"clearFilters": "مسح الفلاتر",
"close": "إغلاق",
"color": "اللون",
@@ -328,8 +327,6 @@
"installed": "مثبت",
"installing": "جارٍ التثبيت",
"interrupted": "تمت المقاطعة",
"itemSelected": "تم تحديد عنصر واحد",
"itemsSelected": "تم تحديد {selectedCount} عناصر",
"keybinding": "اختصار لوحة المفاتيح",
"keybindingAlreadyExists": "الاختصار موجود بالفعل في",
"learnMore": "اعرف المزيد",

View File

@@ -137,11 +137,8 @@
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear",
"clearAll": "Clear all",
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on",
"startRecording": "Start Recording",

View File

@@ -272,7 +272,6 @@
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
"clearAll": "Borrar todo",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
"color": "Color",
@@ -328,8 +327,6 @@
"installed": "Instalado",
"installing": "Instalando",
"interrupted": "Interrumpido",
"itemSelected": "{selectedCount} elemento seleccionado",
"itemsSelected": "{selectedCount} elementos seleccionados",
"keybinding": "Combinación de teclas",
"keybindingAlreadyExists": "La combinación de teclas ya existe en",
"learnMore": "Aprende más",

View File

@@ -272,7 +272,6 @@
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"clear": "Effacer",
"clearAll": "Tout effacer",
"clearFilters": "Effacer les filtres",
"close": "Fermer",
"color": "Couleur",
@@ -328,8 +327,6 @@
"installed": "Installé",
"installing": "Installation",
"interrupted": "Interrompu",
"itemSelected": "{selectedCount} élément sélectionné",
"itemsSelected": "{selectedCount} éléments sélectionnés",
"keybinding": "Raccourci clavier",
"keybindingAlreadyExists": "Le raccourci clavier existe déjà",
"learnMore": "En savoir plus",

View File

@@ -272,7 +272,6 @@
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"clearAll": "すべてクリア",
"clearFilters": "フィルターをクリア",
"close": "閉じる",
"color": "色",
@@ -328,8 +327,6 @@
"installed": "インストール済み",
"installing": "インストール中",
"interrupted": "中断されました",
"itemSelected": "{selectedCount}件選択済み",
"itemsSelected": "{selectedCount}件選択済み",
"keybinding": "キーバインディング",
"keybindingAlreadyExists": "このキー割り当てはすでに存在します",
"learnMore": "詳細を学ぶ",

View File

@@ -272,7 +272,6 @@
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"clearAll": "모두 지우기",
"clearFilters": "필터 지우기",
"close": "닫기",
"color": "색상",
@@ -328,8 +327,6 @@
"installed": "설치됨",
"installing": "설치 중",
"interrupted": "중단됨",
"itemSelected": "{selectedCount}개 선택됨",
"itemsSelected": "{selectedCount}개 선택됨",
"keybinding": "키 바인딩",
"keybindingAlreadyExists": "단축키가 이미 존재합니다",
"learnMore": "더 알아보기",

View File

@@ -272,7 +272,6 @@
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
"clearAll": "Очистить всё",
"clearFilters": "Сбросить фильтры",
"close": "Закрыть",
"color": "Цвет",
@@ -328,8 +327,6 @@
"installed": "Установлено",
"installing": "Установка",
"interrupted": "Прервано",
"itemSelected": "Выбран {selectedCount} элемент",
"itemsSelected": "Выбрано {selectedCount} элементов",
"keybinding": "Привязка клавиш",
"keybindingAlreadyExists": "Горячая клавиша уже существует",
"learnMore": "Узнать больше",

View File

@@ -272,7 +272,6 @@
"category": "分類",
"choose_file_to_upload": "選擇要上傳的檔案",
"clear": "清除",
"clearAll": "全部清除",
"clearFilters": "清除篩選",
"close": "關閉",
"color": "顏色",
@@ -328,8 +327,6 @@
"installed": "已安裝",
"installing": "安裝中",
"interrupted": "已中斷",
"itemSelected": "已選取 {selectedCount} 項",
"itemsSelected": "已選取 {selectedCount} 項",
"keybinding": "快捷鍵",
"keybindingAlreadyExists": "快捷鍵已存在於",
"learnMore": "了解更多",

View File

@@ -272,7 +272,6 @@
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"clearAll": "全部清除",
"clearFilters": "清除筛选",
"close": "关闭",
"color": "颜色",
@@ -328,8 +327,6 @@
"installed": "已安装",
"installing": "正在安装",
"interrupted": "已中断",
"itemSelected": "已选择 {selectedCount} 项",
"itemsSelected": "已选择 {selectedCount} 项",
"keybinding": "按键绑定",
"keybindingAlreadyExists": "快捷键已存在",
"learnMore": "了解更多",

View File

@@ -232,13 +232,7 @@ export const zComfyNodeDef = z.object({
* Comfy Org account.
* https://docs.comfy.org/tutorials/api-nodes/overview
*/
api_node: z.boolean().optional(),
/**
* Specifies the order of inputs for each input category.
* Used to ensure consistent widget ordering regardless of JSON serialization.
* Keys are 'required', 'optional', etc., values are arrays of input names.
*/
input_order: z.record(z.array(z.string())).optional()
api_node: z.boolean().optional()
})
// `/object_info`

View File

@@ -50,7 +50,6 @@ import {
isVideoNode,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
import { getOrderedInputSpecs } from '@/utils/nodeDefOrderingUtil'
import { useExtensionService } from './extensionService'
@@ -249,14 +248,9 @@ export const useLitegraphService = () => {
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
// Use input_order if available to ensure consistent widget ordering
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
// Create sockets and widgets in the determined order
for (const inputSpec of orderedInputSpecs)
for (const inputSpec of Object.values(inputs))
this.#addInputSocket(inputSpec)
for (const inputSpec of orderedInputSpecs)
for (const inputSpec of Object.values(inputs))
this.#addInputWidget(inputSpec)
}
@@ -514,14 +508,9 @@ export const useLitegraphService = () => {
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
// Use input_order if available to ensure consistent widget ordering
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
// Create sockets and widgets in the determined order
for (const inputSpec of orderedInputSpecs)
for (const inputSpec of Object.values(inputs))
this.#addInputSocket(inputSpec)
for (const inputSpec of orderedInputSpecs)
for (const inputSpec of Object.values(inputs))
this.#addInputWidget(inputSpec)
}

View File

@@ -63,10 +63,6 @@ export class ComfyNodeDefImpl
* @deprecated Use `outputs[n].tooltip` instead
*/
readonly output_tooltips?: string[]
/**
* Order of inputs for each category (required, optional, hidden)
*/
readonly input_order?: Record<string, string[]>
// V2 fields
readonly inputs: Record<string, InputSpecV2>
@@ -134,7 +130,6 @@ export class ComfyNodeDefImpl
this.output_is_list = obj.output_is_list
this.output_name = obj.output_name
this.output_tooltips = obj.output_tooltips
this.input_order = obj.input_order
// Initialize V2 fields
const defV2 = transformNodeDefV1ToV2(obj)

View File

@@ -1,14 +1,13 @@
import type { HTMLAttributes } from 'vue'
export interface BaseButtonProps {
size?: 'fit-content' | 'sm' | 'md'
size?: 'sm' | 'md'
type?: 'primary' | 'secondary' | 'transparent'
class?: HTMLAttributes['class']
}
export const getButtonSizeClasses = (size: BaseButtonProps['size'] = 'md') => {
const sizeClasses = {
'fit-content': '',
sm: 'px-2 py-1.5 text-xs',
md: 'px-2.5 py-2 text-sm'
}
@@ -32,7 +31,6 @@ export const getIconButtonSizeClasses = (
size: BaseButtonProps['size'] = 'md'
) => {
const sizeClasses = {
'fit-content': 'w-auto h-auto',
sm: 'w-6 h-6 text-xs !rounded-md',
md: 'w-8 h-8 text-sm'
}

View File

@@ -1,108 +0,0 @@
import { TWidgetValue } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
/**
* Gets an ordered array of InputSpec objects based on input_order.
* This is designed to work with V2 format used by litegraphService.
*
* @param nodeDefImpl - The ComfyNodeDefImpl containing both V1 and V2 formats
* @param inputs - The V2 format inputs (flat Record<string, InputSpec>)
* @returns Array of InputSpec objects in the correct order
*/
export function getOrderedInputSpecs(
nodeDefImpl: ComfyNodeDefImpl,
inputs: Record<string, InputSpec>
): InputSpec[] {
const orderedInputSpecs: InputSpec[] = []
// If no input_order, return default Object.values order
if (!nodeDefImpl.input_order) {
return Object.values(inputs)
}
// Process required inputs in specified order
if (nodeDefImpl.input_order.required) {
for (const name of nodeDefImpl.input_order.required) {
const inputSpec = inputs[name]
if (inputSpec && !inputSpec.isOptional) {
orderedInputSpecs.push(inputSpec)
}
}
}
// Process optional inputs in specified order
if (nodeDefImpl.input_order.optional) {
for (const name of nodeDefImpl.input_order.optional) {
const inputSpec = inputs[name]
if (inputSpec && inputSpec.isOptional) {
orderedInputSpecs.push(inputSpec)
}
}
}
// Add any remaining inputs not specified in input_order
const processedNames = new Set(orderedInputSpecs.map((spec) => spec.name))
for (const inputSpec of Object.values(inputs)) {
if (!processedNames.has(inputSpec.name)) {
orderedInputSpecs.push(inputSpec)
}
}
return orderedInputSpecs
}
/**
* Reorders widget values based on the input_order to match expected widget order.
* This is used when widgets were created in a different order than input_order specifies.
*
* @param widgetValues - The current widget values array
* @param currentWidgetOrder - The current order of widget names
* @param inputOrder - The desired order from input_order
* @returns Reordered widget values array
*/
export function sortWidgetValuesByInputOrder(
widgetValues: TWidgetValue[],
currentWidgetOrder: string[],
inputOrder: string[]
): TWidgetValue[] {
if (!inputOrder || inputOrder.length === 0) {
return widgetValues
}
// Create a map of widget name to value
const valueMap = new Map<string, TWidgetValue>()
currentWidgetOrder.forEach((name, index) => {
if (index < widgetValues.length) {
valueMap.set(name, widgetValues[index])
}
})
// Reorder based on input_order
const reordered: TWidgetValue[] = []
const usedNames = new Set<string>()
// First, add values in the order specified by input_order
for (const name of inputOrder) {
if (valueMap.has(name)) {
reordered.push(valueMap.get(name))
usedNames.add(name)
}
}
// Then add any remaining values not in input_order
for (const [name, value] of valueMap.entries()) {
if (!usedNames.has(name)) {
reordered.push(value)
}
}
// If there are extra values not in the map, append them
if (widgetValues.length > currentWidgetOrder.length) {
for (let i = currentWidgetOrder.length; i < widgetValues.length; i++) {
reordered.push(widgetValues[i])
}
}
return reordered
}

View File

@@ -1,162 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import { sortWidgetValuesByInputOrder } from '@/utils/nodeDefOrderingUtil'
describe('LGraphNode widget ordering', () => {
let node: LGraphNode
beforeEach(() => {
node = new LGraphNode('TestNode')
})
describe('configure with widgets_values', () => {
it('should apply widget values in correct order when widgets order matches input_order', () => {
// Create node with widgets
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('number', 'seed', 0, null, {})
node.addWidget('text', 'prompt', '', null, {})
// Configure with widget values
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345, 'test prompt']
}
node.configure(info)
// Check widget values are applied correctly
expect(node.widgets![0].value).toBe(30) // steps
expect(node.widgets![1].value).toBe(12345) // seed
expect(node.widgets![2].value).toBe('test prompt') // prompt
})
it('should handle mismatched widget order with input_order', () => {
// Simulate widgets created in wrong order (e.g., from unordered Object.entries)
// but widgets_values is in the correct order according to input_order
node.addWidget('number', 'seed', 0, null, {})
node.addWidget('text', 'prompt', '', null, {})
node.addWidget('number', 'steps', 20, null, {})
// Widget values are in input_order: [steps, seed, prompt]
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345, 'test prompt']
}
// This would apply values incorrectly without proper ordering
node.configure(info)
// Without fix, values would be applied in wrong order:
// seed (widget[0]) would get 30 (should be 12345)
// prompt (widget[1]) would get 12345 (should be 'test prompt')
// steps (widget[2]) would get 'test prompt' (should be 30)
// This test demonstrates the bug - values are applied in wrong order
expect(node.widgets![0].value).toBe(30) // seed gets steps value (WRONG)
expect(node.widgets![1].value).toBe(12345) // prompt gets seed value (WRONG)
expect(node.widgets![2].value).toBe('test prompt') // steps gets prompt value (WRONG)
})
it('should skip widgets with serialize: false', () => {
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('button', 'action', 'Click', null, {})
node.widgets![1].serialize = false // button should not serialize
node.addWidget('number', 'seed', 0, null, {})
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345] // Only serializable widgets
}
node.configure(info)
expect(node.widgets![0].value).toBe(30) // steps
expect(node.widgets![1].value).toBe('Click') // button unchanged
expect(node.widgets![2].value).toBe(12345) // seed
})
})
})
describe('sortWidgetValuesByInputOrder', () => {
it('should reorder widget values based on input_order', () => {
const inputOrder = ['steps', 'seed', 'prompt']
const currentWidgetOrder = ['seed', 'prompt', 'steps']
const widgetValues = [12345, 'test prompt', 30]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should reorder to match input_order: [steps, seed, prompt]
expect(reordered).toEqual([30, 12345, 'test prompt'])
})
it('should handle widgets not in input_order', () => {
const inputOrder = ['steps', 'seed']
const currentWidgetOrder = ['seed', 'prompt', 'steps', 'cfg']
const widgetValues = [12345, 'test prompt', 30, 7.5]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should put ordered items first, then unordered
expect(reordered).toEqual([30, 12345, 'test prompt', 7.5])
})
it('should handle empty input_order', () => {
const inputOrder: string[] = []
const currentWidgetOrder = ['seed', 'prompt', 'steps']
const widgetValues = [12345, 'test prompt', 30]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should return values unchanged
expect(reordered).toEqual([12345, 'test prompt', 30])
})
it('should handle mismatched array lengths', () => {
const inputOrder = ['steps', 'seed', 'prompt']
const currentWidgetOrder = ['seed', 'prompt']
const widgetValues = [12345, 'test prompt', 30] // Extra value
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should handle gracefully, keeping extra values at the end
// Since 'steps' is not in currentWidgetOrder, it won't be reordered
// Only 'seed' and 'prompt' will be reordered based on input_order
expect(reordered).toEqual([12345, 'test prompt', 30])
})
})

View File

@@ -1,274 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import {
getOrderedInputSpecs,
sortWidgetValuesByInputOrder
} from '@/utils/nodeDefOrderingUtil'
describe('nodeDefOrderingUtil', () => {
describe('getOrderedInputSpecs', () => {
it('should maintain order when no input_order is specified', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }],
steps: ['INT', { default: 20 }]
}
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
// Should maintain Object.values order when no input_order
const names = result.map((spec) => spec.name)
expect(names).toEqual(['image', 'seed', 'steps'])
})
it('should sort inputs according to input_order', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }],
steps: ['INT', { default: 20 }]
}
},
input_order: {
required: ['steps', 'seed', 'image']
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
const names = result.map((spec) => spec.name)
expect(names).toEqual(['steps', 'seed', 'image'])
})
it('should handle missing inputs in input_order gracefully', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }],
steps: ['INT', { default: 20 }]
}
},
input_order: {
required: ['steps', 'nonexistent', 'seed']
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
// Should skip nonexistent and include image at the end
const names = result.map((spec) => spec.name)
expect(names).toEqual(['steps', 'seed', 'image'])
})
it('should handle inputs not in input_order', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }],
steps: ['INT', { default: 20 }],
cfg: ['FLOAT', { default: 7.0 }]
}
},
input_order: {
required: ['steps', 'seed']
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
// Should have ordered ones first, then remaining
const names = result.map((spec) => spec.name)
expect(names).toEqual(['steps', 'seed', 'image', 'cfg'])
})
it('should handle both required and optional inputs', () => {
const nodeDef: ComfyNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'test',
description: 'Test node',
output_node: false,
input: {
required: {
image: ['IMAGE', {}],
seed: ['INT', { default: 0 }]
},
optional: {
mask: ['MASK', {}],
strength: ['FLOAT', { default: 1.0 }]
}
},
input_order: {
required: ['seed', 'image'],
optional: ['strength', 'mask']
}
}
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
const names = result.map((spec) => spec.name)
const optionalFlags = result.map((spec) => spec.isOptional)
expect(names).toEqual(['seed', 'image', 'strength', 'mask'])
expect(optionalFlags).toEqual([false, false, true, true])
})
it('should work with real KSampler node example', () => {
// Simulating different backend orderings
const kSamplerDefBackendA: ComfyNodeDef = {
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling',
python_module: 'nodes',
description: 'KSampler node',
output_node: false,
input: {
required: {
// Alphabetical order from backend A
cfg: ['FLOAT', { default: 8, min: 0, max: 100 }],
denoise: ['FLOAT', { default: 1, min: 0, max: 1 }],
latent_image: ['LATENT', {}],
model: ['MODEL', {}],
negative: ['CONDITIONING', {}],
positive: ['CONDITIONING', {}],
sampler_name: [['euler', 'euler_cfg_pp'], {}],
scheduler: [['simple', 'sgm_uniform'], {}],
seed: ['INT', { default: 0, min: 0, max: Number.MAX_SAFE_INTEGER }],
steps: ['INT', { default: 20, min: 1, max: 10000 }]
}
},
input_order: {
required: [
'model',
'seed',
'steps',
'cfg',
'sampler_name',
'scheduler',
'positive',
'negative',
'latent_image',
'denoise'
]
}
}
const nodeDefImpl = new ComfyNodeDefImpl(kSamplerDefBackendA)
const result = getOrderedInputSpecs(nodeDefImpl, nodeDefImpl.inputs)
const names = result.map((spec) => spec.name)
// Should follow input_order, not alphabetical
expect(names).toEqual([
'model',
'seed',
'steps',
'cfg',
'sampler_name',
'scheduler',
'positive',
'negative',
'latent_image',
'denoise'
])
})
})
describe('sortWidgetValuesByInputOrder', () => {
it('should reorder widget values to match input_order', () => {
const widgetValues = [0, 'model_ref', 5, 1]
const currentWidgetOrder = ['momentum', 'model', 'norm_threshold', 'eta']
const correctOrder = ['model', 'eta', 'norm_threshold', 'momentum']
const result = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
correctOrder
)
expect(result).toEqual(['model_ref', 1, 5, 0])
})
it('should handle missing widgets in input_order', () => {
const widgetValues = [1, 2, 3, 4]
const currentWidgetOrder = ['a', 'b', 'c', 'd']
const inputOrder = ['b', 'd'] // Only partial order
const result = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// b=2, d=4, then a=1, c=3
expect(result).toEqual([2, 4, 1, 3])
})
it('should handle extra widget values', () => {
const widgetValues = [1, 2, 3, 4, 5] // More values than names
const currentWidgetOrder = ['a', 'b', 'c']
const inputOrder = ['c', 'a', 'b']
const result = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// c=3, a=1, b=2, then extras 4, 5
expect(result).toEqual([3, 1, 2, 4, 5])
})
it('should return unchanged when no input_order', () => {
const widgetValues = [1, 2, 3]
const currentWidgetOrder = ['a', 'b', 'c']
const inputOrder: string[] = []
const result = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
expect(result).toEqual([1, 2, 3])
})
})
})