Compare commits
32 Commits
v1.46.9
...
version-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab42c4f91f | ||
|
|
164143489c | ||
|
|
b93338cac7 | ||
|
|
fa2d187c83 | ||
|
|
6f22ca11c3 | ||
|
|
c17f91095a | ||
|
|
b67d77f08a | ||
|
|
d7f0d75efd | ||
|
|
74b9f16b62 | ||
|
|
dbeb9cc10d | ||
|
|
49a05f32ca | ||
|
|
f8187cec4c | ||
|
|
7e61358724 | ||
|
|
ff9e6415b5 | ||
|
|
c695aa1ee0 | ||
|
|
b907423526 | ||
|
|
7d99189211 | ||
|
|
5ddf5faef3 | ||
|
|
4fe78997b0 | ||
|
|
cda2929572 | ||
|
|
82bea29dda | ||
|
|
d129f757c0 | ||
|
|
874b486640 | ||
|
|
1eede54582 | ||
|
|
be131f7e9a | ||
|
|
daf07a7442 | ||
|
|
454e124099 | ||
|
|
8a819fa2be | ||
|
|
35157f1af0 | ||
|
|
f9cbaf750f | ||
|
|
4bfb0c36be | ||
|
|
b996ed3ab5 |
@@ -38,7 +38,7 @@ test.describe('Download page @smoke', () => {
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
|
||||
@@ -176,7 +176,7 @@ test.describe('Download page mobile @mobile', () => {
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
|
||||
@@ -213,7 +213,7 @@ test.describe('Get started section links @smoke', () => {
|
||||
has: page.getByRole('heading', { name: 'Get started in minutes' })
|
||||
})
|
||||
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Local' })
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Desktop' })
|
||||
await expect(downloadLink).toBeVisible()
|
||||
await expect(downloadLink).toHaveAttribute('href', '/download')
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
|
||||
@@ -55,7 +55,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
|
||||
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
|
||||
for (const item of [
|
||||
'Comfy Local',
|
||||
'Comfy Desktop',
|
||||
'Comfy Cloud',
|
||||
'Comfy API',
|
||||
'Comfy Enterprise'
|
||||
@@ -69,7 +69,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.locator('main').hover()
|
||||
@@ -81,7 +81,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
@@ -121,7 +121,7 @@ test.describe('Mobile menu @mobile', () => {
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await menu.getByText('PRODUCTS').first().click()
|
||||
|
||||
await expect(menu.getByText('Comfy Local')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
|
||||
|
||||
await menu.getByRole('button', { name: /BACK/i }).click()
|
||||
@@ -133,7 +133,7 @@ test.describe('Mobile menu @mobile', () => {
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
).toBeVisible()
|
||||
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
17
apps/website/src/components/common/Badge.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { BadgeVariants } from './badge.variants'
|
||||
import { badgeVariants } from './badge.variants'
|
||||
|
||||
const { variant, class: className } = defineProps<{
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn(badgeVariants({ variant }), className)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
53
apps/website/src/components/common/CallToActionSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
primaryLabelKey,
|
||||
primaryHref,
|
||||
secondaryLabelKey,
|
||||
secondaryHref
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
primaryLabelKey: TranslationKey
|
||||
primaryHref?: string
|
||||
secondaryLabelKey?: TranslationKey
|
||||
secondaryHref?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<BrandButton
|
||||
:href="primaryHref"
|
||||
variant="solid"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(primaryLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
v-if="secondaryLabelKey"
|
||||
:href="secondaryHref"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(secondaryLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
102
apps/website/src/components/common/EventsSection.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Locale,
|
||||
LocalizedText,
|
||||
TranslationKey
|
||||
} from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
export type EventItem = {
|
||||
label: LocalizedText
|
||||
title: LocalizedText
|
||||
cta: LocalizedText
|
||||
href: string
|
||||
}
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
descriptionKey,
|
||||
notifyLabelKey,
|
||||
notifyHref,
|
||||
events
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
descriptionKey: TranslationKey
|
||||
notifyLabelKey: TranslationKey
|
||||
notifyHref?: string
|
||||
events: readonly EventItem[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-12">
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
<div>
|
||||
<BrandButton
|
||||
:href="notifyHref"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(notifyLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray flex-1 text-sm">
|
||||
{{ event.title[locale] }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
|
||||
>
|
||||
{{ event.cta[locale] }}
|
||||
<svg
|
||||
class="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
165
apps/website/src/components/common/MaskRevealButton.stories.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import MaskRevealButton from './MaskRevealButton.vue'
|
||||
|
||||
const meta: Meta<typeof MaskRevealButton> = {
|
||||
title: 'Website/Common/MaskRevealButton',
|
||||
component: MaskRevealButton,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['right', 'left']
|
||||
},
|
||||
hideLabel: { control: 'boolean' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<MaskRevealButton v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LabelVisible: Story = {
|
||||
args: { href: '#', hideLabel: false },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template:
|
||||
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MaskRevealButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" variant="solid" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="solid" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="solid" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" variant="ghost" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="ghost" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="ghost" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Icon Left</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" iconPosition="left" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" iconPosition="left" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" iconPosition="left" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
82
apps/website/src/components/common/MaskRevealButton.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { MaskRevealButtonVariants } from './maskRevealButton.variants'
|
||||
import {
|
||||
maskRevealButtonBadgeVariants,
|
||||
maskRevealButtonVariants,
|
||||
maskRevealLabelVariants
|
||||
} from './maskRevealButton.variants'
|
||||
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
type = 'button',
|
||||
disabled,
|
||||
ariaLabel,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
hideLabel = true,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
variant?: MaskRevealButtonVariants['variant']
|
||||
size?: MaskRevealButtonVariants['size']
|
||||
iconPosition?: MaskRevealButtonVariants['iconPosition']
|
||||
hideLabel?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href="href || undefined"
|
||||
:target="href ? target : undefined"
|
||||
:rel="href ? rel : undefined"
|
||||
:type="!href ? type : undefined"
|
||||
:disabled="!href ? disabled : undefined"
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn(maskRevealButtonVariants({ variant, size, iconPosition }), customClass)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:data-icon-position="iconPosition ?? 'right'"
|
||||
:data-hidden="hideLabel ? 'true' : 'false'"
|
||||
:class="maskRevealLabelVariants()"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="maskRevealButtonBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M7 17 17 7" />
|
||||
<path d="M7 7h10v10" />
|
||||
</svg>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
165
apps/website/src/components/common/PillButton.stories.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import PillButton from './PillButton.vue'
|
||||
|
||||
const meta: Meta<typeof PillButton> = {
|
||||
title: 'Website/Common/PillButton',
|
||||
component: PillButton,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['right', 'left']
|
||||
},
|
||||
hideLabel: { control: 'boolean' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AsAnchor: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const AsButton: Story = {
|
||||
args: { type: 'button' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Submit</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Read More</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<PillButton v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</PillButton>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Go Back</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const RevealLabelOnHover: Story = {
|
||||
args: { href: '#', hideLabel: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Unavailable</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { PillButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
|
||||
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
|
||||
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
|
||||
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
|
||||
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
84
apps/website/src/components/common/PillButton.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { PillButtonVariants } from './pillButton.variants'
|
||||
import {
|
||||
pillButtonBadgeVariants,
|
||||
pillButtonVariants
|
||||
} from './pillButton.variants'
|
||||
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
type = 'button',
|
||||
disabled,
|
||||
ariaLabel,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
hideLabel = false,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
variant?: PillButtonVariants['variant']
|
||||
size?: PillButtonVariants['size']
|
||||
iconPosition?: PillButtonVariants['iconPosition']
|
||||
hideLabel?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href="href || undefined"
|
||||
:target="href ? target : undefined"
|
||||
:rel="href ? rel : undefined"
|
||||
:type="!href ? type : undefined"
|
||||
:disabled="!href ? disabled : undefined"
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn(pillButtonVariants({ variant, size, iconPosition }), customClass)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'relative leading-none transition-all duration-500',
|
||||
hideLabel && 'opacity-0 group-hover:opacity-100'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="pillButtonBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M7 17 17 7" />
|
||||
<path d="M7 7h10v10" />
|
||||
</svg>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
const {
|
||||
logoSrc = '/icons/logo.svg',
|
||||
logoAlt = 'Comfy',
|
||||
text = 'LOCAL'
|
||||
text = 'DESKTOP'
|
||||
} = defineProps<{
|
||||
logoSrc?: string
|
||||
logoAlt?: string
|
||||
@@ -20,7 +20,7 @@ const {
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
|
||||
class="bg-primary-comfy-yellow my-auto flex h-12 items-center justify-center text-primary-comfy-ink lg:my-0 lg:h-auto lg:p-8"
|
||||
>
|
||||
<img
|
||||
:src="logoSrc"
|
||||
@@ -37,7 +37,7 @@ const {
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
|
||||
class="bg-primary-comfy-yellow my-auto flex h-7.25 items-center justify-center text-primary-comfy-ink lg:h-15.5 lg:px-6"
|
||||
>
|
||||
<span
|
||||
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"
|
||||
|
||||
@@ -43,6 +43,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{
|
||||
title: t('footer.resources', locale),
|
||||
links: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('footer.blog', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -52,6 +52,7 @@ const navLinks: NavLink[] = [
|
||||
{
|
||||
label: t('nav.resources', locale),
|
||||
items: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
@@ -93,7 +94,7 @@ const ctaButtons = [
|
||||
{
|
||||
label: t('nav.downloadLocal', locale),
|
||||
prefix: 'DOWNLOAD',
|
||||
core: 'LOCAL',
|
||||
core: 'DESKTOP',
|
||||
href: routes.download,
|
||||
primary: false
|
||||
},
|
||||
@@ -163,7 +164,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<nav
|
||||
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
17
apps/website/src/components/common/badge.variants.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const badgeVariants = cva({
|
||||
base: 'text-primary-warm-gray focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-4 py-1 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
110
apps/website/src/components/common/maskRevealButton.variants.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const maskRevealButtonVariants = cva({
|
||||
base: 'group relative uppercase inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
ghost: 'text-primary-comfy-yellow bg-transparent'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-10 text-xs',
|
||||
md: 'h-12 text-sm',
|
||||
lg: 'h-14 text-base'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{ size: 'sm', iconPosition: 'right', class: 'ps-12 pe-4' },
|
||||
{ size: 'md', iconPosition: 'right', class: 'ps-14 pe-6' },
|
||||
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
|
||||
{ size: 'sm', iconPosition: 'left', class: 'ps-4 pe-12' },
|
||||
{ size: 'md', iconPosition: 'left', class: 'ps-6 pe-14' },
|
||||
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const maskRevealButtonBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
md: 'size-10',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const maskRevealLabelVariants = cva({
|
||||
base: [
|
||||
'relative inline-block align-baseline',
|
||||
'[will-change:mask-size,-webkit-mask-size]',
|
||||
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
|
||||
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
|
||||
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
|
||||
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
|
||||
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
|
||||
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
|
||||
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
|
||||
'group-hover:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
|
||||
'group-focus-visible:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
|
||||
].join(' ')
|
||||
})
|
||||
|
||||
export type MaskRevealButtonVariants = VariantProps<
|
||||
typeof maskRevealButtonVariants
|
||||
>
|
||||
116
apps/website/src/components/common/pillButton.variants.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const pillButtonVariants = cva({
|
||||
base: 'group relative inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
ghost: 'text-primary-comfy-yellow bg-transparent'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-10 text-xs',
|
||||
md: 'h-12 text-sm',
|
||||
lg: 'h-14 text-base'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-4 pe-12 hover:ps-12 hover:pe-4'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-6 pe-14 hover:ps-14 hover:pe-6'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-8 pe-16 hover:ps-16 hover:pe-8'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-12 pe-4 hover:ps-4 hover:pe-12'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-14 pe-6 hover:ps-6 hover:pe-14'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-16 pe-8 hover:ps-8 hover:pe-16'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const pillButtonBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
md: 'size-10',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export type PillButtonVariants = VariantProps<typeof pillButtonVariants>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const tags = ['Seadance 2.0', 'Image To Video']
|
||||
const demoVideoSrc =
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
|
||||
const demoVideoPoster =
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t('learning.featured.title', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
|
||||
{{ t('learning.featured.author', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('learning.featured.description', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<BrandButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
href="https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<ul class="mt-2 flex flex-wrap gap-3">
|
||||
<li v-for="tag in tags" :key="tag">
|
||||
<Badge variant="subtle">{{ tag }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
|
||||
<VideoPlayer
|
||||
:locale
|
||||
:src="demoVideoSrc"
|
||||
:poster="demoVideoPoster"
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
23
apps/website/src/components/learning/HeroSection.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
|
||||
>
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
>{{ t('learning.heroTitle.after', locale) }}
|
||||
<br />
|
||||
{{ t('learning.heroTitle.line2', locale) }}
|
||||
</h1>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: LearningTutorial
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
|
||||
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
|
||||
|
||||
const playFromStart = () => {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => tutorial.id,
|
||||
() => {
|
||||
playFromStart()
|
||||
}
|
||||
)
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) emit('close')
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lockScroll()
|
||||
dialogRef.value?.showModal()
|
||||
playFromStart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlockScroll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
:aria-label="tutorial.title[locale]"
|
||||
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
|
||||
@click="handleBackdropClick"
|
||||
@keydown="handleKeydown"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<button
|
||||
:aria-label="t('gallery.detail.close', locale)"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
|
||||
style="mask: url('/icons/close.svg') center / contain no-repeat"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h2>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
120
apps/website/src/components/learning/TutorialsSection.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
getTutorialPosterSrc,
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import MaskRevealButton from '../common/MaskRevealButton.vue'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeTutorialId = ref<string | null>(null)
|
||||
const activeTutorial = () =>
|
||||
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ t('learning.tutorials.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li
|
||||
v-for="tutorial in learningTutorials"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
|
||||
@click="activeTutorialId = tutorial.id"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white lg:size-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
v-if="tutorial.href"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -283,16 +283,16 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[10%] lg:px-20 lg:py-14"
|
||||
>
|
||||
<ProductHeroBadge />
|
||||
<ProductHeroBadge text="DESKTOP" />
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-6 text-3xl/tight font-light whitespace-pre-line md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
|
||||
class="mt-6 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('download.hero.heading', locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
|
||||
class="mt-6 max-w-md text-sm text-primary-comfy-canvas lg:mt-6 lg:text-base"
|
||||
>
|
||||
{{ t('download.hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -12,6 +12,7 @@ const baseRoutes = {
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
|
||||
31
apps/website/src/data/events.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { EventItem } from '../components/common/EventsSection.vue'
|
||||
|
||||
export const learningEvents: readonly EventItem[] = [
|
||||
{
|
||||
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
|
||||
title: {
|
||||
en: 'Zero to Node: Building Your First Workflow',
|
||||
'zh-CN': '从零到节点:构建你的第一个工作流'
|
||||
},
|
||||
cta: { en: 'Link', 'zh-CN': '链接' },
|
||||
href: '#'
|
||||
},
|
||||
{
|
||||
label: { en: 'Event 1', 'zh-CN': '活动 1' },
|
||||
title: {
|
||||
en: 'Lorem ipsum dollar sita met',
|
||||
'zh-CN': '此处为活动描述的占位文本'
|
||||
},
|
||||
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
|
||||
href: '#'
|
||||
},
|
||||
{
|
||||
label: { en: 'Event 2', 'zh-CN': '活动 2' },
|
||||
title: {
|
||||
en: 'Lorem ipsum dollar sita met',
|
||||
'zh-CN': '此处为活动描述的占位文本'
|
||||
},
|
||||
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
|
||||
href: '#'
|
||||
}
|
||||
] as const
|
||||
84
apps/website/src/data/learningTutorials.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { LocalizedText, TranslationKey } from '../i18n/translations'
|
||||
|
||||
export interface LearningTutorial {
|
||||
id: string
|
||||
tags: readonly TranslationKey[]
|
||||
title: LocalizedText
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POSTER_TIME_SECONDS = 1
|
||||
|
||||
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
|
||||
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
|
||||
|
||||
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
|
||||
tutorial.poster
|
||||
? tutorial.poster
|
||||
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
|
||||
|
||||
export const learningTutorials: readonly LearningTutorial[] = [
|
||||
{
|
||||
id: 'cleanplate_walkthrough_v03',
|
||||
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
// href: '#',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'deaging_workflow_v03',
|
||||
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'frame_adjustments_demo_v03',
|
||||
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'mattes_and_utilities_v03',
|
||||
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=be0889296f65',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'seedance_demo_comfyui_v03',
|
||||
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'skyreplacement_smaller_v06',
|
||||
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
}
|
||||
] as const
|
||||
@@ -1,6 +1,22 @@
|
||||
type Locale = 'en' | 'zh-CN'
|
||||
|
||||
const translations = {
|
||||
// Tags (global, reusable across sections)
|
||||
'tags.partnerNodes': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'tags.imageToVideo': {
|
||||
en: 'Image To Video',
|
||||
'zh-CN': '图像生成视频'
|
||||
},
|
||||
|
||||
// CTAs (global, reusable across sections)
|
||||
'cta.tryWorkflow': {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
@@ -108,8 +124,8 @@ const translations = {
|
||||
'zh-CN': '下载或注册'
|
||||
},
|
||||
'getStarted.step1.downloadLocal': {
|
||||
en: 'Download Local',
|
||||
'zh-CN': '下载本地版'
|
||||
en: 'Download Desktop',
|
||||
'zh-CN': '下载桌面版'
|
||||
},
|
||||
'getStarted.step1.launchCloud': {
|
||||
en: 'Launch Cloud',
|
||||
@@ -589,8 +605,8 @@ const translations = {
|
||||
'是的。基于 GPL-3.0 免费开源。没有功能限制、没有试用期、没有附加条件。'
|
||||
},
|
||||
'download.faq.4.q': {
|
||||
en: 'Why would I pay for Comfy Cloud if Local is free?',
|
||||
'zh-CN': '既然本地版免费,为什么还要付费使用 Comfy Cloud?'
|
||||
en: 'Why would I pay for Comfy Cloud if Desktop is free?',
|
||||
'zh-CN': '既然桌面版免费,为什么还要付费使用 Comfy Cloud?'
|
||||
},
|
||||
'download.faq.4.a': {
|
||||
en: 'Your machine or ours. Cloud gives you powerful GPUs on demand, pre-loaded models, end-to-end security and infrastructure out of the box and partner models cleared for commercial use.',
|
||||
@@ -607,8 +623,8 @@ const translations = {
|
||||
'Desktop:一键安装,自动更新。Portable:独立构建,可从任意文件夹运行。CLI:从 GitHub 克隆,完全开发者控制,适合想自定义环境或参与上游贡献的开发者。'
|
||||
},
|
||||
'download.faq.6.q': {
|
||||
en: 'Can I use my local workflows in Comfy Cloud?',
|
||||
'zh-CN': '我可以在 Comfy Cloud 中使用本地工作流吗?'
|
||||
en: 'Can I use my Desktop workflows in Comfy Cloud?',
|
||||
'zh-CN': '我可以在 Comfy Cloud 中使用桌面工作流吗?'
|
||||
},
|
||||
'download.faq.6.a': {
|
||||
en: 'Yes — same file, same results. No conversion, no rework.',
|
||||
@@ -649,8 +665,8 @@ const translations = {
|
||||
'zh-CN': '专业人士为何\n选择'
|
||||
},
|
||||
'download.reason.headingHighlight': {
|
||||
en: 'Local',
|
||||
'zh-CN': '本地版'
|
||||
en: 'Desktop',
|
||||
'zh-CN': '桌面版'
|
||||
},
|
||||
'download.reason.1.title': {
|
||||
en: 'Unlimited\nCustomization',
|
||||
@@ -699,8 +715,8 @@ const translations = {
|
||||
'zh-CN': '完整的 ComfyUI 引擎——开源、快速、可扩展,随你运行。'
|
||||
},
|
||||
'download.hero.downloadLocal': {
|
||||
en: 'DOWNLOAD LOCAL',
|
||||
'zh-CN': '下载本地版'
|
||||
en: 'DOWNLOAD DESKTOP',
|
||||
'zh-CN': '下载桌面版'
|
||||
},
|
||||
'download.hero.installGithub': {
|
||||
en: 'INSTALL FROM GITHUB',
|
||||
@@ -1435,6 +1451,62 @@ const translations = {
|
||||
'player.subtitlesOn': { en: 'Subtitles on', 'zh-CN': '开启字幕' },
|
||||
'player.subtitlesOff': { en: 'Subtitles off', 'zh-CN': '关闭字幕' },
|
||||
|
||||
// LearningHeroSection
|
||||
'learning.heroTitle.before': { en: 'Learn', 'zh-CN': '学习' },
|
||||
'learning.heroTitle.after': { en: '.', 'zh-CN': '。' },
|
||||
'learning.heroTitle.line2': {
|
||||
en: 'Build what doesn’t exist yet.',
|
||||
'zh-CN': '构建尚未存在之物。'
|
||||
},
|
||||
|
||||
// LearningFeaturedWorkflowSection
|
||||
'learning.featured.title': {
|
||||
en: 'Sky Replacement',
|
||||
'zh-CN': '天空替换'
|
||||
},
|
||||
'learning.featured.author': {
|
||||
en: 'by Doug Hogan',
|
||||
'zh-CN': '作者:Doug Hogan'
|
||||
},
|
||||
'learning.featured.description': {
|
||||
en: 'A sky replacement workflow built on Wan AI models. WanVideoSampler and WanVideoDecode synthesize new sky visuals into existing footage. CLIPVisionLoader and WanVideoClipVisionEncode ensure replacements feel native, not composited.',
|
||||
'zh-CN':
|
||||
'基于 Wan AI 模型构建的天空替换工作流。WanVideoSampler 与 WanVideoDecode 将全新的天空视觉合成到现有素材中。CLIPVisionLoader 与 WanVideoClipVisionEncode 确保替换效果自然融合,而非生硬叠加。'
|
||||
},
|
||||
'learning.featured.watchDemo': {
|
||||
en: 'Watch Demo',
|
||||
'zh-CN': '观看演示'
|
||||
},
|
||||
|
||||
// LearningTutorialsSection
|
||||
'learning.tutorials.heading': {
|
||||
en: 'Featured Demos',
|
||||
'zh-CN': '精选演示'
|
||||
},
|
||||
'learning.tutorials.titlePrefix': {
|
||||
en: 'Learn how to:',
|
||||
'zh-CN': '学习如何:'
|
||||
},
|
||||
|
||||
// LearningCallToActionSection
|
||||
'learning.cta.heading': {
|
||||
en: 'Schedule a demo and see how ComfyUI fits your team’s creative needs.',
|
||||
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
|
||||
},
|
||||
'learning.cta.contactSales': {
|
||||
en: 'Contact Sales',
|
||||
'zh-CN': '联系销售'
|
||||
},
|
||||
|
||||
// LearningEventsSection
|
||||
'learning.events.heading': { en: 'Events', 'zh-CN': '活动' },
|
||||
'learning.events.description': {
|
||||
en: 'Check out our upcoming live streams and community meetings. We’re always open to your questions, ideas, and conversations.',
|
||||
'zh-CN':
|
||||
'查看我们即将举办的直播和社区聚会。我们随时欢迎你的提问、想法和交流。'
|
||||
},
|
||||
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
|
||||
|
||||
// GalleryHeroSection
|
||||
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'gallery.heroTitle.before': {
|
||||
@@ -1471,9 +1543,13 @@ const translations = {
|
||||
},
|
||||
'about.hero.body': {
|
||||
en: 'The team behind Comfy is small, intense, and building what we intend to be our life\u2019s work.',
|
||||
'zh-CN': 'Comfy 背后的团队规模虽小,但充满热情,致力于打造我们毕生的事业。'
|
||||
'zh-CN':
|
||||
'Comfy \u80cc\u540e\u7684\u56e2\u961f\u89c4\u6a21\u867d\u5c0f\uff0c\u4f46\u5145\u6ee1\u70ed\u60c5\uff0c\u81f4\u529b\u4e8e\u6253\u9020\u6211\u4eec\u6bd5\u751f\u7684\u4e8b\u4e1a\u3002'
|
||||
},
|
||||
'about.hero.cta': {
|
||||
en: 'SEE OPEN ROLES',
|
||||
'zh-CN': '\u67e5\u770b\u5f00\u653e\u804c\u4f4d'
|
||||
},
|
||||
'about.hero.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
|
||||
|
||||
// AboutStorySection
|
||||
'about.story.label': { en: 'OUR STORY', 'zh-CN': '我们的故事' },
|
||||
@@ -1734,7 +1810,7 @@ const translations = {
|
||||
'nav.community': { en: 'Community', 'zh-CN': '社区' },
|
||||
'nav.resources': { en: 'Resources', 'zh-CN': '资源' },
|
||||
'nav.company': { en: 'Company', 'zh-CN': '公司' },
|
||||
'nav.comfyLocal': { en: 'Comfy Local', 'zh-CN': 'Comfy 本地版' },
|
||||
'nav.comfyLocal': { en: 'Comfy Desktop', 'zh-CN': 'Comfy 桌面版' },
|
||||
'nav.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'nav.comfyApi': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
|
||||
'nav.comfyEnterprise': {
|
||||
@@ -1743,6 +1819,7 @@ const translations = {
|
||||
},
|
||||
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
|
||||
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
|
||||
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
|
||||
@@ -1751,7 +1828,7 @@ const translations = {
|
||||
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD LOCAL', 'zh-CN': '下载本地版' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
@@ -1802,8 +1879,9 @@ const translations = {
|
||||
'如果我们的网站包含指向第三方网站和服务的链接,请注意这些网站和服务有自己的隐私政策。在访问任何第三方内容的链接后,您应阅读其发布的关于如何收集和使用个人信息的隐私政策信息。本隐私政策不适用于您离开我们网站后的任何活动。'
|
||||
},
|
||||
'privacy.intro.block.3': {
|
||||
en: 'This policy is effective as of April 18, 2025.',
|
||||
'zh-CN': '本政策自 2025 年 4 月 18 日起生效。'
|
||||
en: 'This policy is effective as of April 18, 2025. For information specific to Comfy Desktop (the local install application), including named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights, see our <a href="/privacy/desktop" class="text-white underline">Desktop Privacy Policy</a>.',
|
||||
'zh-CN':
|
||||
'本政策自 2025 年 4 月 18 日起生效。有关 Comfy Desktop(本地安装应用程序)的具体信息,包括指定的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限以及您的权利,请参阅我们的<a href="/zh-CN/privacy/desktop" class="text-white underline">Desktop 隐私政策</a>。'
|
||||
},
|
||||
'privacy.information-we-collect.label': {
|
||||
en: 'INFORMATION',
|
||||
@@ -2053,6 +2131,181 @@ const translations = {
|
||||
'<a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>'
|
||||
},
|
||||
|
||||
// ── Desktop Privacy Policy ────────────────────────────────────────
|
||||
'desktop_privacy.intro.label': { en: 'OVERVIEW', 'zh-CN': 'OVERVIEW' },
|
||||
'desktop_privacy.intro.block.0': {
|
||||
en: 'Effective 3 June 2026. Applies to the Comfy Desktop application.',
|
||||
'zh-CN': 'Effective 3 June 2026. Applies to the Comfy Desktop application.'
|
||||
},
|
||||
'desktop_privacy.intro.block.1': {
|
||||
en: 'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.',
|
||||
'zh-CN':
|
||||
'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.'
|
||||
},
|
||||
|
||||
'desktop_privacy.controller.label': {
|
||||
en: 'CONTROLLER',
|
||||
'zh-CN': 'CONTROLLER'
|
||||
},
|
||||
'desktop_privacy.controller.title': {
|
||||
en: 'Controller',
|
||||
'zh-CN': 'Controller'
|
||||
},
|
||||
'desktop_privacy.controller.block.0': {
|
||||
en: 'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
'desktop_privacy.data.label': {
|
||||
en: 'DATA WE PROCESS',
|
||||
'zh-CN': 'DATA WE PROCESS'
|
||||
},
|
||||
'desktop_privacy.data.title': {
|
||||
en: 'Personal data we process',
|
||||
'zh-CN': 'Personal data we process'
|
||||
},
|
||||
'desktop_privacy.data.block.0': {
|
||||
en: 'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:',
|
||||
'zh-CN':
|
||||
'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:'
|
||||
},
|
||||
'desktop_privacy.data.block.1': {
|
||||
en: 'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.',
|
||||
'zh-CN':
|
||||
'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.'
|
||||
},
|
||||
'desktop_privacy.data.block.2': {
|
||||
en: 'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).',
|
||||
'zh-CN':
|
||||
'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).'
|
||||
},
|
||||
'desktop_privacy.data.block.3': {
|
||||
en: 'We do not process:',
|
||||
'zh-CN': 'We do not process:'
|
||||
},
|
||||
'desktop_privacy.data.block.4': {
|
||||
en: 'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application',
|
||||
'zh-CN':
|
||||
'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application'
|
||||
},
|
||||
'desktop_privacy.data.block.5': {
|
||||
en: 'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.',
|
||||
'zh-CN':
|
||||
'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.'
|
||||
},
|
||||
|
||||
'desktop_privacy.purposes.label': { en: 'PURPOSES', 'zh-CN': 'PURPOSES' },
|
||||
'desktop_privacy.purposes.title': {
|
||||
en: 'Purposes and lawful bases',
|
||||
'zh-CN': 'Purposes and lawful bases'
|
||||
},
|
||||
'desktop_privacy.purposes.block.0': {
|
||||
en: 'We process personal data on the following lawful bases under GDPR and UK GDPR:',
|
||||
'zh-CN':
|
||||
'We process personal data on the following lawful bases under GDPR and UK GDPR:'
|
||||
},
|
||||
'desktop_privacy.purposes.block.1': {
|
||||
en: 'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).',
|
||||
'zh-CN':
|
||||
'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).'
|
||||
},
|
||||
'desktop_privacy.purposes.block.2': {
|
||||
en: 'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
'desktop_privacy.purposes.block.3': {
|
||||
en: 'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.'
|
||||
},
|
||||
|
||||
'desktop_privacy.processors.label': {
|
||||
en: 'RECIPIENTS',
|
||||
'zh-CN': 'RECIPIENTS'
|
||||
},
|
||||
'desktop_privacy.processors.title': {
|
||||
en: 'Recipients',
|
||||
'zh-CN': 'Recipients'
|
||||
},
|
||||
'desktop_privacy.processors.block.0': {
|
||||
en: 'We engage the following processors under Data Processing Agreements:',
|
||||
'zh-CN':
|
||||
'We engage the following processors under Data Processing Agreements:'
|
||||
},
|
||||
'desktop_privacy.processors.block.1': {
|
||||
en: 'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)',
|
||||
'zh-CN':
|
||||
'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)'
|
||||
},
|
||||
|
||||
'desktop_privacy.transfers.label': { en: 'TRANSFERS', 'zh-CN': 'TRANSFERS' },
|
||||
'desktop_privacy.transfers.title': {
|
||||
en: 'International transfers',
|
||||
'zh-CN': 'International transfers'
|
||||
},
|
||||
'desktop_privacy.transfers.block.0': {
|
||||
en: 'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.'
|
||||
},
|
||||
|
||||
'desktop_privacy.retention.label': { en: 'RETENTION', 'zh-CN': 'RETENTION' },
|
||||
'desktop_privacy.retention.title': { en: 'Retention', 'zh-CN': 'Retention' },
|
||||
'desktop_privacy.retention.block.0': {
|
||||
en: 'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.',
|
||||
'zh-CN':
|
||||
'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.'
|
||||
},
|
||||
|
||||
'desktop_privacy.rights.label': { en: 'YOUR RIGHTS', 'zh-CN': 'YOUR RIGHTS' },
|
||||
'desktop_privacy.rights.title': { en: 'Your rights', 'zh-CN': 'Your rights' },
|
||||
'desktop_privacy.rights.block.0': {
|
||||
en: 'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.',
|
||||
'zh-CN':
|
||||
'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.'
|
||||
},
|
||||
'desktop_privacy.rights.block.1': {
|
||||
en: 'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.'
|
||||
},
|
||||
'desktop_privacy.rights.block.2': {
|
||||
en: "You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency.",
|
||||
'zh-CN':
|
||||
"You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency."
|
||||
},
|
||||
'desktop_privacy.rights.block.3': {
|
||||
en: 'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.',
|
||||
'zh-CN':
|
||||
'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.'
|
||||
},
|
||||
|
||||
'desktop_privacy.children.label': { en: 'CHILDREN', 'zh-CN': 'CHILDREN' },
|
||||
'desktop_privacy.children.title': { en: 'Children', 'zh-CN': 'Children' },
|
||||
'desktop_privacy.children.block.0': {
|
||||
en: 'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.',
|
||||
'zh-CN':
|
||||
'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.'
|
||||
},
|
||||
|
||||
'desktop_privacy.changes.label': { en: 'CHANGES', 'zh-CN': 'CHANGES' },
|
||||
'desktop_privacy.changes.title': { en: 'Changes', 'zh-CN': 'Changes' },
|
||||
'desktop_privacy.changes.block.0': {
|
||||
en: 'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.',
|
||||
'zh-CN':
|
||||
'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.'
|
||||
},
|
||||
|
||||
'desktop_privacy.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
|
||||
'desktop_privacy.contact.title': { en: 'Contact', 'zh-CN': 'Contact' },
|
||||
'desktop_privacy.contact.block.0': {
|
||||
en: 'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
// ── Terms of Service ──────────────────────────────────────────────
|
||||
'tos.effectiveDateLabel': {
|
||||
en: 'Effective Date',
|
||||
@@ -4736,6 +4989,8 @@ const translations = {
|
||||
|
||||
type TranslationKey = keyof typeof translations
|
||||
|
||||
type LocalizedText = Record<Locale, string>
|
||||
|
||||
export function t(key: TranslationKey, locale: Locale = 'en'): string {
|
||||
return translations[key][locale] ?? translations[key].en
|
||||
}
|
||||
@@ -4746,4 +5001,4 @@ export function hasKey(key: string): boolean {
|
||||
return key in translations
|
||||
}
|
||||
|
||||
export type { Locale, TranslationKey }
|
||||
export type { Locale, LocalizedText, TranslationKey }
|
||||
|
||||
27
apps/website/src/pages/learning.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../components/common/CallToActionSection.vue'
|
||||
// import EventsSection from '../components/common/EventsSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
import { externalLinks } from '../config/routes'
|
||||
// import { learningEvents } from '../data/events'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout title="Learning — Comfy">
|
||||
<HeroSection client:load />
|
||||
<FeaturedWorkflowSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
13
apps/website/src/pages/privacy/desktop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop Privacy Policy — Comfy"
|
||||
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
|
||||
>
|
||||
<HeroSection title="Desktop Privacy Policy" />
|
||||
<ContentSection prefix="desktop_privacy" client:load />
|
||||
</BaseLayout>
|
||||
27
apps/website/src/pages/zh-CN/learning.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../../components/common/CallToActionSection.vue'
|
||||
import EventsSection from '../../components/common/EventsSection.vue'
|
||||
import { getRoutes, externalLinks } from '../../config/routes'
|
||||
import { learningEvents } from '../../data/events'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout title="学习 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<FeaturedWorkflowSection locale="zh-CN" client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
13
apps/website/src/pages/zh-CN/privacy/desktop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop 隐私政策 — Comfy"
|
||||
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
|
||||
>
|
||||
<HeroSection title="Desktop 隐私政策" />
|
||||
<ContentSection prefix="desktop_privacy" locale="zh-CN" client:load />
|
||||
</BaseLayout>
|
||||
115
browser_tests/assets/missing/missing_model_promoted_widget.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"id": "test-missing-model-promoted-widget",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-promoted-missing-model",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -100,10 +100,7 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async resizeByDragging(
|
||||
element: Locator,
|
||||
{ x, y }: { x?: number; y?: number }
|
||||
) {
|
||||
async dragElementBy(element: Locator, { x, y }: { x?: number; y?: number }) {
|
||||
const elementBox = await element.boundingBox()
|
||||
if (!elementBox) throw new Error('element should have layout')
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createInputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createOutputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
|
||||
@@ -54,6 +54,7 @@ export const TestIds = {
|
||||
errorDialogFindIssues: 'error-dialog-find-issues',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
errorGroupDisplayMessage: 'error-group-display-message',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
|
||||
@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
const defaultInputAsset: Asset & { hash?: string } = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
const importedInputAsset: Asset & { hash?: string } = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
@@ -91,3 +91,21 @@ export async function measureSelectionBounds(
|
||||
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
|
||||
export async function intersection(a: Locator, b: Locator) {
|
||||
const aBounds = await a.boundingBox()
|
||||
const bBounds = await b.boundingBox()
|
||||
if (!aBounds || !bBounds) return undefined
|
||||
|
||||
const y = Math.max(aBounds.y, bBounds.y)
|
||||
const x = Math.max(aBounds.x, bBounds.x)
|
||||
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
|
||||
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
|
||||
|
||||
if (y > bot || x > right) return undefined
|
||||
|
||||
const width = right - x
|
||||
const height = bot - y
|
||||
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
@@ -19,63 +19,24 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
return page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
function getSeeErrorsButton(page: Page) {
|
||||
function getDetailsButton(page: Page) {
|
||||
return getOverlay(page).getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
}
|
||||
|
||||
test.describe('Labels', () => {
|
||||
test('Should display singular error count label for single error', async ({
|
||||
test('Should display single error copy and View details action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getOverlay(comfyPage.page)).toContainText(/1 ERROR/i)
|
||||
})
|
||||
|
||||
test('Should display "Show missing nodes" button for missing node errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing nodes/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display "Show missing models" button for missing model errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing models/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display "Show missing inputs" button for missing media errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing inputs/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display generic "See Errors" button for multiple error types', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_and_media')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/See Errors/i
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).not.toContainText(/1 ERROR/i)
|
||||
await expect(
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toHaveText(/\S/)
|
||||
await expect(getDetailsButton(comfyPage.page)).toContainText(
|
||||
/View details/i
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -137,7 +98,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('See Errors flow', () => {
|
||||
test.describe('View details flow', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
@@ -166,7 +127,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await expect(overlay).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
|
||||
test('"View details" opens right side panel', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
@@ -178,7 +139,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
|
||||
test('"View details" dismisses the overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
@@ -229,19 +190,24 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
// Regression: ErrorOverlay previously read the selection-filtered
|
||||
// missingModelGroups from useErrorGroups, so selecting one of two
|
||||
// missing-model nodes would shrink the overlay label from
|
||||
// "2 required models are missing" to "1". The overlay must show
|
||||
// the workflow total regardless of canvas selection.
|
||||
// missing-model nodes could shrink the overlay count. The overlay must
|
||||
// show the workflow total regardless of canvas selection.
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
await expect(overlay).toContainText(/2 errors found/i)
|
||||
await expect(
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toHaveText(/Resolve them before running the workflow\./i)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
await expect(overlay).toContainText(/2 errors found/i)
|
||||
await expect(
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toHaveText(/Resolve them before running the workflow\./i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -545,4 +545,54 @@ test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Responsive Layout', () => {
|
||||
test('Action buttons stay on screen without horizontal scroll at narrow widths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 800 })
|
||||
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Add new keybinding/i })
|
||||
).toBeInViewport()
|
||||
|
||||
const hasHorizontalScroll = await page
|
||||
.locator('.keybinding-panel .p-datatable-table-container')
|
||||
.evaluate((el) => el.scrollWidth > el.clientWidth + 1)
|
||||
expect(hasHorizontalScroll).toBe(false)
|
||||
})
|
||||
|
||||
test('Keybinding column compresses with width while actions stay reachable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
const keybindingList = row.getByTestId('keybinding-list')
|
||||
await expect(keybindingList).toBeVisible()
|
||||
|
||||
const listWidthAt = async (viewportWidth: number) => {
|
||||
await page.setViewportSize({ width: viewportWidth, height: 800 })
|
||||
return keybindingList.evaluate((el) => el.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
const wideWidth = await listWidthAt(1280)
|
||||
const narrowWidth = await listWidthAt(560)
|
||||
|
||||
expect(narrowWidth).toBeLessThan(wideWidth)
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
63
browser_tests/tests/load3d/load3dSerializeCache.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
type Load3dImageInput = {
|
||||
image: string
|
||||
mask: string
|
||||
normal: string
|
||||
recording: string
|
||||
}
|
||||
|
||||
type PromptBody = {
|
||||
prompt?: Record<
|
||||
string,
|
||||
{ class_type?: string; inputs?: Record<string, unknown> }
|
||||
>
|
||||
}
|
||||
|
||||
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
|
||||
const prompt = (body as PromptBody).prompt ?? {}
|
||||
const node = prompt[nodeId]
|
||||
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
|
||||
const input = node!.inputs!.image as Load3dImageInput
|
||||
expect(typeof input.image).toBe('string')
|
||||
expect(typeof input.recording).toBe('string')
|
||||
return input
|
||||
}
|
||||
|
||||
test.describe('Load3D serialize cache', () => {
|
||||
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
|
||||
comfyPage,
|
||||
load3d
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
|
||||
let firstBody: unknown
|
||||
await exec.run({
|
||||
onPromptRequest: (body) => {
|
||||
firstBody = body
|
||||
}
|
||||
})
|
||||
const firstInput = getLoad3dImageInput(firstBody, '1')
|
||||
expect(firstInput.recording).toBe('')
|
||||
|
||||
await load3d.recordingButton.click()
|
||||
await expect(load3d.stopRecordingButton).toBeVisible()
|
||||
|
||||
let secondBody: unknown
|
||||
await exec.run({
|
||||
onPromptRequest: (body) => {
|
||||
secondBody = body
|
||||
}
|
||||
})
|
||||
const secondInput = getLoad3dImageInput(secondBody, '1')
|
||||
|
||||
expect(
|
||||
secondInput.image,
|
||||
'after starting a recording, the next queue must re-capture ' +
|
||||
'(image filename must change) so the recording is not dropped'
|
||||
).not.toBe(firstInput.image)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSwapNodesGroup,
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
@@ -38,6 +39,9 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await expect(swapGroup).toBeVisible()
|
||||
await expect(
|
||||
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(
|
||||
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Should filter execution errors by search query', async ({
|
||||
test('Should keep execution errors matching the search query', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
|
||||
await searchInput.fill('nonexistent_query_xyz_12345')
|
||||
await searchInput.fill('Execution failed')
|
||||
|
||||
await expect(runtimePanel).toHaveCount(0)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show error message in runtime error panel', async ({
|
||||
test('Should show runtime error log in the execution error group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openExecutionErrorTab(comfyPage)
|
||||
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.runtimeErrorPanel
|
||||
)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
await expect(runtimePanel).toContainText(/\S/)
|
||||
await expect(runtimePanel).toContainText('Error log')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,10 @@ function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -46,14 +50,24 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
const overlay = getErrorOverlay(comfyPage)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeVisible()
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toContainText(/Load Image/)
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(overlay).toBeHidden()
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
await expect(
|
||||
missingMediaGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
|
||||
@@ -23,11 +23,31 @@ const plainVideoFileName = 'plain_video.mp4'
|
||||
const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
const emptyMediaLoaderNodes = [
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
serverOnlyOption: 'server-only-image.png',
|
||||
position: { x: 150, y: 150 }
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
serverOnlyOption: 'server-only-video.mp4',
|
||||
position: { x: 450, y: 150 }
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
serverOnlyOption: 'server-only-audio.wav',
|
||||
position: { x: 750, y: 150 }
|
||||
}
|
||||
]
|
||||
|
||||
const cloudOutputAsset: Asset = {
|
||||
const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
asset_hash: outputHash,
|
||||
hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
@@ -36,10 +56,10 @@ const cloudOutputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
@@ -50,10 +70,10 @@ const cloudUploadedVideoAsset: Asset = {
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -66,12 +86,168 @@ interface CloudUploadAssetState {
|
||||
isUploadedAssetAvailable: boolean
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
|
||||
type ObjectInfoResponse = Record<
|
||||
string,
|
||||
{ input?: { required?: Record<string, unknown> } }
|
||||
>
|
||||
|
||||
function setComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: string[]
|
||||
) {
|
||||
const nodeInfo = objectInfo[nodeType]
|
||||
if (!nodeInfo) {
|
||||
throw new Error(`Missing object_info entry for ${nodeType}`)
|
||||
}
|
||||
|
||||
const requiredInputs = nodeInfo.input?.required
|
||||
if (!requiredInputs) {
|
||||
throw new Error(`Missing required inputs for ${nodeType}`)
|
||||
}
|
||||
|
||||
const input = requiredInputs[inputName]
|
||||
if (!Array.isArray(input)) {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
|
||||
}
|
||||
|
||||
const [valuesOrType, options] = input
|
||||
const optionsObject =
|
||||
options && typeof options === 'object' && !Array.isArray(options)
|
||||
if (Array.isArray(valuesOrType)) {
|
||||
input[0] = values
|
||||
} else if (valuesOrType !== 'COMBO') {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
|
||||
}
|
||||
|
||||
if (optionsObject) {
|
||||
Object.assign(options, { options: values })
|
||||
} else if (!Array.isArray(valuesOrType)) {
|
||||
throw new Error(
|
||||
`Expected ${nodeType}.${inputName} to have options metadata`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
await page.route('**/api/userdata**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
})
|
||||
await page.route('**/i18n', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
await page.route('**/customers/cloud-subscription-status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ is_active: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function routeSetupObjectInfo(
|
||||
page: Page,
|
||||
customize?: (objectInfo: ObjectInfoResponse) => void
|
||||
) {
|
||||
const setupApiUrl =
|
||||
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
|
||||
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
|
||||
|
||||
const objectInfoRouteHandler = async (route: Route) => {
|
||||
try {
|
||||
const response = await fetch(objectInfoUrl, {
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? 'text/plain',
|
||||
body: await response.text()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const objectInfo = (await response.json()) as ObjectInfoResponse
|
||||
customize?.(objectInfo)
|
||||
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(objectInfo)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await page.route('**/object_info', objectInfoRouteHandler)
|
||||
return async () =>
|
||||
await page.unroute('**/object_info', objectInfoRouteHandler)
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
|
||||
const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
markUploadedCloudAssetAvailable: () => void
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
|
||||
const state: CloudUploadAssetState = {
|
||||
isUploadedAssetAvailable: false
|
||||
}
|
||||
@@ -106,9 +282,13 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
}
|
||||
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
await use(page)
|
||||
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
cloudUploadAssetStateByPage.delete(page)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
await unrouteObjectInfo()
|
||||
cloudUploadAssetStateByPage.delete(page)
|
||||
}
|
||||
},
|
||||
markUploadedCloudAssetAvailable: async ({ page }, use) => {
|
||||
await use(() => {
|
||||
@@ -139,7 +319,41 @@ async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
async function closeTemplatesDialogIfOpen(comfyPage: ComfyPage) {
|
||||
const templatesDialog = comfyPage.page.getByRole('dialog').filter({
|
||||
has: comfyPage.templates.content
|
||||
})
|
||||
const closeButton = templatesDialog.getByRole('button', {
|
||||
name: 'Close dialog'
|
||||
})
|
||||
await closeButton
|
||||
.waitFor({ state: 'visible', timeout: 1_000 })
|
||||
.catch(() => undefined)
|
||||
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click()
|
||||
await expect(templatesDialog).toBeHidden()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMediaLoaderWidgetValues(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluate((nodes) => {
|
||||
return nodes.map(({ nodeType, widgetName }) => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(graphNode) => graphNode.type === nodeType
|
||||
)
|
||||
const widget = node?.widgets?.find(
|
||||
(candidate) => candidate.name === widgetName
|
||||
)
|
||||
return widget?.value ?? null
|
||||
})
|
||||
}, emptyMediaLoaderNodes)
|
||||
}
|
||||
|
||||
async function delayNextUpload(
|
||||
comfyPage: ComfyPage,
|
||||
uploadResult?: { name: string; subfolder: string; type: 'input' }
|
||||
) {
|
||||
let releaseUpload!: () => void
|
||||
let resolveUploadStarted!: () => void
|
||||
const uploadStarted = new Promise<void>((resolve) => {
|
||||
@@ -152,6 +366,14 @@ async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
const uploadRouteHandler = async (route: Route) => {
|
||||
resolveUploadStarted()
|
||||
await release
|
||||
if (uploadResult) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(uploadResult)
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
}
|
||||
|
||||
@@ -295,12 +517,51 @@ ossTest.describe(
|
||||
}
|
||||
)
|
||||
|
||||
cloudEmptyMediaInputsTest.describe(
|
||||
'Errors tab - Cloud empty media loader inputs',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
cloudEmptyMediaInputsTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
await closeTemplatesDialogIfOpen(comfyPage)
|
||||
})
|
||||
|
||||
cloudEmptyMediaInputsTest(
|
||||
'does not surface missing inputs after adding LoadImage, LoadVideo, and LoadAudio nodes with no cloud input assets',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
await comfyPage.nodeOps.addNode(
|
||||
node.nodeType,
|
||||
undefined,
|
||||
node.position
|
||||
)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'input')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => getMediaLoaderWidgetValues(comfyPage))
|
||||
.toEqual(['', '', ''])
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
cloudOutputTest.describe(
|
||||
'Errors tab - Cloud missing media runtime sources',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
await closeTemplatesDialogIfOpen(comfyPage)
|
||||
})
|
||||
|
||||
cloudOutputTest(
|
||||
@@ -329,13 +590,18 @@ cloudUploadRaceTest.describe(
|
||||
() => {
|
||||
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
await closeTemplatesDialogIfOpen(comfyPage)
|
||||
})
|
||||
|
||||
cloudUploadRaceTest(
|
||||
'does not surface missing media while dropped video upload is in progress',
|
||||
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const delayedUpload = await delayNextUpload(comfyPage)
|
||||
const delayedUpload = await delayNextUpload(comfyPage, {
|
||||
name: plainVideoFileName,
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
|
||||
dropPosition: graphDropPosition
|
||||
|
||||
@@ -25,9 +25,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelsGroup).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeVisible()
|
||||
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should display model name with referencing node count', async ({
|
||||
|
||||
@@ -23,9 +23,13 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
).toBeVisible()
|
||||
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
|
||||
@@ -369,6 +369,62 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
if (!hostNode?.isSubgraphNode()) {
|
||||
throw new Error('Expected subgraph host node')
|
||||
}
|
||||
|
||||
const interiorNode = hostNode.subgraph.getNodeById(1)
|
||||
const widget = interiorNode?.widgets?.find(
|
||||
(entry) => entry.name === 'ckpt_name'
|
||||
)
|
||||
type SettableWidget = typeof widget & {
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
const settableWidget = widget as SettableWidget | undefined
|
||||
|
||||
if (!settableWidget?.setValue) {
|
||||
throw new Error('Expected concrete ckpt_name widget')
|
||||
}
|
||||
|
||||
settableWidget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
}, resolvedModelName)
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
30
browser_tests/tests/textareaWidgetFontSize.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Textarea widget font size',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
|
||||
await expect
|
||||
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
|
||||
.toBe('14px')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
|
||||
await expect
|
||||
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
|
||||
.toBe('22px')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
@@ -1231,3 +1232,41 @@ test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Fast disconnection support',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
await comfyMouse.click(100, 100)
|
||||
}
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(slot)
|
||||
await expect.poll(isConnected).toBe(false)
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
}
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
await performDisconnect(ksampler.getSlot('positive'), false)
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await ksampler.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
})
|
||||
|
||||
await test.step('Disconnection with subgraph IO', async () => {
|
||||
await performDisconnect(ksampler.getSlot('negative'), true)
|
||||
await performDisconnect(ksampler.getSlot('latent_image'), false)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -174,10 +174,50 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
const { bottomRight } = node.resize
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
|
||||
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
|
||||
await comfyMouse.dragElementBy(bottomRight, { x: 200 })
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
|
||||
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
|
||||
await comfyMouse.dragElementBy(bottomRight, { x: -200, y: 200 })
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'requests lightweight thumbnail URLs for grid cells',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
await expect(previewImage).toBeVisible()
|
||||
})
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
const gridImages = node.imageGrid.locator('img')
|
||||
|
||||
await test.step('Inject a multi-image grid', async () => {
|
||||
const images = Array.from({ length: 4 }, (_, index) => ({
|
||||
filename: `grid-${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}))
|
||||
execution.executed('', '1', { images })
|
||||
await expect(gridImages).toHaveCount(4)
|
||||
})
|
||||
|
||||
// FE-741: small on-node grid cells must request a server re-encoded
|
||||
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
|
||||
// downloading the full-resolution image, while still pointing at the
|
||||
// real `/api/view` URL for that output. Verifies the full path: WS
|
||||
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
|
||||
// rendered grid `<img>`.
|
||||
for (const cell of await gridImages.all()) {
|
||||
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
|
||||
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -54,6 +54,35 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
}
|
||||
|
||||
const advancedButtonOverflowPx = 24
|
||||
const holdPointCanvasInsetPx = 8
|
||||
|
||||
const getAdvancedInputsButton = (node: Locator) =>
|
||||
node.getByTestId('advanced-inputs-button')
|
||||
|
||||
const moveAdvancedButtonRightEdgePastCanvas = async (
|
||||
comfyPage: ComfyPage,
|
||||
button: Locator,
|
||||
overflow: number
|
||||
) => {
|
||||
const box = await button.boundingBox()
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!box) throw new Error('Advanced button has no bounding box')
|
||||
if (!canvasBox) throw new Error('Canvas has no bounding box')
|
||||
|
||||
const scale = await comfyPage.canvasOps.getScale()
|
||||
const deltaX = canvasBox.x + canvasBox.width + overflow - box.x - box.width
|
||||
await comfyPage.page.evaluate(
|
||||
({ deltaX, scale }) => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] += deltaX / scale
|
||||
canvas.setDirty(true, true)
|
||||
},
|
||||
{ deltaX, scale }
|
||||
)
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -123,7 +152,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const showButton = getAdvancedInputsButton(node)
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
@@ -143,6 +172,83 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'should not pan while holding the Advanced button without dragging',
|
||||
{ tag: ['@canvas', '@widget'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = getAdvancedInputsButton(node)
|
||||
await expect(showButton).toBeVisible()
|
||||
|
||||
await moveAdvancedButtonRightEdgePastCanvas(
|
||||
comfyPage,
|
||||
showButton,
|
||||
advancedButtonOverflowPx
|
||||
)
|
||||
|
||||
const buttonBox = await showButton.boundingBox()
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!buttonBox) throw new Error('Advanced button has no bounding box')
|
||||
if (!canvasBox) throw new Error('Canvas has no bounding box')
|
||||
|
||||
const canvasRight = canvasBox.x + canvasBox.width
|
||||
const buttonRight = buttonBox.x + buttonBox.width
|
||||
expect(
|
||||
buttonRight,
|
||||
'Advanced button should extend past the canvas right edge'
|
||||
).toBeGreaterThan(canvasRight)
|
||||
|
||||
const holdPoint = {
|
||||
x: canvasRight - holdPointCanvasInsetPx,
|
||||
y: buttonBox.y + buttonBox.height / 2
|
||||
}
|
||||
expect(
|
||||
holdPoint.x,
|
||||
'Hold point should stay inside the visible part of the Advanced button'
|
||||
).toBeGreaterThanOrEqual(buttonBox.x)
|
||||
expect(
|
||||
holdPoint.x,
|
||||
'Hold point should stay inside the visible canvas'
|
||||
).toBeLessThanOrEqual(canvasRight)
|
||||
expect(
|
||||
holdPoint.y,
|
||||
'Hold point should stay inside the Advanced button height'
|
||||
).toBeGreaterThanOrEqual(buttonBox.y)
|
||||
expect(
|
||||
holdPoint.y,
|
||||
'Hold point should stay inside the Advanced button height'
|
||||
).toBeLessThanOrEqual(buttonBox.y + buttonBox.height)
|
||||
|
||||
const beforeOffset = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(holdPoint.x, holdPoint.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
try {
|
||||
await comfyPage.idleFrames(8)
|
||||
} finally {
|
||||
await comfyPage.page.mouse.up()
|
||||
}
|
||||
|
||||
const afterOffset = await comfyPage.canvasOps.getOffset()
|
||||
expect(afterOffset[0]).toBeCloseTo(beforeOffset[0], 3)
|
||||
expect(afterOffset[1]).toBeCloseTo(beforeOffset[1], 3)
|
||||
}
|
||||
)
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 104 KiB |
@@ -6,6 +6,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
|
||||
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
|
||||
const FLOAT_SOURCE_POSITION_LEFT_OF_NODE = { x: 100, y: 200 }
|
||||
|
||||
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -32,6 +33,20 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
async function getWidgetIndex(comfyPage: ComfyPage, widgetName: string) {
|
||||
const index = await comfyPage.page.evaluate((name) => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(node) => node.type === 'ModelSamplingFlux'
|
||||
)
|
||||
return node?.widgets?.findIndex((widget) => widget.name === name) ?? -1
|
||||
}, widgetName)
|
||||
expect(
|
||||
index,
|
||||
`${widgetName} widget should exist on ModelSamplingFlux`
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
return index
|
||||
}
|
||||
|
||||
test('should hide advanced widgets by default', async ({ comfyPage }) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
@@ -72,6 +87,47 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should keep connected advanced widgets visible when advanced inputs are hidden', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const maxShiftWidget = node.getByLabel('max_shift', { exact: true })
|
||||
const baseShiftWidget = node.getByLabel('base_shift', { exact: true })
|
||||
|
||||
await node.getByText(SHOW_ADVANCED_INPUTS).click()
|
||||
await expect(maxShiftWidget).toBeVisible()
|
||||
await expect(baseShiftWidget).toBeVisible()
|
||||
|
||||
const primitive = await comfyPage.nodeOps.addNode(
|
||||
'PrimitiveFloat',
|
||||
{},
|
||||
FLOAT_SOURCE_POSITION_LEFT_OF_NODE
|
||||
)
|
||||
const [target] =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('ModelSamplingFlux')
|
||||
const maxShiftIndex = await getWidgetIndex(comfyPage, 'max_shift')
|
||||
await primitive.connectWidget(0, target, maxShiftIndex)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(node) => node.type === 'ModelSamplingFlux'
|
||||
)
|
||||
return (
|
||||
node?.inputs.find((input) => input.widget?.name === 'max_shift')
|
||||
?.link ?? null
|
||||
)
|
||||
})
|
||||
)
|
||||
.not.toBeNull()
|
||||
|
||||
await node.getByText(HIDE_ADVANCED_INPUTS).click()
|
||||
|
||||
await expect(maxShiftWidget).toBeVisible()
|
||||
await expect(baseShiftWidget).toBeHidden()
|
||||
})
|
||||
|
||||
test('should hide advanced footer button while collapsed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
|
||||
|
||||
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
async function openSamplerDropdown(comfyPage: ComfyPage) {
|
||||
@@ -278,4 +280,31 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
.getByRole('combobox', { name: 'scheduler', exact: true })
|
||||
await expect(schedulerComboAfterReload).toContainText('karras')
|
||||
})
|
||||
|
||||
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const nodeName = 'Resize Image/Mask'
|
||||
await comfyPage.searchBoxV2.addNode(nodeName, {
|
||||
position: { x: 200, y: 630 }
|
||||
})
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
|
||||
await node.select()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
|
||||
await combo.click()
|
||||
const dropdown = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.selectDefaultViewport
|
||||
)
|
||||
await expect(dropdown).toBeVisible()
|
||||
|
||||
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
|
||||
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
|
||||
const cX = bounds.x + bounds.width / 2
|
||||
const cY = bounds.y + bounds.height / 2
|
||||
const dropdownBounds = (await dropdown.boundingBox())!
|
||||
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
|
||||
await dropdown.click({ position, trial: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,11 +40,11 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
const gutter = comfyPage.page.getByRole('separator')
|
||||
|
||||
await expect(gutter).toBeVisible()
|
||||
await comfyMouse.resizeByDragging(gutter, { x: -200 })
|
||||
await comfyMouse.dragElementBy(gutter, { x: -200 })
|
||||
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
|
||||
const intermediateWidth = await getWidth()
|
||||
|
||||
await comfyMouse.resizeByDragging(gutter, { x: 100 })
|
||||
await comfyMouse.dragElementBy(gutter, { x: 100 })
|
||||
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.9",
|
||||
"version": "1.46.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
--workflow-tabs-height: 2.375rem;
|
||||
--comfy-input-bg: #222;
|
||||
--comfy-textarea-font-size: 10px;
|
||||
--input-text: #ddd;
|
||||
--descrip-text: #999;
|
||||
--drag-text: #ccc;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -50,7 +49,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
class="keybinding-panel flex min-w-0 flex-col gap-2 overflow-x-hidden"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
@@ -46,7 +46,10 @@
|
||||
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div @contextmenu.capture="clearContextMenuTarget">
|
||||
<div
|
||||
class="min-w-0 overflow-x-hidden"
|
||||
@contextmenu.capture="clearContextMenuTarget"
|
||||
>
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
v-model:expanded-rows="expandedRows"
|
||||
@@ -60,6 +63,7 @@
|
||||
selection-mode="single"
|
||||
context-menu
|
||||
striped-rows
|
||||
:table-style="{ tableLayout: 'fixed', width: '100%' }"
|
||||
:pt="{
|
||||
header: 'px-0'
|
||||
}"
|
||||
@@ -71,12 +75,11 @@
|
||||
field="id"
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
class="flex items-center gap-1 truncate"
|
||||
class="flex min-w-0 items-center gap-1 truncate"
|
||||
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
|
||||
:title="slotProps.data.id"
|
||||
>
|
||||
@@ -103,53 +106,38 @@
|
||||
<Column
|
||||
field="keybindings"
|
||||
:header="$t('g.keybinding')"
|
||||
:style="{ width: '30%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
v-if="slotProps.data.keybindings.length > 0"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<template
|
||||
v-for="(binding, idx) in (
|
||||
slotProps.data as ICommandData
|
||||
).keybindings.slice(0, 2)"
|
||||
:key="binding.combo.serialize()"
|
||||
>
|
||||
<span v-if="idx > 0" class="text-muted-foreground">,</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="slotProps.data.keybindings.length > 2"
|
||||
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('g.nMoreKeybindings', {
|
||||
count: slotProps.data.keybindings.length - 2
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
<KeybindingList
|
||||
:keybindings="slotProps.data.keybindings"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:style="{ width: '16%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
<span class="block truncate" :title="slotProps.data.source">{{
|
||||
slotProps.data.source || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<Column
|
||||
field="actions"
|
||||
header=""
|
||||
:style="{ width: '9rem' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8 whitespace-nowrap' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="actions flex flex-row justify-end">
|
||||
<div
|
||||
class="actions flex flex-row justify-end whitespace-nowrap"
|
||||
>
|
||||
<Button
|
||||
v-if="slotProps.data.keybindings.length === 1"
|
||||
v-tooltip="$t('g.edit')"
|
||||
@@ -330,6 +318,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import KeybindingList from './keybinding/KeybindingList.vue'
|
||||
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeybindingList from './KeybindingList.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
nMoreKeybindings: '+ {count} more',
|
||||
nMoreKeybindingsCompact: '+ {count}',
|
||||
keybindingListAriaLabel: 'Keybindings: {combos}'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeKeybinding(key: string, ctrl = false, shift = false) {
|
||||
return new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key, ctrl, shift }
|
||||
})
|
||||
}
|
||||
|
||||
function renderList(props: {
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}) {
|
||||
return render(KeybindingList, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('KeybindingList', () => {
|
||||
it('renders "-" placeholder when there are no keybindings', () => {
|
||||
renderList({ keybindings: [] })
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('keybinding-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a single keybinding without any "more" badge', () => {
|
||||
renderList({ keybindings: [makeKeybinding('A', true)] })
|
||||
expect(screen.getByTestId('keybinding-list')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-medium')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-compact')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('with 2 keybindings: omits wide-tier badge, shows medium/compact for narrow widths', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 1')
|
||||
})
|
||||
|
||||
it('with 3 keybindings: wide-tier uses count-minus-two, narrower tiers use count-minus-one', () => {
|
||||
renderList({
|
||||
keybindings: [
|
||||
makeKeybinding('A', true),
|
||||
makeKeybinding('B', true),
|
||||
makeKeybinding('C', true)
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-wide')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 2 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 2')
|
||||
})
|
||||
|
||||
it('uses a container query parent so the visible tier can adapt to width', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
expect(screen.getByTestId('keybinding-list').className).toContain(
|
||||
'@container/keybindings'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits an accessible label listing all combos', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true, true)]
|
||||
})
|
||||
const ariaText = screen.getByTestId('keybinding-list-aria').textContent
|
||||
expect(ariaText).toContain('Keybindings:')
|
||||
expect(ariaText).toContain('Ctrl')
|
||||
expect(ariaText).toContain('A')
|
||||
expect(ariaText).toContain('Shift')
|
||||
expect(ariaText).toContain('B')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="keybindings.length > 0"
|
||||
class="@container/keybindings flex w-full min-w-0 items-center gap-1 overflow-hidden"
|
||||
data-testid="keybinding-list"
|
||||
>
|
||||
<KeyComboDisplay
|
||||
:key-combo="keybindings[0].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
<template v-if="keybindings.length >= 2">
|
||||
<span
|
||||
class="hidden text-muted-foreground @[16rem]/keybindings:inline"
|
||||
aria-hidden="true"
|
||||
>
|
||||
,
|
||||
</span>
|
||||
<KeyComboDisplay
|
||||
class="hidden @[16rem]/keybindings:inline-flex"
|
||||
:key-combo="keybindings[1].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="keybindings.length > 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[16rem]/keybindings:inline"
|
||||
data-testid="keybinding-list-more-wide"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 2 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[12rem]/keybindings:inline @[16rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-medium"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[8rem]/keybindings:inline @[12rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-compact"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindingsCompact', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span class="sr-only" data-testid="keybinding-list-aria">
|
||||
{{ ariaLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeyComboDisplay from './KeyComboDisplay.vue'
|
||||
|
||||
const { keybindings, isModified = false } = defineProps<{
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const ariaLabel = computed(() => {
|
||||
if (keybindings.length === 0) return ''
|
||||
const combos = keybindings
|
||||
.map((binding) => binding.combo.toString())
|
||||
.join(', ')
|
||||
return t('g.keybindingListAriaLabel', { combos })
|
||||
})
|
||||
</script>
|
||||
180
src/components/error/ErrorOverlay.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ErrorOverlay from './ErrorOverlay.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
useNodeErrorFlagSync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: false,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: vi.fn((id: string) => id),
|
||||
getActiveGraphNodeIds: vi.fn(() => new Set()),
|
||||
getExecutionIdByNode: vi.fn(),
|
||||
getNodeByExecutionId: vi.fn()
|
||||
}))
|
||||
|
||||
const mockOpenPanel = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({ openPanel: mockOpenPanel })
|
||||
}))
|
||||
|
||||
const mockCanvasStore = vi.hoisted(() => ({
|
||||
linearMode: false,
|
||||
canvas: null,
|
||||
currentGraph: null,
|
||||
updateSelectedItems: vi.fn()
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
function createTestI18n() {
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
close: 'Close',
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.',
|
||||
viewDetails: 'View details'
|
||||
},
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeNodeError(messages: string[]): NodeError {
|
||||
return {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: messages.map((message) => ({
|
||||
type: 'execution_error',
|
||||
message,
|
||||
details: 'details'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverlay(props: { appMode?: boolean } = {}) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
return render(ErrorOverlay, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [pinia, createTestI18n()],
|
||||
stubs: {
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ErrorOverlay', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockOpenPanel.mockClear()
|
||||
mockCanvasStore.linearMode = false
|
||||
mockCanvasStore.canvas = null
|
||||
mockCanvasStore.currentGraph = null
|
||||
mockCanvasStore.updateSelectedItems.mockClear()
|
||||
})
|
||||
|
||||
it('renders a single overlay message without list markup', async () => {
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [{ message: 'Only error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Only error')
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('1 ERROR')
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'View details'
|
||||
)
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the app mode button label', async () => {
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [{ message: 'Only error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'Show errors in graph'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,15 @@
|
||||
>
|
||||
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="error-overlay"
|
||||
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
{{ errorCountLabel }}
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -28,20 +28,11 @@
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3" data-testid="error-overlay-messages">
|
||||
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
<li
|
||||
v-for="(message, idx) in overlayMessages"
|
||||
:key="idx"
|
||||
class="flex min-w-0 items-baseline gap-2 text-sm/snug text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
<span class="line-clamp-3 wrap-break-word whitespace-pre-wrap">{{
|
||||
message
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -60,7 +51,11 @@
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
>
|
||||
{{ appMode ? t('linearMode.error.goto') : seeErrorsLabel }}
|
||||
{{
|
||||
appMode
|
||||
? t('linearMode.error.goto')
|
||||
: t('errorOverlay.viewDetails')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,73 +64,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
|
||||
defineProps<{ appMode?: boolean }>()
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||
const { allErrorGroups } = useErrorGroups(ref(''))
|
||||
|
||||
const singleErrorType = computed(() => {
|
||||
const types = new Set(allErrorGroups.value.map((g) => g.type))
|
||||
return types.size === 1 ? [...types][0] : null
|
||||
})
|
||||
|
||||
const overlayMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type === 'execution') {
|
||||
// TODO(FE-816 overlay-redesign): Keep runtime overlay copy raw until the
|
||||
// overlay redesign decides how to use catalog toast fields.
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.add(group.displayMessage ?? group.displayTitle)
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
})
|
||||
|
||||
const seeErrorsLabel = computed(() => {
|
||||
const labelMap: Record<string, string> = {
|
||||
missing_node: t('errorOverlay.showMissingNodes'),
|
||||
missing_model: t('errorOverlay.showMissingModels'),
|
||||
swap_nodes: t('errorOverlay.showSwapNodes'),
|
||||
missing_media: t('errorOverlay.showMissingMedia')
|
||||
}
|
||||
if (singleErrorType.value) {
|
||||
return labelMap[singleErrorType.value] ?? t('errorOverlay.seeErrors')
|
||||
}
|
||||
return t('errorOverlay.seeErrors')
|
||||
})
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
|
||||
)
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
|
||||
337
src/components/error/useErrorOverlayState.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useErrorOverlayState } from './useErrorOverlayState'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
useNodeErrorFlagSync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: false,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: vi.fn((id: string) => id),
|
||||
getActiveGraphNodeIds: vi.fn(() => new Set()),
|
||||
getExecutionIdByNode: vi.fn(),
|
||||
getNodeByExecutionId: vi.fn()
|
||||
}))
|
||||
|
||||
function createTestI18n() {
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeNodeError(messages: string[]): NodeError {
|
||||
return {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: messages.map((message) => ({
|
||||
type: 'execution_error',
|
||||
message,
|
||||
details: 'details'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function mountOverlayState() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
return useErrorOverlayState()
|
||||
},
|
||||
template: `
|
||||
<section>
|
||||
<span data-testid="visible">{{ isVisible }}</span>
|
||||
<span data-testid="title">{{ overlayTitle }}</span>
|
||||
<span data-testid="message">{{ overlayMessage }}</span>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
|
||||
return render(Harness, {
|
||||
global: {
|
||||
plugins: [pinia, createTestI18n()]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('useErrorOverlayState', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
})
|
||||
|
||||
it('uses the raw message for a single uncataloged execution error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [{ message: 'Only error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Execution failed')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent('Only error')
|
||||
})
|
||||
|
||||
it('uses toast copy for a single validation error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Required input is missing',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input is missing',
|
||||
toastTitle: 'Required input missing',
|
||||
toastMessage: 'KSampler is missing a required input: model'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent(
|
||||
'Required input missing'
|
||||
)
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses display copy before raw copy when toast copy is absent', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Friendly validation title',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'Raw validation error',
|
||||
displayMessage: 'Friendly validation message'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent(
|
||||
'Friendly validation title'
|
||||
)
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Friendly validation message'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses toast copy for a single runtime error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Generation failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'torch.OutOfMemoryError: CUDA out of memory',
|
||||
toastTitle: 'Generation failed',
|
||||
toastMessage:
|
||||
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Generation failed')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group toast copy for a single missing media error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
missingMediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'image.png',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Media input missing')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Load Image is missing a required media file.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show when a raw error has no resolved overlay message', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('uses aggregate copy for multiple errors', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError([
|
||||
'First error',
|
||||
'Second error',
|
||||
'Third error',
|
||||
'Fourth error',
|
||||
'Fifth error',
|
||||
'Sixth error',
|
||||
'Seventh error'
|
||||
])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [{ message: 'First error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
})
|
||||
})
|
||||
103
src/components/error/useErrorOverlayState.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
|
||||
function resolveSingleOverlayCopy(
|
||||
group: ErrorGroup
|
||||
): { title?: string; message: string } | undefined {
|
||||
if (group.type === 'execution') {
|
||||
const [card] = group.cards
|
||||
const [error] = card?.errors ?? []
|
||||
const message =
|
||||
error?.toastMessage ??
|
||||
error?.displayMessage ??
|
||||
error?.message ??
|
||||
group.displayMessage ??
|
||||
group.displayTitle
|
||||
|
||||
if (!message) return undefined
|
||||
|
||||
return {
|
||||
title: error?.toastTitle ?? error?.displayTitle ?? group.displayTitle,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
group.toastMessage ?? group.displayMessage ?? group.displayTitle
|
||||
if (!message) return undefined
|
||||
|
||||
return {
|
||||
title: group.toastTitle ?? group.displayTitle,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
export function useErrorOverlayState() {
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { totalErrorCount, isErrorOverlayOpen } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { allErrorGroups } = useErrorGroups('')
|
||||
|
||||
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
|
||||
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
|
||||
const singleErrorGroup = computed(() =>
|
||||
hasExactlyOneError.value && allErrorGroups.value.length === 1
|
||||
? allErrorGroups.value[0]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const multipleErrorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.multipleErrorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const singleOverlayCopy = computed(() =>
|
||||
singleErrorGroup.value
|
||||
? resolveSingleOverlayCopy(singleErrorGroup.value)
|
||||
: undefined
|
||||
)
|
||||
|
||||
const overlayMessage = computed(() => {
|
||||
if (hasMultipleErrors.value) {
|
||||
return t('errorOverlay.multipleErrorsMessage')
|
||||
}
|
||||
|
||||
return singleOverlayCopy.value?.message ?? ''
|
||||
})
|
||||
|
||||
const overlayTitle = computed(() =>
|
||||
hasMultipleErrors.value
|
||||
? multipleErrorCountLabel.value
|
||||
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() =>
|
||||
isErrorOverlayOpen.value &&
|
||||
totalErrorCount.value > 0 &&
|
||||
overlayMessage.value.trim().length > 0
|
||||
)
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
overlayMessage,
|
||||
overlayTitle
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<ArrangeButton v-if="showArrange" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
|
||||
<ConfigureSubgraph v-if="showSubgraphButtons" />
|
||||
@@ -49,6 +50,7 @@
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ArrangeButton from '@/components/graph/selectionToolbox/ArrangeButton.vue'
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
|
||||
@@ -110,6 +112,7 @@ const {
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
const showArrange = computed(() => hasMultipleSelection.value)
|
||||
const showFrameNodes = computed(() => hasMultipleSelection.value)
|
||||
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
@@ -128,6 +131,7 @@ const showAnyPrimaryActions = computed(
|
||||
() =>
|
||||
showColorPicker.value ||
|
||||
showConvertToSubgraph.value ||
|
||||
showArrange.value ||
|
||||
showFrameNodes.value ||
|
||||
showSubgraphButtons.value
|
||||
)
|
||||
|
||||
115
src/components/graph/selectionToolbox/ArrangeButton.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
v-tooltip.top="{ value: t('g.arrange'), showDelay: 1000 }"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrange')"
|
||||
>
|
||||
<div class="flex items-center gap-1 px-0">
|
||||
<i class="icon-[lucide--layout-grid]" />
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
|
||||
>
|
||||
<div
|
||||
v-if="activeLayout"
|
||||
class="flex w-32 flex-row items-center px-2 py-1"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[gap]"
|
||||
:min="MIN_ARRANGE_GAP"
|
||||
:max="MAX_ARRANGE_GAP"
|
||||
:step="1"
|
||||
:aria-label="t('g.arrangeSpacing')"
|
||||
@update:model-value="onSliderUpdate"
|
||||
@value-commit="onSliderCommit"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-row gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('g.arrangeVertically'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeVertically')"
|
||||
@click="start('vertical')"
|
||||
>
|
||||
<i class="icon-[lucide--stretch-horizontal]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('g.arrangeHorizontally'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeHorizontally')"
|
||||
@click="start('horizontal')"
|
||||
>
|
||||
<i class="icon-[lucide--stretch-vertical]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="{ value: t('g.arrangeAsGrid'), showDelay: 1000 }"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeAsGrid')"
|
||||
@click="start('grid')"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3]" />
|
||||
</Button>
|
||||
</div>
|
||||
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
MAX_ARRANGE_GAP,
|
||||
MIN_ARRANGE_GAP
|
||||
} from '@/composables/graph/useArrangeNodes'
|
||||
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { activeLayout, gap, start, previewGap, commitGap, reset } =
|
||||
useArrangeSession()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) reset()
|
||||
})
|
||||
|
||||
const firstValue = (value: number[] | undefined): number | undefined =>
|
||||
value?.[0]
|
||||
|
||||
const onSliderUpdate = (value: number[] | undefined) => {
|
||||
const next = firstValue(value)
|
||||
if (next !== undefined) previewGap(next)
|
||||
}
|
||||
|
||||
const onSliderCommit = (value: number[]) => {
|
||||
const next = firstValue(value)
|
||||
if (next !== undefined) commitGap(next)
|
||||
}
|
||||
</script>
|
||||
@@ -71,7 +71,6 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -18,32 +18,10 @@
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.retainViewOnReload'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.retainViewOnReload')"
|
||||
:aria-pressed="retainViewOnReload"
|
||||
@click="retainViewOnReload = !retainViewOnReload"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-lg text-base-foreground',
|
||||
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
@@ -52,9 +30,6 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
|
||||
default: false
|
||||
})
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
|
||||
@@ -2,20 +2,12 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
|
||||
/**
|
||||
* ErrorNodeCard displays a single error card inside the error tab.
|
||||
* It shows the node header (ID badge, title, action buttons)
|
||||
* and the list of error items (message, traceback, copy button).
|
||||
*/
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
component: ErrorNodeCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
showNodeIdBadge: { control: 'boolean' }
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
@@ -105,58 +97,36 @@ const promptOnlyCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
/** Single validation error with node ID badge visible */
|
||||
export const WithNodeIdBadge: Story = {
|
||||
export const SingleValidationError: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Single validation error without node ID badge */
|
||||
export const WithoutNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: false
|
||||
card: singleErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Regular node error — no "Enter subgraph" button */
|
||||
export const WithoutEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
card: subgraphErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
card: multipleErrorsCard,
|
||||
showNodeIdBadge: true
|
||||
card: multipleErrorsCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime execution error with full traceback */
|
||||
export const RuntimeError: Story = {
|
||||
args: {
|
||||
card: runtimeErrorCard,
|
||||
showNodeIdBadge: true
|
||||
card: runtimeErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt-level error (no node header) */
|
||||
export const PromptError: Story = {
|
||||
args: {
|
||||
card: promptOnlyCard,
|
||||
showNodeIdBadge: false
|
||||
card: promptOnlyCard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
en: {
|
||||
g: {
|
||||
copy: 'Copy',
|
||||
details: 'Details',
|
||||
findIssues: 'Find Issues',
|
||||
findOnGithub: 'Find on GitHub',
|
||||
getHelpAction: 'Get Help'
|
||||
@@ -78,6 +79,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
'Report this error and we\u0027ll help you resolve it'
|
||||
@@ -96,8 +98,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const onCopyToClipboard = vi.fn()
|
||||
const onLocateNode = vi.fn()
|
||||
render(ErrorNodeCard, {
|
||||
props: { card, onCopyToClipboard },
|
||||
props: { card, onCopyToClipboard, onLocateNode },
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
@@ -131,14 +134,20 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
Button: {
|
||||
template:
|
||||
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user, onCopyToClipboard }
|
||||
return { user, onCopyToClipboard, onLocateNode }
|
||||
}
|
||||
|
||||
async function toggleRuntimeDetails(
|
||||
user: ReturnType<typeof userEvent.setup>
|
||||
) {
|
||||
await user.click(screen.getByRole('button', { name: /Details/ }))
|
||||
}
|
||||
|
||||
let cardIdCounter = 0
|
||||
@@ -160,40 +169,67 @@ describe('ErrorNodeCard.vue', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function makeValidationErrorCard(): ErrorCardData {
|
||||
function makePromptErrorCard(): ErrorCardData {
|
||||
return {
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '6',
|
||||
nodeTitle: 'CLIP Text Encode',
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs',
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: text'
|
||||
message: 'Server Error: No outputs',
|
||||
details: 'Error details',
|
||||
displayMessage:
|
||||
'The workflow does not contain any output nodes to produce a result.'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
it('displays enriched report for runtime errors on mount', async () => {
|
||||
it('shows runtime details by default and can collapse them', async () => {
|
||||
const reportText =
|
||||
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
|
||||
mockGenerateErrorReport.mockReturnValue(reportText)
|
||||
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Error log')).toBeInTheDocument()
|
||||
const detailsButton = screen.getByRole('button', { name: /Details/ })
|
||||
const detailsRegion = screen.getByRole('region', { name: 'Error log' })
|
||||
expect(detailsButton).toHaveAttribute(
|
||||
'aria-controls',
|
||||
detailsRegion.getAttribute('id')
|
||||
)
|
||||
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/System Information/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Find on GitHub/ })
|
||||
).toBeInTheDocument()
|
||||
|
||||
await toggleRuntimeDetails(user)
|
||||
|
||||
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Find on GitHub/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locates the node when the runtime node title is clicked', async () => {
|
||||
const { user, onLocateNode } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'KSampler' }))
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not generate report for non-runtime errors', async () => {
|
||||
renderCard(makeValidationErrorCard())
|
||||
renderCard(makePromptErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockGetLogs).not.toHaveBeenCalled()
|
||||
@@ -201,15 +237,15 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
it('displays original details for non-runtime errors', async () => {
|
||||
renderCard(makeValidationErrorCard())
|
||||
renderCard(makePromptErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error details')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays catalog-resolved copy when available', async () => {
|
||||
it('hides grouped catalog copy and shows the item label as a list item', async () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
@@ -229,17 +265,17 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('KSampler - model')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('listitem')).toHaveTextContent('KSampler - model')
|
||||
expect(screen.queryByText('Missing connection')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Required input slots have no connection feeding them.')
|
||||
).toBeInTheDocument()
|
||||
screen.queryByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('KSampler is missing a required input: model')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler - model')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('Required input is missing')
|
||||
screen.queryByText('KSampler is missing a required input: model')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -250,8 +286,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
||||
|
||||
@@ -261,21 +298,6 @@ describe('ErrorNodeCard.vue', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('copies original details when copy button is clicked for validation error', async () => {
|
||||
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
||||
|
||||
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
|
||||
'Required input is missing\n\nInput: text'
|
||||
)
|
||||
})
|
||||
|
||||
it('generates report with fallback logs when getLogs fails', async () => {
|
||||
mockGetLogs.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
@@ -300,8 +322,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens GitHub issues search when Find Issue button is clicked', async () => {
|
||||
@@ -310,9 +333,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Find on GitHub/ })
|
||||
).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
|
||||
@@ -335,9 +356,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Get Help/ })
|
||||
).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Get Help/ }))
|
||||
@@ -398,9 +417,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
|
||||
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-if="card.nodeTitle || card.title"
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
@@ -27,6 +28,24 @@
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasRuntimeError"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="t('g.details')"
|
||||
:aria-controls="runtimeDetailsControlIds || undefined"
|
||||
:aria-expanded="runtimeDetailsExpanded"
|
||||
@click.stop="toggleRuntimeDetails"
|
||||
>
|
||||
<i class="icon-[lucide--monitor-x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
@@ -39,120 +58,143 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<!-- Card Content -->
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-col gap-3',
|
||||
fullHeight && error.isRuntimeError && 'flex-1'
|
||||
)
|
||||
"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<!-- Human-friendly category/title when resolved by the error catalog. -->
|
||||
<p
|
||||
v-if="error.displayTitle"
|
||||
class="m-0 px-0.5 text-sm font-semibold text-destructive-background-hover"
|
||||
>
|
||||
{{ error.displayTitle }}
|
||||
</p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="getDisplayMessage(error)"
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getDisplayMessage(error) }}
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<!-- Traceback / Details (enriched with full report for runtime errors) -->
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
</button>
|
||||
<span v-else>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="displayedDetailsMap[idx]"
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background-hover p-2.5',
|
||||
error.isRuntimeError
|
||||
? fullHeight
|
||||
? 'min-h-0 flex-1'
|
||||
: 'max-h-[15lh]'
|
||||
: 'max-h-[6lh]'
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ displayedDetailsMap[idx] }}
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
{{ t('g.findOnGithub') }}
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
{{ t('g.copy') }}
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-full justify-center gap-1 rounded-lg text-xs"
|
||||
@click="handleGetHelp"
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
|
||||
:id="getRuntimeDetailsId(idx)"
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
{{ t('g.getHelpAction') }}
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
const {
|
||||
card,
|
||||
showNodeIdBadge = false,
|
||||
compact = false,
|
||||
fullHeight = false
|
||||
} = defineProps<{
|
||||
const { card, compact = false } = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
/** Hide card header and error message (used in single-node selection mode) */
|
||||
compact?: boolean
|
||||
/** Allow runtime error details to fill available height (used in dedicated panel) */
|
||||
fullHeight?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -164,6 +206,23 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const { displayedDetailsMap } = useErrorReport(() => card)
|
||||
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
|
||||
const runtimeDetailsExpanded = ref(true)
|
||||
const hasRuntimeError = computed(() =>
|
||||
card.errors.some((error) => error.isRuntimeError)
|
||||
)
|
||||
const isRuntimeDisclosureExpanded = computed(
|
||||
() => compact || runtimeDetailsExpanded.value
|
||||
)
|
||||
const runtimeDetailsControlIds = computed(() =>
|
||||
card.errors
|
||||
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
function toggleRuntimeDetails() {
|
||||
runtimeDetailsExpanded.value = !runtimeDetailsExpanded.value
|
||||
}
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
@@ -179,7 +238,7 @@ function handleEnterSubgraph() {
|
||||
|
||||
function handleCopyError(idx: number) {
|
||||
const details = displayedDetailsMap.value[idx]
|
||||
const message = getDisplayMessage(card.errors[idx])
|
||||
const message = getCopyMessage(card.errors[idx])
|
||||
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
|
||||
}
|
||||
|
||||
@@ -187,7 +246,26 @@ function handleCheckGithub(error: ErrorItem) {
|
||||
findOnGitHub(error.message)
|
||||
}
|
||||
|
||||
function getDisplayMessage(error: ErrorItem | undefined) {
|
||||
function getCopyMessage(error: ErrorItem | undefined) {
|
||||
return error?.displayMessage ?? error?.message
|
||||
}
|
||||
|
||||
function getInlineMessage(error: ErrorItem | undefined) {
|
||||
if (!error || error.displayMessage) return undefined
|
||||
return error.message
|
||||
}
|
||||
|
||||
function getInlineItemLabel(error: ErrorItem | undefined) {
|
||||
if (!error || error.isRuntimeError) return undefined
|
||||
return error.displayItemLabel
|
||||
}
|
||||
|
||||
function getInlineDetails(error: ErrorItem | undefined, idx: number) {
|
||||
if (getInlineItemLabel(error)) return undefined
|
||||
return displayedDetailsMap.value[idx]
|
||||
}
|
||||
|
||||
function getRuntimeDetailsId(idx: number) {
|
||||
return `${card.id}-runtime-details-${idx}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -90,8 +90,6 @@ const i18n = createI18n({
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
missingNodePacks: {
|
||||
ossMessage: 'Missing node packs detected. Install them.',
|
||||
cloudMessage: 'Unsupported node packs detected.',
|
||||
ossManagerDisabledHint:
|
||||
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
|
||||
applyChanges: 'Apply Changes'
|
||||
@@ -159,21 +157,6 @@ describe('MissingNodeCard', () => {
|
||||
})
|
||||
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders cloud message when isCloud is true', () => {
|
||||
mockIsCloud.value = true
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Unsupported node packs detected.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders OSS message when isCloud is false', () => {
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Missing node packs detected. Install them.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct number of MissingPackGroupRow components', () => {
|
||||
renderCard({ missingPackGroups: makePackGroups(3) })
|
||||
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)
|
||||
|
||||
@@ -36,19 +36,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
|
||||
<p
|
||||
class="m-0 text-sm/relaxed text-muted-foreground"
|
||||
:class="showManagerHint ? 'pb-3' : 'pb-5'"
|
||||
>
|
||||
{{
|
||||
isCloud
|
||||
? t('rightSidePanel.missingNodePacks.cloudMessage')
|
||||
: t('rightSidePanel.missingNodePacks.ossMessage')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- Manager disabled hint: shown on OSS when manager is not active -->
|
||||
<i18n-t
|
||||
v-if="showManagerHint"
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
const mockFocusNode = vi.hoisted(() => vi.fn())
|
||||
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -36,10 +41,28 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: mockFocusNode,
|
||||
enterSubgraph: mockEnterSubgraph
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
|
||||
downloadModel: vi.fn(),
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue({
|
||||
fileSize: null,
|
||||
gatedRepoUrl: null
|
||||
}),
|
||||
isModelDownloadable: vi.fn(() => true),
|
||||
toBrowsableUrl: vi.fn((url: string) => url)
|
||||
}))
|
||||
|
||||
describe('TabErrors.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -47,16 +70,39 @@ describe('TabErrors.vue', () => {
|
||||
en: {
|
||||
g: {
|
||||
workflow: 'Workflow',
|
||||
copy: 'Copy'
|
||||
copy: 'Copy',
|
||||
details: 'Details',
|
||||
findOnGithub: 'Find on GitHub',
|
||||
getHelpAction: 'Get Help'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
getHelpTooltip: 'Get help',
|
||||
info: 'Info',
|
||||
infoFor: 'Info for {item}',
|
||||
locateNode: 'Locate node',
|
||||
locateNodeFor: 'Locate {item}',
|
||||
missingModels: {
|
||||
missingModelsTitle: 'Missing Models',
|
||||
downloadAll: 'Download all',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing missing models.'
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaTitle: 'Missing Inputs',
|
||||
image: 'Images',
|
||||
uploadFile: 'Upload {type}',
|
||||
useFromLibrary: 'Use from Library',
|
||||
confirmSelection: 'Confirm selection',
|
||||
locateNode: 'Locate node',
|
||||
expandNodes: 'Show referencing nodes',
|
||||
collapseNodes: 'Hide referencing nodes',
|
||||
cancelSelection: 'Cancel selection',
|
||||
or: 'OR'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,29 +166,111 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by class_type', async () => {
|
||||
it('renders node validation errors grouped by catalog copy', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'CLIP Text Encode'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
const titles: Record<string, string> = {
|
||||
'1': 'KSampler',
|
||||
'2': 'CLIP Text Encode'
|
||||
}
|
||||
return {
|
||||
title: titles[String(nodeId)] ?? ''
|
||||
} as ReturnType<typeof getNodeByExecutionId>
|
||||
})
|
||||
|
||||
renderComponent({
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'6': {
|
||||
'2': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{ message: 'Required input is missing', details: 'Input: text' }
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: clip',
|
||||
extra_info: {
|
||||
input_name: 'clip'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: positive',
|
||||
extra_info: {
|
||||
input_name: 'positive'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: model',
|
||||
extra_info: {
|
||||
input_name: 'model'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
|
||||
expect(screen.getByText('#6')).toBeInTheDocument()
|
||||
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
|
||||
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(screen.queryByText('#1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('#2')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('CLIP Text Encode')).not.toBeInTheDocument()
|
||||
|
||||
const itemRows = screen.getAllByRole('listitem')
|
||||
expect(itemRows).toHaveLength(3)
|
||||
expect(itemRows[0]).toHaveTextContent('KSampler - model')
|
||||
expect(itemRows[1]).toHaveTextContent('KSampler - positive')
|
||||
expect(itemRows[2]).toHaveTextContent('CLIP Text Encode - clip')
|
||||
|
||||
const infoButton = within(itemRows[1]).getByRole('button', {
|
||||
name: 'Info for KSampler - positive'
|
||||
})
|
||||
|
||||
await user.click(infoButton)
|
||||
|
||||
const itemDetail = screen.getByText(
|
||||
'KSampler is missing a required input: positive'
|
||||
)
|
||||
expect(infoButton).toHaveAttribute(
|
||||
'aria-controls',
|
||||
itemDetail.getAttribute('id')
|
||||
)
|
||||
|
||||
const labelLocateButton = within(itemRows[1]).getByRole('button', {
|
||||
name: 'KSampler - positive'
|
||||
})
|
||||
|
||||
await user.click(labelLocateButton)
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('1')
|
||||
|
||||
const iconLocateButton = within(itemRows[2]).getByRole('button', {
|
||||
name: 'Locate CLIP Text Encode - clip'
|
||||
})
|
||||
|
||||
await user.click(iconLocateButton)
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('2')
|
||||
|
||||
expect(
|
||||
screen.queryByText('Required input is missing')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: model')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: positive')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: clip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders runtime execution errors from WebSocket', async () => {
|
||||
@@ -151,7 +279,7 @@ describe('TabErrors.vue', () => {
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
renderComponent({
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
@@ -166,12 +294,16 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Node threw an error during execution.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Error log')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Details' }))
|
||||
|
||||
expect(screen.queryByText(/Line 1/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters errors based on search query', async () => {
|
||||
@@ -206,7 +338,7 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls copyToClipboard when copy button is clicked', async () => {
|
||||
it('calls copyToClipboard when a runtime error copy button is clicked', async () => {
|
||||
const { useCopyToClipboard } =
|
||||
await import('@/composables/useCopyToClipboard')
|
||||
const mockCopy = vi.fn()
|
||||
@@ -214,21 +346,26 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
errors: [{ message: 'Test message', details: 'Test details' }]
|
||||
}
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '1',
|
||||
node_type: 'TestNode',
|
||||
exception_message: 'Test message',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['Test details'],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('error-card-copy'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
expect(mockCopy).toHaveBeenCalledWith(
|
||||
'Node threw an error during execution.\n\nTest details'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders single runtime error outside accordion in full-height panel', async () => {
|
||||
it('renders a single runtime error in the normal execution group', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'KSampler'
|
||||
@@ -250,7 +387,11 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByTestId(
|
||||
'runtime-error-panel'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -282,6 +423,85 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'local-only.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
} satisfies MissingModelCandidate
|
||||
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [missingModel]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing media display message below the section title', () => {
|
||||
const missingMedia = {
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'portrait.png',
|
||||
isMissing: true
|
||||
} satisfies MissingMediaCandidate
|
||||
|
||||
renderComponent({
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [missingMedia]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
replacement: {
|
||||
old_node_id: 'OldSampler',
|
||||
new_node_id: 'KSampler',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}
|
||||
} satisfies MissingNodeType
|
||||
|
||||
renderComponent({
|
||||
missingNodesError: {
|
||||
missingNodesError: {
|
||||
message: 'Missing Node Packs',
|
||||
nodeTypes: [swapNode]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('KSampler')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Replace Node/ })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
|
||||
@@ -11,32 +11,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Runtime error: full-height panel outside accordion -->
|
||||
<div
|
||||
v-if="singleRuntimeErrorCard"
|
||||
data-testid="runtime-error-panel"
|
||||
aria-live="polite"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
|
||||
>
|
||||
{{ singleRuntimeErrorGroup?.displayTitle }}
|
||||
</div>
|
||||
<ErrorNodeCard
|
||||
:key="singleRuntimeErrorCard.id"
|
||||
:card="singleRuntimeErrorCard"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
full-height
|
||||
class="min-h-0 flex-1"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content (non-runtime or mixed errors) -->
|
||||
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
@@ -70,10 +45,13 @@
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.type === 'execution' && group.cards.length > 1"
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ group.cards.length }})
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
@@ -154,6 +132,18 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
@@ -166,7 +156,7 @@
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@@ -174,12 +164,79 @@
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@@ -189,7 +246,7 @@
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-else-if="group.type === 'missing_model'"
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
@@ -197,7 +254,7 @@
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-else-if="group.type === 'missing_media'"
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
@@ -243,6 +300,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
@@ -254,6 +312,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
@@ -273,6 +332,13 @@ import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
key: string
|
||||
nodeId: string
|
||||
label: string
|
||||
displayDetails?: string
|
||||
}
|
||||
|
||||
const ErrorPanelSurveyCta =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
@@ -295,6 +361,7 @@ const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
@@ -313,6 +380,78 @@ const showNodeIdBadge = computed(
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
group.cards.length > 0 &&
|
||||
group.cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
const items: ExecutionItemListEntry[] = []
|
||||
for (const card of group.cards) {
|
||||
if (!card.nodeId) continue
|
||||
for (let idx = 0; idx < card.errors.length; idx++) {
|
||||
const error = card.errors[idx]
|
||||
const label = error.displayItemLabel
|
||||
if (!label) continue
|
||||
items.push({
|
||||
key: `${card.id}:${idx}`,
|
||||
nodeId: card.nodeId,
|
||||
label,
|
||||
displayDetails: error.displayDetails
|
||||
})
|
||||
}
|
||||
}
|
||||
return items.sort(compareExecutionItemListEntry)
|
||||
}
|
||||
|
||||
function compareExecutionItemListEntry(
|
||||
a: ExecutionItemListEntry,
|
||||
b: ExecutionItemListEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionGroupCount(group: ErrorGroup) {
|
||||
if (group.type !== 'execution') return 0
|
||||
if (isExecutionItemListGroup(group)) {
|
||||
return group.cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
return group.cards.length
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
|
||||
function toggleExecutionItemDetail(key: string) {
|
||||
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key)
|
||||
} else {
|
||||
nextKeys.add(key)
|
||||
}
|
||||
expandedExecutionItemDetailKeys.value = nextKeys
|
||||
}
|
||||
|
||||
function getExecutionItemDetailId(key: string) {
|
||||
return `execution-item-detail-${key}`
|
||||
}
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
@@ -344,20 +483,6 @@ function handleMissingModelRefresh() {
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
const singleRuntimeErrorGroup = computed(() => {
|
||||
if (filteredGroups.value.length !== 1) return null
|
||||
const group = filteredGroups.value[0]
|
||||
const isSoleRuntimeError =
|
||||
group.type === 'execution' &&
|
||||
group.cards.length === 1 &&
|
||||
group.cards[0].errors.every((e) => e.isRuntimeError)
|
||||
return isSoleRuntimeError ? group : null
|
||||
})
|
||||
|
||||
const singleRuntimeErrorCard = computed(
|
||||
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
|
||||
)
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
|
||||
|
||||
@@ -23,6 +23,9 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
}))
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
const unknownValidationMessage = vi.hoisted(
|
||||
() => 'A node returned a validation error ComfyUI does not recognize.'
|
||||
)
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
@@ -43,6 +46,18 @@ vi.mock('@/i18n', () => {
|
||||
'Required input missing',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastMessage':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.title':
|
||||
'Validation failed',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.message':
|
||||
unknownValidationMessage,
|
||||
'errorCatalog.validationErrors.unknown_validation_error.detailsWithRawDetails':
|
||||
'{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.itemLabel':
|
||||
'{nodeName}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.toastTitle':
|
||||
'Validation failed',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.toastMessage':
|
||||
'{nodeName} returned an unrecognized validation error.',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
@@ -174,6 +189,7 @@ describe('useErrorGroups', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockIsCloud.value = false
|
||||
vi.mocked(isLGraphNode).mockReturnValue(false)
|
||||
})
|
||||
|
||||
describe('missingPackGroups', () => {
|
||||
@@ -301,7 +317,24 @@ describe('useErrorGroups', () => {
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Some nodes are missing and need to be installed'
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Cloud copy for missing_node group in Cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
|
||||
)
|
||||
})
|
||||
|
||||
@@ -366,7 +399,7 @@ describe('useErrorGroups', () => {
|
||||
expect(swapIdx).toBeLessThan(missingIdx)
|
||||
})
|
||||
|
||||
it('includes execution error groups from node errors', async () => {
|
||||
it('uses fallback catalog grouping for unknown node validation errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
@@ -387,8 +420,8 @@ describe('useErrorGroups', () => {
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
expect(execGroups[0].groupKey).toBe('execution:KSampler')
|
||||
expect(execGroups[0].displayTitle).toBe('KSampler')
|
||||
expect(execGroups[0].groupKey).toBe('execution:unknown_validation_error')
|
||||
expect(execGroups[0].displayTitle).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('resolves required_input_missing item display copy', async () => {
|
||||
@@ -437,6 +470,55 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups node validation errors by catalog id across node types', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'model',
|
||||
extra_info: {
|
||||
input_name: 'model'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'clip',
|
||||
extra_info: {
|
||||
input_name: 'clip'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroups = groups.allErrorGroups.value.filter(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroups).toHaveLength(1)
|
||||
|
||||
const [group] = execGroups
|
||||
expect(group.groupKey).toBe('execution:missing_connection')
|
||||
expect(group.displayTitle).toBe('Missing connection')
|
||||
expect(group.cards.map((card) => card.title)).toEqual([
|
||||
'KSampler',
|
||||
'CLIPLoader'
|
||||
])
|
||||
expect(group.cards.flatMap((card) => card.errors)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { store, groups } = createErrorGroups()
|
||||
@@ -524,6 +606,28 @@ describe('useErrorGroups', () => {
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
it('includes prompt error when a node is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '1' }])
|
||||
store.lastPromptError = {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'No outputs',
|
||||
details: ''
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
it('sorts cards within an execution group by nodeId numerically', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
@@ -676,7 +780,7 @@ describe('useErrorGroups', () => {
|
||||
expect(groups.groupedErrorMessages.value).toEqual([])
|
||||
})
|
||||
|
||||
it('collects unique error messages from node errors', async () => {
|
||||
it('collects unique display messages from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
@@ -696,10 +800,7 @@ describe('useErrorGroups', () => {
|
||||
await nextTick()
|
||||
|
||||
const messages = groups.groupedErrorMessages.value
|
||||
expect(messages).toContain('Error A')
|
||||
expect(messages).toContain('Error B')
|
||||
// Deduplication: Error A appears twice but should only be listed once
|
||||
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
|
||||
expect(messages).toEqual([unknownValidationMessage])
|
||||
})
|
||||
|
||||
it('includes missing node group display message', async () => {
|
||||
@@ -960,4 +1061,28 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabErrorGroups', () => {
|
||||
it('filters prompt error when a node is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '1' }])
|
||||
store.lastPromptError = {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'No outputs',
|
||||
details: ''
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.tabErrorGroups.value.find(
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
@@ -66,6 +66,7 @@ export interface SwapNodeGroup {
|
||||
interface GroupEntry {
|
||||
type: 'execution'
|
||||
displayTitle: string
|
||||
displayMessage?: string
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
}
|
||||
@@ -75,10 +76,14 @@ interface ErrorSearchItem {
|
||||
cardIndex: number
|
||||
searchableNodeId: string
|
||||
searchableNodeTitle: string
|
||||
searchableRawMessage: string
|
||||
searchableRawDetails: string
|
||||
searchableMessage: string
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/**
|
||||
* Resolve display info for a node by its execution ID.
|
||||
* For group node internals, resolves the parent group node's title instead.
|
||||
@@ -106,17 +111,21 @@ function getOrCreateGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
groupKey: string,
|
||||
displayTitle = groupKey,
|
||||
priority = 1
|
||||
priority = 1,
|
||||
displayMessage?: string
|
||||
): Map<string, ErrorCardData> {
|
||||
let entry = groupsMap.get(groupKey)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
type: 'execution',
|
||||
displayTitle,
|
||||
displayMessage,
|
||||
priority,
|
||||
cards: new Map()
|
||||
}
|
||||
groupsMap.set(groupKey, entry)
|
||||
} else if (!entry.displayMessage && displayMessage) {
|
||||
entry.displayMessage = displayMessage
|
||||
}
|
||||
return entry.cards
|
||||
}
|
||||
@@ -138,44 +147,6 @@ function createErrorCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In single-node mode, regroup cards by error message instead of class_type.
|
||||
* This lets the user see "what kinds of errors this node has" at a glance.
|
||||
*/
|
||||
function regroupByErrorMessage(
|
||||
groupsMap: Map<string, GroupEntry>
|
||||
): Map<string, GroupEntry> {
|
||||
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
|
||||
Array.from(g.cards.values())
|
||||
)
|
||||
|
||||
const cardErrorPairs = allCards.flatMap((card) =>
|
||||
card.errors.map((error) => ({ card, error }))
|
||||
)
|
||||
|
||||
const messageMap = new Map<string, GroupEntry>()
|
||||
for (const { card, error } of cardErrorPairs) {
|
||||
addCardErrorToGroup(messageMap, card, error)
|
||||
}
|
||||
|
||||
return messageMap
|
||||
}
|
||||
|
||||
function addCardErrorToGroup(
|
||||
messageMap: Map<string, GroupEntry>,
|
||||
card: ErrorCardData,
|
||||
error: ErrorItem
|
||||
) {
|
||||
const displayTitle =
|
||||
error.displayTitle ?? error.displayMessage ?? error.message
|
||||
const groupKey = error.catalogId ?? displayTitle
|
||||
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
|
||||
if (!group.has(card.id)) {
|
||||
group.set(card.id, { ...card, errors: [] })
|
||||
}
|
||||
group.get(card.id)?.errors.push(error)
|
||||
}
|
||||
|
||||
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
|
||||
return compareExecutionId(a.nodeId, b.nodeId)
|
||||
}
|
||||
@@ -186,6 +157,7 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
@@ -209,6 +181,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableRawMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableRawDetails: card.errors.map((e) => e.details).join(' '),
|
||||
searchableMessage: card.errors
|
||||
.map((e) =>
|
||||
[e.displayTitle, e.displayMessage, e.message]
|
||||
@@ -225,9 +199,11 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
|
||||
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
|
||||
keys: [
|
||||
{ name: 'searchableNodeId', weight: 0.3 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.3 },
|
||||
{ name: 'searchableMessage', weight: 0.3 },
|
||||
{ name: 'searchableRawMessage', weight: 0.3 },
|
||||
{ name: 'searchableNodeId', weight: 0.2 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.2 },
|
||||
{ name: 'searchableMessage', weight: 0.2 },
|
||||
{ name: 'searchableRawDetails', weight: 0.1 },
|
||||
{ name: 'searchableDetails', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3
|
||||
@@ -333,22 +309,33 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
errors: ErrorItem[],
|
||||
error: CataloguedErrorItem,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (filterBySelection && !isErrorInSelection(nodeId)) return
|
||||
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
|
||||
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
|
||||
const cards = getOrCreateGroup(
|
||||
groupsMap,
|
||||
error.catalogId,
|
||||
error.displayTitle ?? classType,
|
||||
1,
|
||||
error.displayMessage
|
||||
)
|
||||
if (!cards.has(nodeId)) {
|
||||
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) return
|
||||
card.errors.push(...errors)
|
||||
card.errors.push(error)
|
||||
}
|
||||
|
||||
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
||||
if (selectedNodeInfo.value.nodeIds || !executionErrorStore.lastPromptError)
|
||||
function processPromptError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (
|
||||
(filterBySelection && selectedNodeInfo.value.nodeIds) ||
|
||||
!executionErrorStore.lastPromptError
|
||||
)
|
||||
return
|
||||
|
||||
const error = executionErrorStore.lastPromptError
|
||||
@@ -362,7 +349,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groupsMap,
|
||||
`prompt:${error.type}`,
|
||||
groupDisplayTitle,
|
||||
0
|
||||
0,
|
||||
resolvedDisplay.displayMessage
|
||||
)
|
||||
|
||||
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
|
||||
@@ -389,13 +377,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
)) {
|
||||
const nodeDisplayName =
|
||||
resolveNodeInfo(nodeId).title || nodeError.class_type
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
nodeError.errors.map((e) => {
|
||||
return {
|
||||
for (const e of nodeError.errors) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
{
|
||||
message: e.message,
|
||||
details: e.details ?? undefined,
|
||||
...resolveRunErrorMessage({
|
||||
@@ -403,10 +391,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
error: e,
|
||||
nodeDisplayName
|
||||
})
|
||||
}
|
||||
}),
|
||||
filterBySelection
|
||||
)
|
||||
},
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,20 +410,18 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
[
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
}
|
||||
],
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
},
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
@@ -717,7 +703,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
kind: 'missing_media',
|
||||
groups: missingMediaGroups.value,
|
||||
count: totalItems,
|
||||
mediaTypes: missingMediaGroups.value.map((group) => group.mediaType),
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
@@ -834,9 +819,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
kind: 'missing_media',
|
||||
groups: filteredMissingMediaGroups.value,
|
||||
count: totalItems,
|
||||
mediaTypes: filteredMissingMediaGroups.value.map(
|
||||
(group) => group.mediaType
|
||||
),
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
@@ -861,14 +843,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processPromptError(groupsMap, true)
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
const executionGroups = isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
const filterByNode = selectedNodeInfo.value.nodeIds !== null
|
||||
|
||||
// Missing nodes are intentionally unfiltered — they represent
|
||||
@@ -881,7 +859,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...(filterByNode
|
||||
? buildMissingMediaGroupsFiltered()
|
||||
: buildMissingMediaGroups()),
|
||||
...executionGroups
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
@@ -126,6 +127,8 @@ const placeholder = computed(() => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
|
||||
useSearchQueryTracking('node_modal', currentQuery, suggestions)
|
||||
|
||||
// Debounced search tracking (500ms as per implementation plan)
|
||||
const debouncedTrackSearch = debounce((query: string) => {
|
||||
if (query.trim()) {
|
||||
|
||||
@@ -121,6 +121,7 @@ import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
@@ -344,6 +345,8 @@ const hoveredNodeDef = computed(
|
||||
() => displayedResults.value[selectedIndex.value] ?? null
|
||||
)
|
||||
|
||||
useSearchQueryTracking('node_modal', searchQuery, displayedResults)
|
||||
|
||||
watch(
|
||||
hoveredNodeDef,
|
||||
(newVal) => {
|
||||
|
||||
@@ -166,6 +166,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
@@ -212,6 +213,7 @@ const filteredWorkflows = computed(() => {
|
||||
workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
)
|
||||
})
|
||||
useSearchQueryTracking('apps', searchQuery, filteredWorkflows)
|
||||
const filteredRoot = computed<TreeNode>(() => {
|
||||
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
|
||||
})
|
||||
|
||||
@@ -190,6 +190,7 @@ import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import {
|
||||
DEFAULT_GROUPING_ID,
|
||||
@@ -337,6 +338,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
})
|
||||
|
||||
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
|
||||
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
|
||||
const filters: Ref<
|
||||
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
|
||||
> = ref([])
|
||||
|
||||
@@ -190,6 +190,7 @@ import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import {
|
||||
DEFAULT_SORTING_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
@@ -289,6 +290,8 @@ const activeNodes = computed(() =>
|
||||
: filteredNodeDefs.value
|
||||
)
|
||||
|
||||
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
|
||||
|
||||
const hasNoMatches = computed(
|
||||
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
|
||||
)
|
||||
|
||||
@@ -204,6 +204,90 @@ describe('contextMenuConverter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('media node ordering (FE-839)', () => {
|
||||
const IMAGE_GROUP = [
|
||||
'Open Image',
|
||||
'Open in Mask Editor',
|
||||
'Copy Image',
|
||||
'Paste Image',
|
||||
'Save Image'
|
||||
]
|
||||
|
||||
// Input order is intentionally scrambled to prove the rendered order is
|
||||
// governed by MENU_ORDER, not by the order options are passed in.
|
||||
const mediaNodeOptions = (): MenuOption[] => [
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
{ label: 'Copy', source: 'vue' },
|
||||
{ label: 'Pin', source: 'vue' },
|
||||
{ label: 'Node Info', source: 'vue' },
|
||||
{ label: 'Open in Mask Editor', source: 'vue' },
|
||||
{ label: 'Open Image', source: 'vue' },
|
||||
{ label: 'Copy Image', source: 'vue' },
|
||||
{ label: 'Paste Image', source: 'vue' },
|
||||
{ label: 'Save Image', source: 'vue' }
|
||||
]
|
||||
|
||||
const firstActionLabel = (result: MenuOption[]) =>
|
||||
result.find((opt) => opt.type !== 'divider' && opt.type !== 'category')
|
||||
?.label
|
||||
const indexOfLabel = (result: MenuOption[], label: string) =>
|
||||
result.findIndex((opt) => opt.label === label)
|
||||
|
||||
it('places Open Image as the first item for media nodes', () => {
|
||||
expect(firstActionLabel(buildStructuredMenu(mediaNodeOptions()))).toBe(
|
||||
'Open Image'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces the whole image action group above generic node actions', () => {
|
||||
const result = buildStructuredMenu(mediaNodeOptions())
|
||||
const renameIndex = indexOfLabel(result, 'Rename')
|
||||
for (const label of IMAGE_GROUP) {
|
||||
expect(indexOfLabel(result, label)).toBeLessThan(renameIndex)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Copy Image, Paste Image, Save Image in that relative order', () => {
|
||||
const result = buildStructuredMenu(mediaNodeOptions())
|
||||
expect(indexOfLabel(result, 'Copy Image')).toBeLessThan(
|
||||
indexOfLabel(result, 'Paste Image')
|
||||
)
|
||||
expect(indexOfLabel(result, 'Paste Image')).toBeLessThan(
|
||||
indexOfLabel(result, 'Save Image')
|
||||
)
|
||||
})
|
||||
|
||||
it('separates the image group from core actions with a divider', () => {
|
||||
const result = buildStructuredMenu(mediaNodeOptions())
|
||||
const saveIndex = indexOfLabel(result, 'Save Image')
|
||||
const renameIndex = indexOfLabel(result, 'Rename')
|
||||
const hasDividerBetween = result
|
||||
.slice(saveIndex + 1, renameIndex)
|
||||
.some((opt) => opt.type === 'divider')
|
||||
expect(hasDividerBetween).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the relative order of core actions below the image group', () => {
|
||||
const result = buildStructuredMenu(mediaNodeOptions())
|
||||
expect(indexOfLabel(result, 'Rename')).toBeLessThan(
|
||||
indexOfLabel(result, 'Pin')
|
||||
)
|
||||
expect(indexOfLabel(result, 'Pin')).toBeLessThan(
|
||||
indexOfLabel(result, 'Node Info')
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves non-media node menus starting with Rename', () => {
|
||||
const result = buildStructuredMenu([
|
||||
{ label: 'Node Info', source: 'vue' },
|
||||
{ label: 'Pin', source: 'vue' },
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
{ label: 'Copy', source: 'vue' }
|
||||
])
|
||||
expect(firstActionLabel(result)).toBe('Rename')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertContextMenuToOptions', () => {
|
||||
it('should convert empty array to empty result', () => {
|
||||
const result = convertContextMenuToOptions([])
|
||||
|
||||
@@ -227,6 +227,12 @@ function removeDuplicateMenuOptions(options: MenuOption[]): MenuOption[] {
|
||||
* Order groups for menu items - defines the display order of sections
|
||||
*/
|
||||
const MENU_ORDER: string[] = [
|
||||
// Section 0: Media operations (surfaced at the top for media nodes)
|
||||
'Open Image',
|
||||
'Open in Mask Editor',
|
||||
'Copy Image',
|
||||
'Paste Image',
|
||||
'Save Image',
|
||||
// Section 1: Basic operations
|
||||
'Rename',
|
||||
'Copy',
|
||||
@@ -248,12 +254,7 @@ const MENU_ORDER: string[] = [
|
||||
// Section 4: Node properties
|
||||
'Node Info',
|
||||
'Color',
|
||||
// Section 5: Node-specific operations
|
||||
'Open in Mask Editor',
|
||||
'Open Image',
|
||||
'Copy Image',
|
||||
'Paste Image',
|
||||
'Save Image',
|
||||
// Section 5: Clipspace operations
|
||||
'Copy (Clipspace)',
|
||||
'Paste (Clipspace)'
|
||||
]
|
||||
@@ -309,27 +310,29 @@ export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
|
||||
coreLabels.sort((a, b) => getMenuItemOrder(a) - getMenuItemOrder(b))
|
||||
|
||||
// Section boundaries based on MENU_ORDER indices
|
||||
// Section 1: 0-2 (Rename, Copy, Duplicate)
|
||||
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
|
||||
// Section 3: 9-14 (Convert to Subgraph, Frame selection, Frame Nodes, Minimize Node, Expand Node, Clone)
|
||||
// Section 4: 15-16 (Node Info, Color)
|
||||
// Section 5: 17+ (Image operations and fallback items)
|
||||
// Section 0: 0-4 (Open Image, Open in Mask Editor, Copy Image, Paste Image, Save Image)
|
||||
// Section 1: 5-7 (Rename, Copy, Duplicate)
|
||||
// Section 2: 8-13 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
|
||||
// Section 3: 14-19 (Convert to Subgraph, Frame selection, Frame Nodes, Minimize Node, Expand Node, Clone)
|
||||
// Section 4: 20-21 (Node Info, Color)
|
||||
// Section 5: 22+ (Clipspace and fallback items)
|
||||
const getSectionNumber = (index: number): number => {
|
||||
if (index <= 2) return 1
|
||||
if (index <= 8) return 2
|
||||
if (index <= 14) return 3
|
||||
if (index <= 16) return 4
|
||||
if (index <= 4) return 0
|
||||
if (index <= 7) return 1
|
||||
if (index <= 13) return 2
|
||||
if (index <= 19) return 3
|
||||
if (index <= 21) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
let lastSection = 0
|
||||
let lastSection = -1
|
||||
for (const label of coreLabels) {
|
||||
const item = coreItemsMap.get(label)!
|
||||
const itemIndex = getMenuItemOrder(label)
|
||||
const currentSection = getSectionNumber(itemIndex)
|
||||
|
||||
// Add divider when moving to a new section
|
||||
if (lastSection > 0 && currentSection !== lastSection) {
|
||||
if (lastSection !== -1 && currentSection !== lastSection) {
|
||||
orderedCoreItems.push({ type: 'divider' })
|
||||
}
|
||||
|
||||
|
||||
182
src/composables/graph/useArrangeNodes.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
interface MockNodeSpec {
|
||||
id: number | string
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
title_mode?: TitleMode
|
||||
}
|
||||
|
||||
const makeNode = (spec: MockNodeSpec): LGraphNode =>
|
||||
({
|
||||
id: spec.id,
|
||||
pos: spec.pos,
|
||||
size: spec.size,
|
||||
title_mode: spec.title_mode
|
||||
}) as unknown as LGraphNode
|
||||
|
||||
const GAP = 12
|
||||
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
|
||||
|
||||
describe('computeArrangement', () => {
|
||||
it('returns no updates when fewer than 2 nodes are selected', () => {
|
||||
expect(computeArrangement([], 'vertical')).toEqual([])
|
||||
expect(
|
||||
computeArrangement(
|
||||
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
|
||||
'grid'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
describe('vertical', () => {
|
||||
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
|
||||
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
|
||||
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
|
||||
]
|
||||
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
|
||||
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
|
||||
// Each node's pos.y = visualTop + its titleHeight (30).
|
||||
// b: pos.y = 70+30 = 100; visualTop += (30+30)+12 = 142
|
||||
// a: pos.y = 142+30 = 172; visualTop += (50+30)+12 = 234
|
||||
// c: pos.y = 234+30 = 264
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 'b', position: { x: 10, y: 100 } },
|
||||
{ nodeId: 'a', position: { x: 10, y: 172 } },
|
||||
{ nodeId: 'c', position: { x: 10, y: 264 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the title-height contribution for NO_TITLE nodes', () => {
|
||||
const nodes = [
|
||||
makeNode({
|
||||
id: 1,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
}),
|
||||
makeNode({
|
||||
id: 2,
|
||||
pos: [0, 200],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
})
|
||||
]
|
||||
// No titles: visualHeight = size[1] = 100. visualTop = 0. Gap = 12.
|
||||
// 1: pos.y = 0; visualTop = 0 + 100 + 12 = 112.
|
||||
// 2: pos.y = 112.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves heterogeneous heights when computing gaps', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
|
||||
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
|
||||
]
|
||||
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
|
||||
// 2: pos.y = 212+30 = 242.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('horizontal', () => {
|
||||
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
|
||||
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
|
||||
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
|
||||
]
|
||||
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
|
||||
// Sort by X: b(0), a(100), c(300)
|
||||
// Lay out from (100, 50):
|
||||
// b at (100, 50)
|
||||
// a at (100 + 60 + 12, 50) = (172, 50)
|
||||
// c at (172 + 80 + 12, 50) = (264, 50)
|
||||
const result = computeArrangement(nodes, 'horizontal')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 'b', position: { x: 100, y: 50 } },
|
||||
{ nodeId: 'a', position: { x: 172, y: 50 } },
|
||||
{ nodeId: 'c', position: { x: 264, y: 50 } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid', () => {
|
||||
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
|
||||
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
|
||||
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
|
||||
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
|
||||
]
|
||||
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
|
||||
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
|
||||
// Row visual heights: row0=max(50+30,60+30)=90; row1=max(40+30,30+30)=70.
|
||||
// colX=[0, 132]. rowVisualTop=[-30, -30+90+12=72].
|
||||
// pos.y = rowVisualTop + 30 (titleHeight).
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 132, y: 0 } },
|
||||
{ nodeId: 3, position: { x: 0, y: 102 } },
|
||||
{ nodeId: 4, position: { x: 132, y: 102 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
|
||||
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
|
||||
const nodes = Array.from({ length: 5 }, (_, i) =>
|
||||
makeNode({
|
||||
id: i + 1,
|
||||
pos: [i * 50, i * 50],
|
||||
size: [40, 40]
|
||||
})
|
||||
)
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toHaveLength(5)
|
||||
// Sorted by Y then X = original order. Anchor = node 1 at (0,0).
|
||||
// colWidths=[40,40,40]. rowVisualHeight = 40+30 = 70 each.
|
||||
// colX=[0,52,104]. rowVisualTop=[-30, -30+70+12=52]. pos.y = visualTop+30.
|
||||
expect(result[0].position).toEqual({ x: 0, y: 0 })
|
||||
expect(result[1].position).toEqual({ x: 52, y: 0 })
|
||||
expect(result[2].position).toEqual({ x: 104, y: 0 })
|
||||
expect(result[3].position).toEqual({ x: 0, y: 82 })
|
||||
expect(result[4].position).toEqual({ x: 52, y: 82 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('anchor selection', () => {
|
||||
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
|
||||
const nodes = [
|
||||
// min y but large x: x+y = 1000
|
||||
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
|
||||
// min x but large y: x+y = 1000
|
||||
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
|
||||
// smallest x+y: 600
|
||||
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
|
||||
]
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).
|
||||
expect(result[0]).toEqual({
|
||||
nodeId: 'minY',
|
||||
position: { x: 300, y: 300 }
|
||||
})
|
||||
expect(result.every((u) => u.position.x === 300)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||