Compare commits

...

12 Commits

Author SHA1 Message Date
Terry Jia
db39a13405 add support for cmd + wheel to zoom in/out on Mac 2025-08-20 21:12:48 -04:00
Jin Yi
11f5439d29 [feat] Add comprehensive Storybook stories for custom UI components (#5098) 2025-08-21 08:41:54 +09:00
Benjamin Lu
4e8f665a19 [bugfix] Remove empty title field from issue templates (#5136)
Removed `title: ''` from bug report and feature request templates

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-20 16:01:24 -07:00
Johnpaul Chiwetelu
20b0927783 Fix CopyToClipboard Issue (#5109)
* feat: enhance clipboard functionality with fallback support

* feat: refactor toast notifications for clipboard copy functionality

* refactor: simplify clipboard fallback logic by removing support check

* refactor: improve fallback copy textarea styling for better accessibility
2025-08-20 12:26:30 -07:00
filtered
e789227420 Add support for high-resolution wheel events (#5092)
* Add high res wheel event handling

Attempts to resolve high res wheel event handling.  First pass.

* [Test] Add comprehensive TDD tests for device detection spec

* Implement efficient timestamp-based device detection for mouse/trackpad

- Add timestamp-based detection without creating timers on every event
- Implement 500ms cooldown period to prevent rapid mode switching
- Support Linux wheel event buffering with divisibility detection
- Maintain backward compatibility with isTrackpadGesture()
- All 69 device detection tests passing

* Remove magic number and unused code from device detection

- Replace hardcoded 500ms with CanvasPointer.trackpadMaxGap constant
- Update trackpadMaxGap from 200ms to 500ms for cooldown period
- Remove unused lastIntegerDelta property that was only set but never read
- Update tests to remove references to removed property

* Update old CanvasPointer tests to match new device detection behavior

- Update tests to require two-finger panning (deltaX && deltaY) for trackpad detection
- Fix expectations to match new default mouse mode behavior
- Small values alone no longer automatically mean trackpad
- All 15 legacy tests now pass with new implementation

* Consolidate CanvasPointer tests and remove redundant test file

- Add backward compatibility test to comprehensive test file
- Remove old CanvasPointer.test.ts that was created on this branch
- Old file had 15 tests, mostly redundant or testing unused features
- New comprehensive file now has 70 tests with full coverage
- Preserves the only unique test (lastTrackpadEvent backward compatibility)

* Simplify conditional assignment with ternary operator

* Remove redundant code

* Simplify comments to remove redundant explanations for developers

* Refactor device detection for improved readability and maintainability

* Inline immediately-returned variable for conciseness

* Cleanup: Remove redundant code, fix style

* Update test expectations

* Guard against invalid state in event comparison

* Fix node.js setTimeout type issue

Caused by node.js types being loaded globally.

* Remove any type from unit test

* Address PR feedback

- Add static value to handle the high-res maximum buffer time.
- nits
2025-08-20 11:51:29 -07:00
Alexander Brown
4db9e3d7fb Fix: Shift+Click+Drag from outputs with Subgraph outputs (#5115)
* fix: Handle shift+click+drag to collectively move outputs when connected to a subgraph output

* [Bug]: Multiple issues with shift-dragging links to subgraph output node input slots
Fixes #4877
When shift clicking, ignore links that are no longer present in the subgraph.

* cleanup: Utility function to filter for relevant outputs when shift+clicking

* cleanup: Remove some pieces that are redundant in this context.
Different enough to warrant not extracting a common function yet.
2025-08-20 11:22:02 -07:00
Simula_r
1e9d4c7c37 Fix/widget ordering consistency (#5106)
* feat: input ordered nodes

* fix: ensure node input order upon creation using input_order

* refactor: back to the original state of migrations.ts

* refactor: remove console.logs

* test: fix widget ordering tests

* fix: any types
2025-08-20 11:07:40 -07:00
Arjan Singh
180f95182d [fix] reposition TaskItem info #4996 (#5113)
Position sidebar task items at top of tile
2025-08-20 10:16:42 -07:00
Benjamin Lu
bcdb96a727 Remove duplicate semantic labeling from issue templates (#5114)
- Remove [Bug] and [Feature] prefixes from titles
- Remove enhancement label from feature template

Fixes #5100
2025-08-20 09:52:15 -07:00
snomiao
337fb2100a [ci] Add retry logic to wrangler page deploy step (#5118)
- Implement 3-attempt retry mechanism for Cloudflare Pages deployment
- Add 10-second delay between retry attempts
- Install wrangler globally to ensure CLI availability
- Maintain existing functionality while improving stability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-20 09:50:38 -07:00
Jin Yi
2407748425 [feat] Add enhanced filter UI components with search and clear functionality (#5119)
* [feat] Add enhanced filter UI components with search and clear functionality

- Add SearchBox, clear all button, and item count to MultiSelect header
- Add 'fit-content' size option to button types for flexible sizing
- Update SingleSelect and ModelSelector components for consistency
- Add localization strings for item selection and clear all functionality

Co-Authored-By: Claude <noreply@anthropic.com>

* Update locales [skip ci]

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-20 09:36:19 -07:00
Jin Yi
5f349ed3cd chore: storybook-doc added (#5122) 2025-08-20 08:15:43 -07:00
46 changed files with 4535 additions and 97 deletions

View File

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

View File

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

View File

@@ -162,6 +162,9 @@ 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
@@ -177,11 +180,35 @@ jobs:
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
id: cloudflare-deploy
if: always()
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 }}
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 }}
- name: Save deployment info for summary
if: always()

View File

@@ -93,6 +93,44 @@ 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
@@ -135,6 +173,7 @@ 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
@@ -170,4 +209,22 @@ This Storybook setup includes:
- PrimeVue component library integration
- Proper alias resolution for `@/` imports
For component-specific examples, see the NodePreview stories in `src/components/node/`.
## 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.

11
package-lock.json generated
View File

@@ -84,6 +84,7 @@
"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",
@@ -12224,6 +12225,16 @@
"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

@@ -84,7 +84,8 @@
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
"zod-to-json-schema": "^3.24.1",
"lucide-vue-next": "^0.540.0"
},
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -0,0 +1,145 @@
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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,221 @@
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

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,83 @@
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

@@ -0,0 +1,665 @@
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,8 +31,12 @@ const containerClasses = computed(() => {
return `${baseClasses} ${ratioClasses[ratio]}`
})
const containerStyle = computed(() => ({
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}))
const containerStyle = computed(() =>
maxWidth || minWidth
? {
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}
: {}
)
</script>

View File

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,151 @@
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,6 +9,41 @@
: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">
@@ -42,7 +77,7 @@
</template>
</MultiSelect>
<!-- Selected count badge (unchanged) -->
<!-- Selected count badge -->
<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"
@@ -58,22 +93,41 @@ import MultiSelect, {
} from 'primevue/multiselect'
import { computed } from 'vue'
const { label, options } = defineProps<{
label?: string
options: { name: string; value: string }[]
}>()
import SearchBox from '@/components/input/SearchBox.vue'
const selectedItems = defineModel<{ name: string; value: string }[]>({
import TextButton from '../button/TextButton.vue'
type Option = { name: string; value: string }
interface Props {
/** Input label shown on the trigger button */
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>()
const selectedItems = defineModel<Option[]>({
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: [
@@ -97,19 +151,19 @@ const pt = computed(() => ({
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: { class: 'hidden' },
header: () => ({
class:
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : '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',
'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',
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

@@ -0,0 +1,33 @@
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,8 +1,6 @@
<template>
<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" />
<div :class="wrapperStyle">
<i-lucide:search :class="iconColorStyle" />
<InputText
v-model="searchQuery"
:placeholder="placeHolder || 'Search...'"
@@ -15,10 +13,21 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { defineModel } from 'vue'
import { computed, defineModel } from 'vue'
const { placeHolder } = defineProps<{
const { placeHolder, hasBorder = false } = 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

@@ -0,0 +1,116 @@
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'
'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'
]
},
list: {

View File

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

View File

@@ -59,6 +59,10 @@
<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

@@ -0,0 +1,556 @@
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

@@ -0,0 +1,138 @@
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,32 +4,64 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const { copy, copied } = 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) => {
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')
})
try {
await copy(text)
if (copied.value) {
showSuccessToast()
} else {
// If VueUse copy failed, try fallback
fallbackCopy(text)
}
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorNotSupported')
})
} catch (err) {
// VueUse copy failed, try fallback
fallbackCopy(text)
}
}

View File

@@ -54,7 +54,10 @@ 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 = 200
static trackpadMaxGap = 500
/** The maximum time in milliseconds to buffer a high-res wheel event. */
static maxHighResBufferTime = 10
/** The element this PointerState should capture input against when dragging. */
element: Element
@@ -90,8 +93,23 @@ export class CanvasPointer {
/** The last pointerup event for the primary button */
eUp?: CanvasPointerEvent
/** The last pointermove event that was treated as a trackpad gesture. */
lastTrackpadEvent?: WheelEvent
/** 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>
/**
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
@@ -273,33 +291,179 @@ 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 {
if (this.#isContinuationOfGesture(e)) {
this.lastTrackpadEvent = e
// 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()
return true
}
const threshold = CanvasPointer.trackpadThreshold
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
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
}
/**

View File

@@ -36,6 +36,7 @@ import type {
INodeSlot,
INodeSlotContextItem,
ISlotType,
LinkNetwork,
LinkSegment,
NullableProperties,
Point,
@@ -2575,16 +2576,27 @@ 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 &&
(output.links?.length || output._floatingLinks?.size)
) {
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
linkConnector.moveOutputLink(graph, output)
this.#linkConnectorDrop()
return
@@ -3487,8 +3499,8 @@ export class LGraphCanvas
// Detect if this is a trackpad gesture or mouse wheel
const isTrackpad = this.pointer.isTrackpadGesture(e)
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
// Legacy mode or standard mode with ctrl - use wheel for zoom
if (e.ctrlKey || e.metaKey || LiteGraph.canvasNavigationMode === 'legacy') {
// Legacy mode or standard mode with ctrl/cmd - use wheel for zoom
if (isTrackpad) {
// Trackpad gesture - use smooth scaling
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18

View File

@@ -314,6 +314,27 @@ 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, IWidgetOptions } from './types/widgets'
export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
export {
findUsedSubgraphIds,
getDirectSubgraphIds,

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -232,7 +232,13 @@ export const zComfyNodeDef = z.object({
* Comfy Org account.
* https://docs.comfy.org/tutorials/api-nodes/overview
*/
api_node: z.boolean().optional()
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()
})
// `/object_info`

View File

@@ -50,6 +50,7 @@ import {
isVideoNode,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
import { getOrderedInputSpecs } from '@/utils/nodeDefOrderingUtil'
import { useExtensionService } from './extensionService'
@@ -248,9 +249,14 @@ export const useLitegraphService = () => {
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const inputSpec of Object.values(inputs))
// 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)
this.#addInputSocket(inputSpec)
for (const inputSpec of Object.values(inputs))
for (const inputSpec of orderedInputSpecs)
this.#addInputWidget(inputSpec)
}
@@ -508,9 +514,14 @@ export const useLitegraphService = () => {
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const inputSpec of Object.values(inputs))
// 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)
this.#addInputSocket(inputSpec)
for (const inputSpec of Object.values(inputs))
for (const inputSpec of orderedInputSpecs)
this.#addInputWidget(inputSpec)
}

View File

@@ -63,6 +63,10 @@ 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>
@@ -130,6 +134,7 @@ 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,13 +1,14 @@
import type { HTMLAttributes } from 'vue'
export interface BaseButtonProps {
size?: 'sm' | 'md'
size?: 'fit-content' | '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'
}
@@ -31,6 +32,7 @@ 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

@@ -0,0 +1,108 @@
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

@@ -0,0 +1,162 @@
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

@@ -0,0 +1,274 @@
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])
})
})
})