mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-28 02:34:10 +00:00
Merge remote-tracking branch 'origin/main' into sno-storybook--settings-panel
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 5V3C14 2.44772 13.5523 2 13 2H11C10.4477 2 10 2.44772 10 3V5C10 5.55228 10.4477 6 11 6H13C13.5523 6 14 5.55228 14 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M6 5V3C6 2.44772 5.55228 2 5 2H3C2.44772 2 2 2.44772 2 3V5C2 5.55228 2.44772 6 3 6H5C5.55228 6 6 5.55228 6 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M14 13V11C14 10.4477 13.5523 10 13 10H11C10.4477 10 10 10.4477 10 11V13C10 13.5523 10.4477 14 11 14H13C13.5523 14 14 13.5523 14 13Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M10 4H6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M10 12H8C5.79086 12 4 10.2091 4 8V6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.99999 4H6.99999M8.99999 12H7.6231C5.02081 12 3.11138 9.55445 3.74252 7.02986L3.99999 6M13.6894 3.24254L13.1894 5.24254C13.0781 5.6877 12.6781 6 12.2192 6H10.2808C9.63019 6 9.15284 5.38861 9.31062 4.75746L9.81062 2.75746C9.92192 2.3123 10.3219 2 10.7808 2H12.7192C13.3698 2 13.8471 2.61139 13.6894 3.24254ZM6.68936 3.24254L6.18936 5.24254C6.07806 5.6877 5.67808 6 5.21921 6H3.28077C2.63019 6 2.15284 5.38861 2.31062 4.75746L2.81062 2.75746C2.92191 2.3123 3.3219 2 3.78077 2H5.71921C6.36978 2 6.84714 2.61139 6.68936 3.24254ZM13.6894 11.2425L13.1894 13.2425C13.0781 13.6877 12.6781 14 12.2192 14H10.2808C9.63019 14 9.15284 13.3886 9.31062 12.7575L9.81062 10.7575C9.92192 10.3123 10.3219 10 10.7808 10H12.7192C13.3698 10 13.8471 10.6114 13.6894 11.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 890 B After Width: | Height: | Size: 910 B |
145
src/components/button/IconButton.stories.ts
Normal file
145
src/components/button/IconButton.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
35
src/components/button/IconGroup.stories.ts
Normal file
35
src/components/button/IconGroup.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
221
src/components/button/IconTextButton.stories.ts
Normal file
221
src/components/button/IconTextButton.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
50
src/components/button/MoreButton.stories.ts
Normal file
50
src/components/button/MoreButton.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
83
src/components/button/TextButton.stories.ts
Normal file
83
src/components/button/TextButton.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
665
src/components/card/Card.stories.ts
Normal file
665
src/components/card/Card.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
36
src/components/chip/SquareChip.stories.ts
Normal file
36
src/components/chip/SquareChip.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
<ColorPickerButton />
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<EditModelButton />
|
||||
<Load3DViewerButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
@@ -35,7 +34,6 @@ import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isImageOutputSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_AddEditModelStep.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-pen-to-square"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.AddEditModelStep')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isImageOutputOrEditModelNode = (node: unknown) =>
|
||||
isLGraphNode(node) &&
|
||||
(isImageNode(node) || node.type === 'workflow>FLUX.1 Kontext Image Edit')
|
||||
|
||||
const isImageOutputSelected = computed(
|
||||
() =>
|
||||
canvasStore.selectedItems.length === 1 &&
|
||||
isImageOutputOrEditModelNode(canvasStore.selectedItems[0])
|
||||
)
|
||||
</script>
|
||||
151
src/components/input/MultiSelect.stories.ts
Normal file
151
src/components/input/MultiSelect.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
33
src/components/input/SearchBox.stories.ts
Normal file
33
src/components/input/SearchBox.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
116
src/components/input/SingleSelect.stories.ts
Normal file
116
src/components/input/SingleSelect.stories.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
556
src/components/widget/layout/BaseWidget.stories.ts
Normal file
556
src/components/widget/layout/BaseWidget.stories.ts
Normal 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
|
||||
}
|
||||
}
|
||||
138
src/components/widget/nav/Navigation.stories.ts
Normal file
138
src/components/widget/nav/Navigation.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { addFluxKontextGroupNode } from '@/scripts/fluxKontextEditNode'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
@@ -775,17 +774,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.AddEditModelStep',
|
||||
icon: 'pi pi-pen-to-square',
|
||||
label: 'Add Edit Model Step',
|
||||
versionAdded: '1.23.3',
|
||||
function: async () => {
|
||||
const node = app.canvas.selectedItems.values().next().value
|
||||
if (!(node instanceof LGraphNode)) return
|
||||
await addFluxKontextGroupNode(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ConvertToSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
@@ -3486,8 +3498,11 @@ export class LGraphCanvas
|
||||
|
||||
// Detect if this is a trackpad gesture or mouse wheel
|
||||
const isTrackpad = this.pointer.isTrackpadGesture(e)
|
||||
const isCtrlOrMacMeta =
|
||||
e.ctrlKey || (e.metaKey && navigator.platform.includes('Mac'))
|
||||
const isZoomModifier = isCtrlOrMacMeta && !e.altKey && !e.shiftKey
|
||||
|
||||
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
|
||||
if (isZoomModifier || LiteGraph.canvasNavigationMode === 'legacy') {
|
||||
// Legacy mode or standard mode with ctrl - use wheel for zoom
|
||||
if (isTrackpad) {
|
||||
// Trackpad gesture - use smooth scaling
|
||||
|
||||
@@ -1925,6 +1925,7 @@ export class LGraphNode
|
||||
}
|
||||
}
|
||||
|
||||
widget.onRemove?.()
|
||||
this.widgets.splice(widgetIndex, 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
1220
src/lib/litegraph/test/CanvasPointer.deviceDetection.test.ts
Normal file
1220
src/lib/litegraph/test/CanvasPointer.deviceDetection.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "تصفح القوالب"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "إضافة خطوة تحرير النموذج"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "حذف العناصر المحددة"
|
||||
},
|
||||
|
||||
@@ -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": "اعرف المزيد",
|
||||
@@ -762,7 +765,6 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "حول ComfyUI",
|
||||
"Add Edit Model Step": "إضافة خطوة تعديل النموذج",
|
||||
"Bottom Panel": "لوحة سفلية",
|
||||
"Browse Templates": "تصفح القوالب",
|
||||
"Bypass/Unbypass Selected Nodes": "تجاوز/إلغاء تجاوز العقد المحددة",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Browse Templates"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Add Edit Model Step"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Delete Selected Items"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -958,7 +961,6 @@
|
||||
"Restart": "Restart",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
|
||||
"Browse Templates": "Browse Templates",
|
||||
"Add Edit Model Step": "Add Edit Model Step",
|
||||
"Delete Selected Items": "Delete Selected Items",
|
||||
"Zoom to fit": "Zoom to fit",
|
||||
"Move Selected Nodes Down": "Move Selected Nodes Down",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Explorar plantillas"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Agregar paso de edición de modelo"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Eliminar elementos seleccionados"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -762,7 +765,6 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "Acerca de ComfyUI",
|
||||
"Add Edit Model Step": "Agregar paso de edición de modelo",
|
||||
"Bottom Panel": "Panel inferior",
|
||||
"Browse Templates": "Explorar plantillas",
|
||||
"Bypass/Unbypass Selected Nodes": "Evitar/No evitar nodos seleccionados",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Parcourir les modèles"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Ajouter/Modifier une étape de modèle"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Supprimer les éléments sélectionnés"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -762,7 +765,6 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "À propos de ComfyUI",
|
||||
"Add Edit Model Step": "Ajouter une étape d’édition de modèle",
|
||||
"Bottom Panel": "Panneau inférieur",
|
||||
"Browse Templates": "Parcourir les modèles",
|
||||
"Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nœuds sélectionnés",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "テンプレートを参照"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "編集モデルステップを追加"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "選択したアイテムを削除"
|
||||
},
|
||||
|
||||
@@ -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": "詳細を学ぶ",
|
||||
@@ -762,7 +765,6 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "ComfyUIについて",
|
||||
"Add Edit Model Step": "モデル編集ステップを追加",
|
||||
"Bottom Panel": "下部パネル",
|
||||
"Browse Templates": "テンプレートを参照",
|
||||
"Bypass/Unbypass Selected Nodes": "選択したノードのバイパス/バイパス解除",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "템플릿 탐색"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "모델 편집 단계 추가"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "선택한 항목 삭제"
|
||||
},
|
||||
|
||||
@@ -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": "더 알아보기",
|
||||
@@ -762,7 +765,6 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "ComfyUI에 대하여",
|
||||
"Add Edit Model Step": "모델 편집 단계 추가",
|
||||
"Bottom Panel": "하단 패널",
|
||||
"Browse Templates": "템플릿 탐색",
|
||||
"Bypass/Unbypass Selected Nodes": "선택한 노드 우회/우회 해제",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Просмотр шаблонов"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Добавить или изменить шаг модели"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Удалить выбранные элементы"
|
||||
},
|
||||
|
||||
@@ -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": "Узнать больше",
|
||||
@@ -762,7 +765,6 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "О ComfyUI",
|
||||
"Add Edit Model Step": "Добавить или изменить шаг модели",
|
||||
"Bottom Panel": "Нижняя панель",
|
||||
"Browse Templates": "Просмотреть шаблоны",
|
||||
"Bypass/Unbypass Selected Nodes": "Обойти/восстановить выбранные ноды",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "瀏覽範本"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "新增編輯模型步驟"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "刪除選取項目"
|
||||
},
|
||||
|
||||
@@ -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": "了解更多",
|
||||
@@ -762,7 +765,6 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "關於 ComfyUI",
|
||||
"Add Edit Model Step": "新增編輯模型步驟",
|
||||
"Bottom Panel": "底部面板",
|
||||
"Browse Templates": "瀏覽範本",
|
||||
"Bypass/Unbypass Selected Nodes": "繞過/取消繞過選取節點",
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "浏览模板"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "添加编辑模型步骤"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "删除选定的项目"
|
||||
},
|
||||
|
||||
@@ -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": "了解更多",
|
||||
@@ -762,7 +765,6 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "关于ComfyUI",
|
||||
"Add Edit Model Step": "添加编辑模型步骤",
|
||||
"Bottom Panel": "底部面板",
|
||||
"Browse Templates": "浏览模板",
|
||||
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1,693 +0,0 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
import {
|
||||
type INodeOutputSlot,
|
||||
type LGraph,
|
||||
type LGraphNode,
|
||||
LLink,
|
||||
LiteGraph,
|
||||
type Point
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { parseFilePath } from '@/utils/formatUtil'
|
||||
|
||||
import { app } from './app'
|
||||
|
||||
const fluxKontextGroupNode = {
|
||||
nodes: [
|
||||
{
|
||||
id: -1,
|
||||
type: 'Reroute',
|
||||
pos: [2354.87890625, -127.23468780517578],
|
||||
size: [75, 26],
|
||||
flags: {},
|
||||
order: 20,
|
||||
mode: 0,
|
||||
inputs: [{ name: '', type: '*', link: null }],
|
||||
outputs: [{ name: '', type: '*', links: null }],
|
||||
properties: { showOutputText: false, horizontal: false },
|
||||
index: 0
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'ReferenceLatent',
|
||||
pos: [2730, -220],
|
||||
size: [197.712890625, 46],
|
||||
flags: {},
|
||||
order: 22,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'conditioning',
|
||||
name: 'conditioning',
|
||||
type: 'CONDITIONING',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'latent',
|
||||
name: 'latent',
|
||||
shape: 7,
|
||||
type: 'LATENT',
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CONDITIONING',
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'ReferenceLatent',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
index: 1
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'VAEDecode',
|
||||
pos: [3270, -110],
|
||||
size: [210, 46],
|
||||
flags: {},
|
||||
order: 25,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'samples',
|
||||
name: 'samples',
|
||||
type: 'LATENT',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'vae',
|
||||
name: 'vae',
|
||||
type: 'VAE',
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'IMAGE',
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'VAEDecode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
index: 2
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'KSampler',
|
||||
pos: [2930, -110],
|
||||
size: [315, 262],
|
||||
flags: {},
|
||||
order: 24,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'model',
|
||||
name: 'model',
|
||||
type: 'MODEL',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'positive',
|
||||
name: 'positive',
|
||||
type: 'CONDITIONING',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'negative',
|
||||
name: 'negative',
|
||||
type: 'CONDITIONING',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'latent_image',
|
||||
name: 'latent_image',
|
||||
type: 'LATENT',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'seed',
|
||||
name: 'seed',
|
||||
type: 'INT',
|
||||
widget: { name: 'seed' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'steps',
|
||||
name: 'steps',
|
||||
type: 'INT',
|
||||
widget: { name: 'steps' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'cfg',
|
||||
name: 'cfg',
|
||||
type: 'FLOAT',
|
||||
widget: { name: 'cfg' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'sampler_name',
|
||||
name: 'sampler_name',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'sampler_name' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'scheduler',
|
||||
name: 'scheduler',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'scheduler' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'denoise',
|
||||
name: 'denoise',
|
||||
type: 'FLOAT',
|
||||
widget: { name: 'denoise' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'LATENT',
|
||||
name: 'LATENT',
|
||||
type: 'LATENT',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'KSampler',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: [972054013131369, 'fixed', 20, 1, 'euler', 'simple', 1],
|
||||
index: 3
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'FluxGuidance',
|
||||
pos: [2940, -220],
|
||||
size: [211.60000610351562, 58],
|
||||
flags: {},
|
||||
order: 23,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'conditioning',
|
||||
name: 'conditioning',
|
||||
type: 'CONDITIONING',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'guidance',
|
||||
name: 'guidance',
|
||||
type: 'FLOAT',
|
||||
widget: { name: 'guidance' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CONDITIONING',
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'FluxGuidance',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: [2.5],
|
||||
index: 4
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'SaveImage',
|
||||
pos: [3490, -110],
|
||||
size: [985.3012084960938, 1060.3828125],
|
||||
flags: {},
|
||||
order: 26,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'images',
|
||||
name: 'images',
|
||||
type: 'IMAGE',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'filename_prefix',
|
||||
name: 'filename_prefix',
|
||||
type: 'STRING',
|
||||
widget: { name: 'filename_prefix' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [],
|
||||
properties: { cnr_id: 'comfy-core', ver: '0.3.38' },
|
||||
widgets_values: ['ComfyUI'],
|
||||
index: 5
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'CLIPTextEncode',
|
||||
pos: [2500, -110],
|
||||
size: [422.84503173828125, 164.31304931640625],
|
||||
flags: {},
|
||||
order: 12,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'clip',
|
||||
name: 'clip',
|
||||
type: 'CLIP',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'text',
|
||||
name: 'text',
|
||||
type: 'STRING',
|
||||
widget: { name: 'text' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CONDITIONING',
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
title: 'CLIP Text Encode (Positive Prompt)',
|
||||
properties: {
|
||||
'Node name for S&R': 'CLIPTextEncode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: ['there is a bright light'],
|
||||
color: '#232',
|
||||
bgcolor: '#353',
|
||||
index: 6
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'CLIPTextEncode',
|
||||
pos: [2504.1435546875, 97.9598617553711],
|
||||
size: [422.84503173828125, 164.31304931640625],
|
||||
flags: { collapsed: true },
|
||||
order: 13,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'clip',
|
||||
name: 'clip',
|
||||
type: 'CLIP',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'text',
|
||||
name: 'text',
|
||||
type: 'STRING',
|
||||
widget: { name: 'text' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CONDITIONING',
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
title: 'CLIP Text Encode (Negative Prompt)',
|
||||
properties: {
|
||||
'Node name for S&R': 'CLIPTextEncode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: [''],
|
||||
color: '#322',
|
||||
bgcolor: '#533',
|
||||
index: 7
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'UNETLoader',
|
||||
pos: [2630, -370],
|
||||
size: [270, 82],
|
||||
flags: {},
|
||||
order: 6,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'unet_name',
|
||||
name: 'unet_name',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'unet_name' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'weight_dtype',
|
||||
name: 'weight_dtype',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'weight_dtype' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'MODEL',
|
||||
name: 'MODEL',
|
||||
type: 'MODEL',
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'UNETLoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: ['flux1-kontext-dev.safetensors', 'default'],
|
||||
color: '#223',
|
||||
bgcolor: '#335',
|
||||
index: 8
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'DualCLIPLoader',
|
||||
pos: [2100, -290],
|
||||
size: [337.76861572265625, 130],
|
||||
flags: {},
|
||||
order: 8,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'clip_name1',
|
||||
name: 'clip_name1',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'clip_name1' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'clip_name2',
|
||||
name: 'clip_name2',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'clip_name2' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'type',
|
||||
name: 'type',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'type' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'device',
|
||||
name: 'device',
|
||||
shape: 7,
|
||||
type: 'COMBO',
|
||||
widget: { name: 'device' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CLIP',
|
||||
name: 'CLIP',
|
||||
type: 'CLIP',
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'DualCLIPLoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: [
|
||||
'clip_l.safetensors',
|
||||
't5xxl_fp8_e4m3fn_scaled.safetensors',
|
||||
'flux',
|
||||
'default'
|
||||
],
|
||||
color: '#223',
|
||||
bgcolor: '#335',
|
||||
index: 9
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'VAELoader',
|
||||
pos: [2960, -370],
|
||||
size: [270, 58],
|
||||
flags: {},
|
||||
order: 7,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'vae_name',
|
||||
name: 'vae_name',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'vae_name' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'VAE',
|
||||
name: 'VAE',
|
||||
type: 'VAE',
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'VAELoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: ['ae.safetensors'],
|
||||
color: '#223',
|
||||
bgcolor: '#335',
|
||||
index: 10
|
||||
}
|
||||
],
|
||||
links: [
|
||||
[6, 0, 1, 0, 72, 'CONDITIONING'],
|
||||
[0, 0, 1, 1, 66, '*'],
|
||||
[3, 0, 2, 0, 69, 'LATENT'],
|
||||
[10, 0, 2, 1, 76, 'VAE'],
|
||||
[8, 0, 3, 0, 74, 'MODEL'],
|
||||
[4, 0, 3, 1, 70, 'CONDITIONING'],
|
||||
[7, 0, 3, 2, 73, 'CONDITIONING'],
|
||||
[0, 0, 3, 3, 66, '*'],
|
||||
[1, 0, 4, 0, 67, 'CONDITIONING'],
|
||||
[2, 0, 5, 0, 68, 'IMAGE'],
|
||||
[9, 0, 6, 0, 75, 'CLIP'],
|
||||
[9, 0, 7, 0, 75, 'CLIP']
|
||||
],
|
||||
external: [],
|
||||
config: {
|
||||
'0': {},
|
||||
'1': {},
|
||||
'2': { output: { '0': { visible: true } } },
|
||||
'3': {
|
||||
output: { '0': { visible: true } },
|
||||
input: {
|
||||
denoise: { visible: false },
|
||||
cfg: { visible: false }
|
||||
}
|
||||
},
|
||||
'4': {},
|
||||
'5': {},
|
||||
'6': {},
|
||||
'7': { input: { text: { visible: false } } },
|
||||
'8': { input: { weight_dtype: { visible: false } } },
|
||||
'9': { input: { type: { visible: false }, device: { visible: false } } },
|
||||
'10': {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureGraphHasFluxKontextGroupNode(
|
||||
graph: LGraph & { extra: { groupNodes?: Record<string, any> } }
|
||||
) {
|
||||
graph.extra ??= {}
|
||||
graph.extra.groupNodes ??= {}
|
||||
if (graph.extra.groupNodes['FLUX.1 Kontext Image Edit']) return
|
||||
|
||||
graph.extra.groupNodes['FLUX.1 Kontext Image Edit'] =
|
||||
structuredClone(fluxKontextGroupNode)
|
||||
|
||||
// Lazy import to avoid circular dependency issues
|
||||
const { GroupNodeConfig } = await import('@/extensions/core/groupNode')
|
||||
await GroupNodeConfig.registerFromWorkflow(
|
||||
{
|
||||
'FLUX.1 Kontext Image Edit':
|
||||
graph.extra.groupNodes['FLUX.1 Kontext Image Edit']
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
export async function addFluxKontextGroupNode(fromNode: LGraphNode) {
|
||||
const { canvas } = app
|
||||
const { graph } = canvas
|
||||
if (!graph) throw new TypeError('Graph is not initialized')
|
||||
await ensureGraphHasFluxKontextGroupNode(graph)
|
||||
|
||||
const node = LiteGraph.createNode('workflow>FLUX.1 Kontext Image Edit')
|
||||
if (!node) throw new TypeError('Failed to create node')
|
||||
|
||||
const pos = getPosToRightOfNode(fromNode)
|
||||
|
||||
graph.add(node)
|
||||
node.pos = pos
|
||||
app.canvas.processSelect(node, undefined)
|
||||
|
||||
connectPreviousLatent(fromNode, node)
|
||||
|
||||
const symb = Object.getOwnPropertySymbols(node)[0]
|
||||
// @ts-expect-error It's there -- promise.
|
||||
node[symb].populateWidgets()
|
||||
|
||||
setWidgetValues(node)
|
||||
}
|
||||
|
||||
function setWidgetValues(node: LGraphNode) {
|
||||
const seedInput = node.widgets?.find((x) => x.name === 'seed')
|
||||
if (!seedInput) throw new TypeError('Seed input not found')
|
||||
seedInput.value = Math.floor(Math.random() * 1_125_899_906_842_624)
|
||||
|
||||
const firstClip = node.widgets?.find((x) => x.name === 'clip_name1')
|
||||
setPreferredValue('t5xxl_fp8_e4m3fn_scaled.safetensors', 't5xxl', firstClip)
|
||||
|
||||
const secondClip = node.widgets?.find((x) => x.name === 'clip_name2')
|
||||
setPreferredValue('clip_l.safetensors', 'clip_l', secondClip)
|
||||
|
||||
const unet = node.widgets?.find((x) => x.name === 'unet_name')
|
||||
setPreferredValue('flux1-dev-kontext_fp8_scaled.safetensors', 'kontext', unet)
|
||||
|
||||
const vae = node.widgets?.find((x) => x.name === 'vae_name')
|
||||
setPreferredValue('ae.safetensors', 'ae.s', vae)
|
||||
}
|
||||
|
||||
function setPreferredValue(
|
||||
preferred: string,
|
||||
match: string,
|
||||
widget: IBaseWidget | undefined
|
||||
): void {
|
||||
if (!widget) throw new TypeError('Widget not found')
|
||||
|
||||
const { values } = widget.options
|
||||
if (!Array.isArray(values)) return
|
||||
|
||||
// Match against filename portion only
|
||||
const mapped = values.map((x) => parseFilePath(x).filename)
|
||||
const value =
|
||||
mapped.find((x) => x === preferred) ??
|
||||
mapped.find((x) => x.includes?.(match))
|
||||
widget.value = value ?? preferred
|
||||
}
|
||||
|
||||
function getPosToRightOfNode(fromNode: LGraphNode) {
|
||||
const nodes = app.canvas.graph?.nodes
|
||||
if (!nodes) throw new TypeError('Could not get graph nodes')
|
||||
|
||||
const pos = [
|
||||
fromNode.pos[0] + fromNode.size[0] + 100,
|
||||
fromNode.pos[1]
|
||||
] satisfies Point
|
||||
|
||||
while (nodes.find((x) => isPointTooClose(x.pos, pos))) {
|
||||
pos[0] += 20
|
||||
pos[1] += 20
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
function connectPreviousLatent(fromNode: LGraphNode, toEditNode: LGraphNode) {
|
||||
const { canvas } = app
|
||||
const { graph } = canvas
|
||||
if (!graph) throw new TypeError('Graph is not initialized')
|
||||
|
||||
const l = findNearestOutputOfType([fromNode], 'LATENT')
|
||||
if (!l) {
|
||||
const imageOutput = findNearestOutputOfType([fromNode], 'IMAGE')
|
||||
if (!imageOutput) throw new TypeError('No image output found')
|
||||
|
||||
const vaeEncode = LiteGraph.createNode('VAEEncode')
|
||||
if (!vaeEncode) throw new TypeError('Failed to create node')
|
||||
|
||||
const { node: imageNode, index: imageIndex } = imageOutput
|
||||
graph.add(vaeEncode)
|
||||
vaeEncode.pos = getPosToRightOfNode(fromNode)
|
||||
vaeEncode.pos[1] -= 200
|
||||
|
||||
vaeEncode.connect(0, toEditNode, 0)
|
||||
imageNode.connect(imageIndex, vaeEncode, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const { node, index } = l
|
||||
|
||||
node.connect(index, toEditNode, 0)
|
||||
}
|
||||
|
||||
function getInputNodes(node: LGraphNode): LGraphNode[] {
|
||||
return node.inputs
|
||||
.map((x) => LLink.resolve(x.link, app.graph)?.outputNode)
|
||||
.filter((x) => !!x)
|
||||
}
|
||||
|
||||
function getOutputOfType(
|
||||
node: LGraphNode,
|
||||
type: string
|
||||
): {
|
||||
output: INodeOutputSlot
|
||||
index: number
|
||||
} {
|
||||
const index = node.outputs.findIndex((x) => x.type === type)
|
||||
const output = node.outputs[index]
|
||||
return { output, index }
|
||||
}
|
||||
|
||||
function findNearestOutputOfType(
|
||||
nodes: Iterable<LGraphNode>,
|
||||
type: string = 'LATENT',
|
||||
depth: number = 0
|
||||
): { node: LGraphNode; index: number } | undefined {
|
||||
for (const node of nodes) {
|
||||
const { output, index } = getOutputOfType(node, type)
|
||||
if (output) return { node, index }
|
||||
}
|
||||
|
||||
if (depth < 3) {
|
||||
const closestNodes = new Set([...nodes].flatMap((x) => getInputNodes(x)))
|
||||
const res = findNearestOutputOfType(closestNodes, type, depth + 1)
|
||||
if (res) return res
|
||||
}
|
||||
}
|
||||
|
||||
function isPointTooClose(a: Point, b: Point, precision: number = 5) {
|
||||
return Math.abs(a[0] - b[0]) < precision && Math.abs(a[1] - b[1]) < precision
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
108
src/utils/nodeDefOrderingUtil.ts
Normal file
108
src/utils/nodeDefOrderingUtil.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user