Compare commits
18 Commits
codex/cove
...
rizumu/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97f16d51f | ||
|
|
d467031c1a | ||
|
|
9bd55b8588 | ||
|
|
f4e0430072 | ||
|
|
c78592c1ec | ||
|
|
00b0c6b434 | ||
|
|
da34fa3944 | ||
|
|
c8ed15da31 | ||
|
|
b132abc64a | ||
|
|
55c52a730a | ||
|
|
fbe462143a | ||
|
|
61cb1bcde0 | ||
|
|
9dcab4ee96 | ||
|
|
dc29f30b02 | ||
|
|
fb3350ee0e | ||
|
|
be8e0010ee | ||
|
|
d0e97d6933 | ||
|
|
3e34506515 |
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,6 +55,3 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
BIN
apps/website/public/images/mcp/mcp-thumb-asphalt.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-concepts.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-kaiju.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-keyart.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-moodboard.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -26,7 +26,7 @@ function toggle(index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
|
||||
120
apps/website/src/components/blocks/FeatureGrid01.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
type CardAction =
|
||||
| {
|
||||
type: 'link'
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
export interface FeatureCard {
|
||||
id: string
|
||||
label?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<p
|
||||
v-if="card.label"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ card.label }}
|
||||
</p>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
|
||||
card.label && 'mt-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="card.action" class="mt-6">
|
||||
<Button
|
||||
v-if="card.action.type === 'link'"
|
||||
as="a"
|
||||
:href="card.action.href"
|
||||
:target="card.action.target"
|
||||
:rel="
|
||||
card.action.target === '_blank'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
</Button>
|
||||
<CopyableField
|
||||
v-else
|
||||
:value="card.action.value"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
100
apps/website/src/components/blocks/FeatureGrid02.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
|
||||
|
||||
type Cta = { label: string; href: string; target?: '_blank' }
|
||||
|
||||
export interface FeatureStep {
|
||||
id: string
|
||||
number: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
steps: readonly FeatureStep[]
|
||||
primaryCta?: Cta
|
||||
secondaryCta?: Cta
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader>{{ heading }}</SectionHeader>
|
||||
|
||||
<!-- Step cards in a row, joined by node-union connectors on desktop -->
|
||||
<div
|
||||
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
|
||||
>
|
||||
<template v-for="(step, i) in steps" :key="step.id">
|
||||
<div
|
||||
v-if="i > 0"
|
||||
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<NodeUnionIcon
|
||||
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-4 p-8">
|
||||
<div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ step.number }}
|
||||
</p>
|
||||
<h3
|
||||
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="primaryCta || secondaryCta"
|
||||
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
|
||||
>
|
||||
<Button
|
||||
v-if="primaryCta"
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="
|
||||
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
size="lg"
|
||||
class="w-full lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="
|
||||
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
108
apps/website/src/components/blocks/FeatureRows01.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import type { VideoTrack } from '../common/VideoPlayer.vue'
|
||||
|
||||
type RowMedia =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
// <video> has no native alt; used as the player's accessible label.
|
||||
alt?: string
|
||||
poster?: string
|
||||
tracks?: readonly VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
media: RowMedia
|
||||
}
|
||||
|
||||
const {
|
||||
heading,
|
||||
eyebrow,
|
||||
locale = 'en',
|
||||
rows
|
||||
} = defineProps<{
|
||||
heading: string
|
||||
eyebrow?: string
|
||||
locale?: Locale
|
||||
rows: readonly FeatureRow[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" max-width="xl">
|
||||
{{ heading }}
|
||||
</SectionHeader>
|
||||
|
||||
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
|
||||
<GlassCard
|
||||
v-for="(row, i) in rows"
|
||||
:key="row.id"
|
||||
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
|
||||
>
|
||||
<!-- Text -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
|
||||
{{ row.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-smoke-700 lg:text-base">
|
||||
{{ row.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media: image or video -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-1 flex lg:w-1/2',
|
||||
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="row.media.type === 'image'"
|
||||
:src="row.media.src"
|
||||
:alt="row.media.alt ?? row.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-4xl object-cover"
|
||||
/>
|
||||
<VideoPlayer
|
||||
v-else
|
||||
:locale="locale"
|
||||
:aria-label="row.media.alt ?? row.title"
|
||||
:src="row.media.src"
|
||||
:poster="row.media.poster"
|
||||
:tracks="row.media.tracks"
|
||||
:autoplay="row.media.autoplay"
|
||||
:loop="row.media.loop"
|
||||
:minimal="row.media.minimal"
|
||||
:hide-controls="row.media.hideControls"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
@@ -27,6 +29,7 @@ const {
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
titleHighlight,
|
||||
subtitle,
|
||||
features = [],
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
@@ -41,14 +44,17 @@ const {
|
||||
videoAutoplay = false,
|
||||
videoLoop = false,
|
||||
videoMinimal = false,
|
||||
videoHideControls = false
|
||||
videoHideControls = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
class?: HTMLAttributes['class']
|
||||
badgeText: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
titleHighlight?: string
|
||||
subtitle?: string
|
||||
features?: string[]
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
@@ -72,7 +78,8 @@ const {
|
||||
:class="
|
||||
cn(
|
||||
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -84,7 +91,7 @@ const {
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
>
|
||||
<template v-if="titleHighlight">
|
||||
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
|
||||
@@ -93,6 +100,13 @@ const {
|
||||
<template v-else>{{ title }}</template>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<ul v-if="features.length" class="mt-8 space-y-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
@@ -127,27 +141,29 @@ const {
|
||||
</div>
|
||||
|
||||
<div class="order-first w-full lg:order-last lg:flex-1">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
<slot name="media">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
59
apps/website/src/components/blocks/ReasonsSplit01.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
export interface Reason {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const { highlightClass = 'text-white' } = defineProps<{
|
||||
heading: string
|
||||
headingHighlight?: string
|
||||
highlightClass?: string
|
||||
subtitle?: string
|
||||
reasons: readonly Reason[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
|
||||
>
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
|
||||
>
|
||||
{{ heading
|
||||
}}<span v-if="headingHighlight" :class="highlightClass">{{
|
||||
headingHighlight
|
||||
}}</span>
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right reasons list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason.id"
|
||||
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
|
||||
>
|
||||
<div class="shrink-0 xl:w-84">
|
||||
<h3
|
||||
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ reason.title }}
|
||||
</h3>
|
||||
<slot name="reason-extra" :reason="reason" />
|
||||
</div>
|
||||
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
|
||||
{{ reason.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,12 +7,14 @@ const {
|
||||
label,
|
||||
headingTag = 'h2',
|
||||
maxWidth = 'lg',
|
||||
headingSize = 'section'
|
||||
headingSize = 'section',
|
||||
align = 'center'
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
headingTag?: 'h1' | 'h2' | 'h3'
|
||||
maxWidth?: 'md' | 'lg' | 'xl'
|
||||
headingSize?: 'section' | 'hero'
|
||||
align?: 'center' | 'start'
|
||||
}>()
|
||||
|
||||
const maxWidthClass = {
|
||||
@@ -28,7 +30,14 @@ const headingSizeClass = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
maxWidthClass[maxWidth],
|
||||
align === 'center' ? 'mx-auto text-center' : 'text-left'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SectionLabel v-if="label">{{ label }}</SectionLabel>
|
||||
<component
|
||||
:is="headingTag"
|
||||
|
||||
@@ -37,7 +37,8 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ label: t('nav.comfyLocal', locale), href: routes.download },
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{ label: t('nav.comfyApi', locale), href: routes.api },
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
const {
|
||||
value,
|
||||
copyLabel = 'Copy',
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
void copy(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,8 @@ const baseRoutes = {
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models'
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -65,6 +66,8 @@ export const externalLinks = {
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
|
||||
@@ -127,7 +127,7 @@ export const drops: readonly Drop[] = [
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
// href: '#',
|
||||
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -69,10 +69,19 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
header: t('nav.colFeatures', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.mcpServer', locale),
|
||||
href: routes.mcp,
|
||||
badge: 'new'
|
||||
},
|
||||
// TODO: no page yet — re-enable when landing pages ship
|
||||
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
|
||||
// { label: t('nav.appMode', locale), href: '#' },
|
||||
// { label: t('nav.agentSkills', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.launches', locale),
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
@@ -180,11 +189,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
},
|
||||
// TODO: no /brand page yet
|
||||
// { label: t('nav.brand', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.launches', locale),
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -11,6 +11,16 @@ const translations = {
|
||||
'zh-CN': '图像生成视频'
|
||||
},
|
||||
|
||||
// UI (global, reusable across sections)
|
||||
'ui.copy': {
|
||||
en: 'Copy',
|
||||
'zh-CN': '复制'
|
||||
},
|
||||
'ui.copied': {
|
||||
en: 'Copied',
|
||||
'zh-CN': '已复制'
|
||||
},
|
||||
|
||||
// CTAs (global, reusable across sections)
|
||||
'cta.tryWorkflow': {
|
||||
en: 'Try Workflow',
|
||||
@@ -1825,6 +1835,311 @@ const translations = {
|
||||
'我们尽力为经历面试流程的候选人提供有意义的反馈。由于申请量较大,在简历筛选阶段可能无法提供详细反馈。'
|
||||
},
|
||||
|
||||
// MCP – Meta
|
||||
'mcp.meta.title': {
|
||||
en: 'Comfy MCP — Drive ComfyUI from any AI agent',
|
||||
'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI'
|
||||
},
|
||||
'mcp.meta.description': {
|
||||
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
|
||||
'zh-CN':
|
||||
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎,可在 Claude Code、Claude Desktop 及任何兼容 MCP 的客户端中生成图像、视频、音频和 3D 内容。'
|
||||
},
|
||||
|
||||
// MCP – HeroSection
|
||||
'mcp.hero.heading': {
|
||||
en: 'Drive ComfyUI from\nany AI agent.',
|
||||
'zh-CN': '让任何 AI 智能体\n驱动 ComfyUI。'
|
||||
},
|
||||
'mcp.hero.subtitle': {
|
||||
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol — so your assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
|
||||
'zh-CN':
|
||||
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
|
||||
},
|
||||
'mcp.hero.demoPrompt': {
|
||||
en: "match this frame's palette, make the hero key art",
|
||||
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
|
||||
},
|
||||
'mcp.hero.viewDocs': {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
},
|
||||
'mcp.hero.demoGenerate': {
|
||||
en: 'GENERATE',
|
||||
'zh-CN': '生成'
|
||||
},
|
||||
'mcp.hero.demoActionGenerateImage': {
|
||||
en: 'GENERATE-IMAGE',
|
||||
'zh-CN': '生成图像'
|
||||
},
|
||||
'mcp.hero.demoActionGenerate3d': {
|
||||
en: 'GENERATE-3D ASSET',
|
||||
'zh-CN': '生成 3D 资产'
|
||||
},
|
||||
'mcp.hero.demoActionUpscale': {
|
||||
en: 'UPSCALE-IMAGE',
|
||||
'zh-CN': '放大图像'
|
||||
},
|
||||
|
||||
// MCP – SetupStepsSection
|
||||
'mcp.setup.label': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'mcp.setup.heading': {
|
||||
en: 'Set up Comfy MCP in three steps',
|
||||
'zh-CN': '三步完成 Comfy MCP 配置'
|
||||
},
|
||||
'mcp.setup.subtitle': {
|
||||
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
'zh-CN': 'COMFY CLOUD MCP 文档'
|
||||
},
|
||||
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
|
||||
'mcp.setup.step3.title': {
|
||||
en: 'Connect and sign in',
|
||||
'zh-CN': '连接并登录'
|
||||
},
|
||||
'mcp.setup.step3.description': {
|
||||
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
|
||||
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
|
||||
},
|
||||
'mcp.setup.step3.cta': {
|
||||
en: 'COMFY CLOUD SKILLS',
|
||||
'zh-CN': 'COMFY CLOUD 技能'
|
||||
},
|
||||
|
||||
// MCP – WhyBuildSection
|
||||
'mcp.why.heading': {
|
||||
en: 'Why build on\n',
|
||||
'zh-CN': '为什么选择\n'
|
||||
},
|
||||
'mcp.why.headingHighlight': {
|
||||
en: 'Comfy MCP?',
|
||||
'zh-CN': 'Comfy MCP?'
|
||||
},
|
||||
'mcp.why.subtitle': {
|
||||
en: 'A trusted infrastructure that lets engineers and professionals ship faster.',
|
||||
'zh-CN': '一套值得信赖的基础设施,让工程师和专业人士交付更快。'
|
||||
},
|
||||
'mcp.why.1.title': {
|
||||
en: 'Open protocol,\nany client.',
|
||||
'zh-CN': '开放协议,\n任意客户端。'
|
||||
},
|
||||
'mcp.why.1.description': {
|
||||
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
|
||||
'zh-CN':
|
||||
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop,更多客户端即将推出。'
|
||||
},
|
||||
'mcp.why.2.title': {
|
||||
en: 'The full engine,\nnot a sandbox.',
|
||||
'zh-CN': '完整引擎,\n非沙箱环境。'
|
||||
},
|
||||
'mcp.why.2.description': {
|
||||
en: 'Same tool your team uses. Fully connected multi-step, multi-GPU workflows. Everything available now and in the future.',
|
||||
'zh-CN':
|
||||
'与你团队使用的相同工具。完整连接的多步骤、多 GPU 工作流。当前及未来的所有功能均可使用。'
|
||||
},
|
||||
'mcp.why.3.title': {
|
||||
en: 'Outputs you keep.',
|
||||
'zh-CN': '输出归你所有。'
|
||||
},
|
||||
'mcp.why.3.description': {
|
||||
en: 'Downloads go to your Comfy library — store, reuse, remix, and share without leaving the ecosystem.',
|
||||
'zh-CN':
|
||||
'下载内容保存到你的 Comfy 库——在生态系统内存储、复用、二次创作和分享。'
|
||||
},
|
||||
'mcp.why.4.title': {
|
||||
en: 'Powered by\nComfy Cloud.',
|
||||
'zh-CN': '由 Comfy Cloud\n提供支持。'
|
||||
},
|
||||
'mcp.why.4.description': {
|
||||
en: 'Run without a local GPU through the same infrastructure your team already trusts.',
|
||||
'zh-CN': '无需本地 GPU,通过你团队信赖的相同基础设施运行。'
|
||||
},
|
||||
|
||||
// MCP – ToolsSection
|
||||
'mcp.tools.heading': {
|
||||
en: 'Everything ComfyUI can do,\nnow available as tools.',
|
||||
'zh-CN': 'ComfyUI 能做的一切,\n现在都可作为工具调用。'
|
||||
},
|
||||
'mcp.tools.1.title': {
|
||||
en: 'Generate anything',
|
||||
'zh-CN': '生成任意内容'
|
||||
},
|
||||
'mcp.tools.1.description': {
|
||||
en: 'Generate images, video, audio, 3D, upscale, or remove backgrounds. Add or remove elements in images, create or modify any visual, audio, or 3D asset at any scale.',
|
||||
'zh-CN':
|
||||
'生成图像、视频、音频、3D 内容,放大分辨率或移除背景。添加或删除图像元素,以任意规模创建或修改任何视觉、音频或 3D 资产。'
|
||||
},
|
||||
'mcp.tools.1.alt': {
|
||||
en: 'Comfy MCP generating images, video, audio, and 3D assets from a single prompt',
|
||||
'zh-CN': 'Comfy MCP 通过单个提示生成图像、视频、音频和 3D 资产'
|
||||
},
|
||||
'mcp.tools.2.title': {
|
||||
en: 'Search the ecosystem',
|
||||
'zh-CN': '搜索生态系统'
|
||||
},
|
||||
'mcp.tools.2.description': {
|
||||
en: 'Query thousands of models, browse rankings, and choose workflow templates straight from your response.',
|
||||
'zh-CN': '查询数千个模型,浏览排名,直接在对话中选择工作流模板。'
|
||||
},
|
||||
'mcp.tools.2.alt': {
|
||||
en: 'Comfy MCP searching the ecosystem of models, rankings, and workflow templates',
|
||||
'zh-CN': 'Comfy MCP 搜索模型、排名和工作流模板的生态系统'
|
||||
},
|
||||
'mcp.tools.3.title': {
|
||||
en: 'Run real workflows',
|
||||
'zh-CN': '运行真实工作流'
|
||||
},
|
||||
'mcp.tools.3.description': {
|
||||
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
|
||||
'zh-CN':
|
||||
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
|
||||
},
|
||||
'mcp.tools.3.alt': {
|
||||
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
|
||||
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
|
||||
},
|
||||
|
||||
// MCP – HowItWorksSection
|
||||
'mcp.howItWorks.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': '工作原理'
|
||||
},
|
||||
'mcp.howItWorks.step1.number': { en: '01', 'zh-CN': '01' },
|
||||
'mcp.howItWorks.step1.title': {
|
||||
en: 'CONNECT',
|
||||
'zh-CN': '连接'
|
||||
},
|
||||
'mcp.howItWorks.step1.description': {
|
||||
en: 'Add the Comfy Cloud MCP server to Claude Code or Claude Desktop and sign in once with OAuth. No API keys to manage.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud MCP 服务器添加到 Claude Code 或 Claude Desktop,通过 OAuth 一次性登录。无需管理 API 密钥。'
|
||||
},
|
||||
'mcp.howItWorks.step2.number': { en: '02', 'zh-CN': '02' },
|
||||
'mcp.howItWorks.step2.title': {
|
||||
en: 'DISCOVER',
|
||||
'zh-CN': '发现'
|
||||
},
|
||||
'mcp.howItWorks.step2.description': {
|
||||
en: "Your agent gets Comfy's tools: search, generate, submit, and retrieve — everything it needs to create.",
|
||||
'zh-CN':
|
||||
'你的智能体获得 Comfy 的工具:搜索、生成、提交和获取——一切所需,应有尽有。'
|
||||
},
|
||||
'mcp.howItWorks.step3.number': { en: '03', 'zh-CN': '03' },
|
||||
'mcp.howItWorks.step3.title': {
|
||||
en: 'CREATE',
|
||||
'zh-CN': '创作'
|
||||
},
|
||||
'mcp.howItWorks.step3.description': {
|
||||
en: 'Request what you want, the agent queues and runs the workflow, and returns the finished result.',
|
||||
'zh-CN': '描述你的需求,智能体排队执行工作流,并返回最终结果。'
|
||||
},
|
||||
|
||||
// MCP – FAQSection
|
||||
'mcp.faq.heading': {
|
||||
en: 'Q&As',
|
||||
'zh-CN': '常见问答'
|
||||
},
|
||||
'mcp.faq.1.q': {
|
||||
en: 'Which clients are supported?',
|
||||
'zh-CN': '支持哪些客户端?'
|
||||
},
|
||||
'mcp.faq.1.a': {
|
||||
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
|
||||
'zh-CN':
|
||||
'目前支持 Claude Code 和 Claude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。'
|
||||
},
|
||||
'mcp.faq.2.q': {
|
||||
en: 'Do I need an API key?',
|
||||
'zh-CN': '我需要 API 密钥吗?'
|
||||
},
|
||||
'mcp.faq.2.a': {
|
||||
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
|
||||
'zh-CN':
|
||||
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
|
||||
},
|
||||
'mcp.faq.3.q': {
|
||||
en: 'Do the slash commands work in Claude Desktop?',
|
||||
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
|
||||
},
|
||||
'mcp.faq.3.a': {
|
||||
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
|
||||
'zh-CN':
|
||||
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
|
||||
},
|
||||
'mcp.faq.4.q': {
|
||||
en: "The sign-in didn't open a browser.",
|
||||
'zh-CN': '登录时没有打开浏览器。'
|
||||
},
|
||||
'mcp.faq.4.a': {
|
||||
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
|
||||
'zh-CN':
|
||||
'在 Claude Code 中,运行 /mcp,选择 comfy-cloud,然后选择 Authenticate(授权)。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
|
||||
},
|
||||
'mcp.faq.5.q': {
|
||||
en: 'How do I connect in Claude Code?',
|
||||
'zh-CN': '如何在 Claude Code 中连接?'
|
||||
},
|
||||
'mcp.faq.5.a': {
|
||||
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
|
||||
'zh-CN':
|
||||
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate(授权)。一步即可添加连接和斜杠命令。'
|
||||
},
|
||||
'mcp.faq.6.q': {
|
||||
en: "What's the server URL for Claude Desktop?",
|
||||
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
|
||||
},
|
||||
'mcp.faq.6.a': {
|
||||
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
|
||||
},
|
||||
'mcp.faq.7.q': {
|
||||
en: 'What can my agent do once connected?',
|
||||
'zh-CN': '连接后我的智能体能做什么?'
|
||||
},
|
||||
'mcp.faq.7.a': {
|
||||
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
|
||||
'zh-CN':
|
||||
'生成图像、视频、音频和 3D;搜索模型、节点和模板;并运行 ComfyUI 工作流——全部在对话中完成。'
|
||||
},
|
||||
'mcp.faq.8.q': {
|
||||
en: 'Is it generally available?',
|
||||
'zh-CN': '现已正式发布了吗?'
|
||||
},
|
||||
'mcp.faq.8.a': {
|
||||
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
|
||||
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
|
||||
},
|
||||
|
||||
// SiteNav
|
||||
'nav.products': { en: 'Products', 'zh-CN': '产品' },
|
||||
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
|
||||
@@ -1867,6 +2182,7 @@ const translations = {
|
||||
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
|
||||
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
@@ -2597,18 +2913,18 @@ const translations = {
|
||||
'zh-CN': 'Plans; Fees; Free Tier.'
|
||||
},
|
||||
'tos.payment.block.1': {
|
||||
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
|
||||
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfy’s right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
|
||||
'zh-CN':
|
||||
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
|
||||
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfy’s right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
|
||||
},
|
||||
'tos.payment.block.2.heading': {
|
||||
en: 'Self-Serve Credit Card Billing.',
|
||||
'zh-CN': 'Self-Serve Credit Card Billing.'
|
||||
},
|
||||
'tos.payment.block.3': {
|
||||
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.',
|
||||
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.',
|
||||
'zh-CN':
|
||||
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.'
|
||||
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.'
|
||||
},
|
||||
'tos.payment.block.4.heading': {
|
||||
en: 'Invoiced Billing.',
|
||||
|
||||
24
apps/website/src/pages/mcp.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
|
||||
import HeroSection from '../templates/mcp/HeroSection.vue'
|
||||
import SetupSection from '../templates/mcp/SetupSection.vue'
|
||||
import WhySection from '../templates/mcp/WhySection.vue'
|
||||
import ToolsSection from '../templates/mcp/ToolsSection.vue'
|
||||
import HowItWorksSection from '../templates/mcp/HowItWorksSection.vue'
|
||||
import FAQSection from '../templates/mcp/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('mcp.meta.title', 'en')}
|
||||
description={t('mcp.meta.description', 'en')}
|
||||
>
|
||||
<HeroSection locale="en" client:load />
|
||||
<SetupSection locale="en" client:visible />
|
||||
<WhySection locale="en" />
|
||||
<ToolsSection locale="en" />
|
||||
<HowItWorksSection locale="en" />
|
||||
<ProductCardsSection locale="en" label-key="products.labelProducts" />
|
||||
<FAQSection client:visible locale="en" />
|
||||
</BaseLayout>
|
||||
24
apps/website/src/pages/zh-CN/mcp.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
|
||||
import HeroSection from '../../templates/mcp/HeroSection.vue'
|
||||
import SetupSection from '../../templates/mcp/SetupSection.vue'
|
||||
import WhySection from '../../templates/mcp/WhySection.vue'
|
||||
import ToolsSection from '../../templates/mcp/ToolsSection.vue'
|
||||
import HowItWorksSection from '../../templates/mcp/HowItWorksSection.vue'
|
||||
import FAQSection from '../../templates/mcp/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('mcp.meta.title', 'zh-CN')}
|
||||
description={t('mcp.meta.description', 'zh-CN')}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SetupSection locale="zh-CN" client:visible />
|
||||
<WhySection locale="zh-CN" />
|
||||
<ToolsSection locale="zh-CN" />
|
||||
<HowItWorksSection locale="zh-CN" />
|
||||
<ProductCardsSection locale="zh-CN" label-key="products.labelProducts" />
|
||||
<FAQSection client:visible locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -162,6 +162,45 @@
|
||||
animation: ripple-effect 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-cursor-blink {
|
||||
animation: cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.card-slide-enter-active {
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.card-slide-enter-from {
|
||||
transform: translateX(56px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Existing cards slide down smoothly when a new card is prepended. */
|
||||
.card-slide-move {
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.card-slide-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.card-slide-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@utility animate-delay-* {
|
||||
animation-delay: --value([*]);
|
||||
}
|
||||
|
||||
195
apps/website/src/templates/mcp/ComfyMcpDemo.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { Check } from '@lucide/vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const PROMPT = t('mcp.hero.demoPrompt', locale)
|
||||
const generateLabel = t('mcp.hero.demoGenerate', locale)
|
||||
|
||||
const cards = [
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'moodboard_v1.png · 6-up',
|
||||
tag: 'Gmail',
|
||||
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
|
||||
},
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'concepts_01–03.png',
|
||||
tag: 'Notion',
|
||||
thumb: '/images/mcp/mcp-thumb-concepts.webp'
|
||||
},
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'hero_keyart.png',
|
||||
tag: 'Figma',
|
||||
thumb: '/images/mcp/mcp-thumb-keyart.webp'
|
||||
},
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionGenerate3d',
|
||||
file: 'asphalt_pbr/ · 5 maps',
|
||||
tag: 'Blender',
|
||||
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
|
||||
},
|
||||
{
|
||||
actionKey: 'mcp.hero.demoActionUpscale',
|
||||
file: 'kaiju_neon_4k.png · 4096',
|
||||
tag: null,
|
||||
thumb: '/images/mcp/mcp-thumb-kaiju.webp'
|
||||
}
|
||||
] as const
|
||||
|
||||
const visibleCount = ref(0)
|
||||
const displayedPrompt = ref('')
|
||||
const promptDone = ref(false)
|
||||
|
||||
const displayedCards = computed(() =>
|
||||
cards
|
||||
.slice(0, visibleCount.value)
|
||||
.map((card) => ({ ...card, action: t(card.actionKey, locale) }))
|
||||
// Newest card first — it slides in right below the prompt box and pushes
|
||||
// the rest down.
|
||||
.reverse()
|
||||
)
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let active = false
|
||||
|
||||
function schedule(fn: () => void, ms: number) {
|
||||
timer = setTimeout(() => {
|
||||
if (active) fn()
|
||||
}, ms)
|
||||
}
|
||||
|
||||
function typePrompt(onDone: () => void) {
|
||||
displayedPrompt.value = ''
|
||||
promptDone.value = false
|
||||
let i = 0
|
||||
|
||||
function step() {
|
||||
i++
|
||||
displayedPrompt.value = PROMPT.slice(0, i)
|
||||
if (i < PROMPT.length) {
|
||||
schedule(step, 35)
|
||||
} else {
|
||||
promptDone.value = true
|
||||
schedule(onDone, 350)
|
||||
}
|
||||
}
|
||||
|
||||
schedule(step, 50)
|
||||
}
|
||||
|
||||
function revealNextCard() {
|
||||
if (visibleCount.value >= cards.length) {
|
||||
// All done — pause then reset
|
||||
schedule(() => {
|
||||
visibleCount.value = 0
|
||||
schedule(revealNextCard, 500)
|
||||
}, 2500)
|
||||
return
|
||||
}
|
||||
|
||||
// Type the prompt, then slide in the next card
|
||||
typePrompt(() => {
|
||||
visibleCount.value++
|
||||
schedule(revealNextCard, 400)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
active = true
|
||||
schedule(revealNextCard, 600)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
active = false
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 max-lg:h-[50vh]">
|
||||
<!-- Prompt panel -->
|
||||
<div
|
||||
class="rounded-5xl flex flex-col justify-between gap-8 overflow-hidden bg-white/4 p-8"
|
||||
>
|
||||
<p
|
||||
class="font-formula text-[17px] leading-relaxed font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ displayedPrompt
|
||||
}}<span
|
||||
class="bg-primary-comfy-yellow ml-0.5 inline-block h-5.5 w-2 translate-y-0.5"
|
||||
:class="promptDone ? 'animate-cursor-blink' : ''"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-white/10" />
|
||||
<div
|
||||
class="bg-primary-comfy-yellow font-formula rounded-2xl px-4 py-3 text-sm font-extrabold tracking-[0.7px] text-primary-comfy-ink uppercase"
|
||||
>
|
||||
{{ generateLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards accumulate — each slides in from the right after its prompt cycle -->
|
||||
<div class="relative overflow-hidden max-lg:min-h-0 max-lg:flex-1">
|
||||
<TransitionGroup
|
||||
name="card-slide"
|
||||
tag="div"
|
||||
class="flex flex-col gap-2.5 max-lg:absolute max-lg:inset-x-0 max-lg:top-0"
|
||||
>
|
||||
<div
|
||||
v-for="(card, i) in displayedCards"
|
||||
:key="card.file"
|
||||
class="flex items-center gap-3.5 overflow-hidden rounded-3xl px-4 py-3.5"
|
||||
:class="i === 0 ? 'bg-white/8' : 'bg-white/4'"
|
||||
>
|
||||
<img
|
||||
:src="card.thumb"
|
||||
:alt="card.action"
|
||||
class="size-13.5 shrink-0 rounded-[14px] object-cover"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<p
|
||||
class="font-formula text-primary-comfy-yellow text-xs font-extrabold tracking-[0.7px] uppercase"
|
||||
>
|
||||
{{ card.action }}
|
||||
</p>
|
||||
<p
|
||||
class="font-formula truncate text-sm font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ card.file }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="card.tag"
|
||||
class="font-formula relative isolate inline-flex h-8 shrink-0 items-center justify-center overflow-visible bg-transparent px-5 text-sm font-extrabold tracking-[0.7px] text-white/60 uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm before:bg-white/20"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ card.tag }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Check
|
||||
class="size-4 shrink-0 text-primary-comfy-canvas/60"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Bottom fade so accumulating cards dissolve into the page background -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-32 bg-linear-to-t from-primary-comfy-ink to-transparent lg:hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
19
apps/website/src/templates/mcp/FAQSection.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
|
||||
const faqs = faqNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
question: t(`mcp.faq.${n}.q`, locale),
|
||||
answer: t(`mcp.faq.${n}.a`, locale)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FAQSplit01 :heading="t('mcp.faq.heading', locale)" :faqs="faqs" />
|
||||
</template>
|
||||
27
apps/website/src/templates/mcp/HeroSection.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import ComfyMcpDemo from './ComfyMcpDemo.vue'
|
||||
import { mcpCtas } from './ctas'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const ctas = mcpCtas(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroSplit01
|
||||
:locale="locale"
|
||||
class="min-h-screen"
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:secondary-cta="ctas.docs"
|
||||
>
|
||||
<template #media>
|
||||
<ComfyMcpDemo :locale="locale" />
|
||||
</template>
|
||||
</HeroSplit01>
|
||||
</template>
|
||||
29
apps/website/src/templates/mcp/HowItWorksSection.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import FeatureGrid02 from '../../components/blocks/FeatureGrid02.vue'
|
||||
import type { FeatureStep } from '../../components/blocks/FeatureGrid02.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { mcpCtas } from './ctas'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const ctas = mcpCtas(locale)
|
||||
|
||||
const stepNumbers = [1, 2, 3] as const
|
||||
|
||||
const steps: FeatureStep[] = stepNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
number: t(`mcp.howItWorks.step${n}.number`, locale),
|
||||
title: t(`mcp.howItWorks.step${n}.title`, locale),
|
||||
description: t(`mcp.howItWorks.step${n}.description`, locale)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureGrid02
|
||||
:heading="t('mcp.howItWorks.heading', locale)"
|
||||
:steps="steps"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:secondary-cta="ctas.docs"
|
||||
/>
|
||||
</template>
|
||||
64
apps/website/src/templates/mcp/SetupSection.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
|
||||
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
|
||||
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const cards: FeatureCard[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
label: t('mcp.setup.step1.label', locale),
|
||||
title: t('mcp.setup.step1.title', locale),
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
label: t('mcp.setup.step2.label', locale),
|
||||
title: t('mcp.setup.step2.title', locale),
|
||||
description: t('mcp.setup.step2.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step2.cta', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
label: t('mcp.setup.step3.label', locale),
|
||||
title: t('mcp.setup.step3.title', locale),
|
||||
description: t('mcp.setup.step3.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step3.cta', locale),
|
||||
href: externalLinks.mcpSkills,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureGrid01
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
:columns="3"
|
||||
:cards="cards"
|
||||
:copy-label="t('ui.copy', locale)"
|
||||
:copied-label="t('ui.copied', locale)"
|
||||
/>
|
||||
</template>
|
||||
66
apps/website/src/templates/mcp/ToolsSection.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import FeatureRows01 from '../../components/blocks/FeatureRows01.vue'
|
||||
import type { FeatureRow } from '../../components/blocks/FeatureRows01.vue'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
type ToolMedia =
|
||||
| { type: 'image'; src: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
hideControls?: boolean
|
||||
}
|
||||
|
||||
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
|
||||
{
|
||||
n: 1,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
|
||||
},
|
||||
altKey: 'mcp.tools.1.alt'
|
||||
},
|
||||
{
|
||||
n: 2,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
|
||||
},
|
||||
altKey: 'mcp.tools.2.alt'
|
||||
},
|
||||
{
|
||||
n: 3,
|
||||
media: {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
hideControls: true
|
||||
},
|
||||
altKey: 'mcp.tools.3.alt'
|
||||
}
|
||||
]
|
||||
|
||||
const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
|
||||
const alt = altKey ? t(altKey, locale) : undefined
|
||||
return {
|
||||
id: String(n),
|
||||
title: t(`mcp.tools.${n}.title`, locale),
|
||||
description: t(`mcp.tools.${n}.description`, locale),
|
||||
media: { ...media, alt }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureRows01
|
||||
:locale="locale"
|
||||
:heading="t('mcp.tools.heading', locale)"
|
||||
:rows="rows"
|
||||
/>
|
||||
</template>
|
||||
26
apps/website/src/templates/mcp/WhySection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import ReasonsSplit01 from '../../components/blocks/ReasonsSplit01.vue'
|
||||
import type { Reason } from '../../components/blocks/ReasonsSplit01.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const reasonNumbers = [1, 2, 3, 4] as const
|
||||
|
||||
const reasons: Reason[] = reasonNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
title: t(`mcp.why.${n}.title`, locale),
|
||||
description: t(`mcp.why.${n}.description`, locale)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReasonsSplit01
|
||||
:heading="t('mcp.why.heading', locale)"
|
||||
:heading-highlight="t('mcp.why.headingHighlight', locale)"
|
||||
highlight-class="text-primary-comfy-yellow"
|
||||
:subtitle="t('mcp.why.subtitle', locale)"
|
||||
:reasons="reasons"
|
||||
/>
|
||||
</template>
|
||||
27
apps/website/src/templates/mcp/ctas.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export interface McpCta {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
}
|
||||
|
||||
/**
|
||||
* The two calls-to-action shared by the MCP hero and "how it works" sections:
|
||||
* view the docs, or run a workflow in the cloud.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
|
||||
return {
|
||||
docs: {
|
||||
label: t('mcp.hero.viewDocs', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
browser_tests/assets/video/video-preview-portrait.webm
Normal file
BIN
browser_tests/assets/video/video-preview-square.webm
Normal file
BIN
browser_tests/assets/video/video-preview-wide.webm
Normal file
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -6,72 +7,370 @@ import { VideoPreview } from '@e2e/fixtures/components/VideoPreview'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
const file1 = 'workflow.mp4' as const
|
||||
const file2 = 'workflow.webm' as const
|
||||
const file2 = 'video-preview-wide.webm' as const
|
||||
const file3 = 'video-preview-square.webm' as const
|
||||
const file4 = 'video-preview-portrait.webm' as const
|
||||
const MIN_PREVIEW_FRAME_HEIGHT = 100
|
||||
const CENTER_TOLERANCE_PX = 1
|
||||
const videoShapeFixtures = [
|
||||
[file2, 'landscape'],
|
||||
[file3, 'square'],
|
||||
[file4, 'portrait']
|
||||
] as const
|
||||
|
||||
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
|
||||
const loadVideo = new VideoPreview(loadVideoNode)
|
||||
type ThumbnailShape = (typeof videoShapeFixtures)[number][1]
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
interface VideoPreviewLayout {
|
||||
objectFit: string
|
||||
objectPosition: string
|
||||
wrapperHeight: number
|
||||
wrapperWidth: number
|
||||
wrapperX: number
|
||||
wrapperY: number
|
||||
videoBoxHeight: number
|
||||
videoBoxWidth: number
|
||||
videoIntrinsicHeight: number
|
||||
videoIntrinsicWidth: number
|
||||
videoX: number
|
||||
videoY: number
|
||||
}
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Video')
|
||||
await expect(loadVideoNode).toHaveCount(1)
|
||||
await expect(loadVideoNode).toBeVisible()
|
||||
async function readVideoPreviewLayout(
|
||||
preview: Locator
|
||||
): Promise<VideoPreviewLayout | null> {
|
||||
return await preview.evaluate((previewElement) => {
|
||||
const video = previewElement.querySelector('video')
|
||||
const wrapper = video?.parentElement
|
||||
if (!(video instanceof HTMLVideoElement) || !wrapper) return null
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const videoRect = video.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
objectFit: getComputedStyle(video).objectFit,
|
||||
objectPosition: getComputedStyle(video).objectPosition,
|
||||
wrapperHeight: wrapperRect.height,
|
||||
wrapperWidth: wrapperRect.width,
|
||||
wrapperX: wrapperRect.x,
|
||||
wrapperY: wrapperRect.y,
|
||||
videoBoxHeight: videoRect.height,
|
||||
videoBoxWidth: videoRect.width,
|
||||
videoIntrinsicHeight: video.videoHeight,
|
||||
videoIntrinsicWidth: video.videoWidth,
|
||||
videoX: videoRect.x,
|
||||
videoY: videoRect.y
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await test.step('Upload a video file', async () => {
|
||||
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file1}`))
|
||||
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file1)
|
||||
await expect(loadVideo.video).toBeVisible()
|
||||
})
|
||||
async function requireBoundingBox(locator: Locator, subject: string) {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error(`${subject} should have a bounding box`)
|
||||
|
||||
await test.step('Update displayed video', async () => {
|
||||
const initialSrc = await loadVideo.videoSrc()
|
||||
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file2}`))
|
||||
comfyFiles.deleteAfterTest({ filename: file2, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file2)
|
||||
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
await test.step('Display multiple videmus', async () => {
|
||||
await expect(loadVideo.navigationDots).toBeHidden()
|
||||
async function expectNodeBoxUnchanged(
|
||||
locator: Locator,
|
||||
before: { height: number; width: number },
|
||||
subject: string
|
||||
) {
|
||||
const after = await requireBoundingBox(locator, subject)
|
||||
expect(
|
||||
Math.abs(after.width - before.width),
|
||||
`${subject} should not change node width`
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(
|
||||
Math.abs(after.height - before.height),
|
||||
`${subject} should not change node height`
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
}
|
||||
|
||||
//forcibly display multiple video files at once
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.evaluate(
|
||||
(names) => {
|
||||
graph!.nodes[0].images.splice(
|
||||
0,
|
||||
1,
|
||||
...names.map((filename) => ({
|
||||
type: 'input',
|
||||
filename,
|
||||
subfolder: ''
|
||||
}))
|
||||
function objectPositionFraction(value: string) {
|
||||
if (value.endsWith('%')) return Number.parseFloat(value) / 100
|
||||
|
||||
switch (value) {
|
||||
case 'left':
|
||||
case 'top':
|
||||
return 0
|
||||
case 'center':
|
||||
return 0.5
|
||||
case 'right':
|
||||
case 'bottom':
|
||||
return 1
|
||||
default:
|
||||
throw new Error(`Unsupported object-position value: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function objectPositionFractions(objectPosition: string) {
|
||||
const [x = '50%', y = '50%'] = objectPosition.split(/\s+/)
|
||||
|
||||
return {
|
||||
x: objectPositionFraction(x),
|
||||
y: objectPositionFraction(y)
|
||||
}
|
||||
}
|
||||
|
||||
function getPaintedVideoRect({
|
||||
objectPosition,
|
||||
videoBoxHeight,
|
||||
videoBoxWidth,
|
||||
videoIntrinsicHeight,
|
||||
videoIntrinsicWidth,
|
||||
videoX,
|
||||
videoY
|
||||
}: VideoPreviewLayout) {
|
||||
const videoAspectRatio = videoIntrinsicWidth / videoIntrinsicHeight
|
||||
const boxAspectRatio = videoBoxWidth / videoBoxHeight
|
||||
const paintedWidth =
|
||||
videoAspectRatio > boxAspectRatio
|
||||
? videoBoxWidth
|
||||
: videoBoxHeight * videoAspectRatio
|
||||
const paintedHeight =
|
||||
videoAspectRatio > boxAspectRatio
|
||||
? videoBoxWidth / videoAspectRatio
|
||||
: videoBoxHeight
|
||||
const position = objectPositionFractions(objectPosition)
|
||||
|
||||
return {
|
||||
height: paintedHeight,
|
||||
width: paintedWidth,
|
||||
x: videoX + (videoBoxWidth - paintedWidth) * position.x,
|
||||
y: videoY + (videoBoxHeight - paintedHeight) * position.y
|
||||
}
|
||||
}
|
||||
|
||||
function expectAspectRatioMatchesShape(
|
||||
aspectRatio: number,
|
||||
shape: ThumbnailShape
|
||||
) {
|
||||
if (shape === 'landscape') {
|
||||
expect(
|
||||
aspectRatio,
|
||||
'landscape fixture should be wider than tall'
|
||||
).toBeGreaterThan(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (shape === 'portrait') {
|
||||
expect(
|
||||
aspectRatio,
|
||||
'portrait fixture should be taller than wide'
|
||||
).toBeLessThan(1)
|
||||
return
|
||||
}
|
||||
|
||||
expect(
|
||||
Math.abs(aspectRatio - 1),
|
||||
'square fixture should have matching width and height'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX / 100)
|
||||
}
|
||||
|
||||
async function expectCenteredVideoPreview(preview: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const layout = await readVideoPreviewLayout(preview)
|
||||
return layout?.videoIntrinsicWidth ?? 0
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const layout = await readVideoPreviewLayout(preview)
|
||||
if (!layout) throw new Error('Video preview should render a video element')
|
||||
|
||||
expect(
|
||||
layout.wrapperHeight,
|
||||
'video preview should keep a usable minimum frame height'
|
||||
).toBeGreaterThanOrEqual(MIN_PREVIEW_FRAME_HEIGHT - CENTER_TOLERANCE_PX)
|
||||
expect(layout.videoBoxWidth).toBeGreaterThan(0)
|
||||
expect(layout.videoBoxHeight).toBeGreaterThan(0)
|
||||
expect(layout.objectFit).toBe('contain')
|
||||
|
||||
const objectPosition = objectPositionFractions(layout.objectPosition)
|
||||
expect(objectPosition.x).toBe(0.5)
|
||||
expect(objectPosition.y).toBe(0.5)
|
||||
|
||||
const wrapperCenterX = layout.wrapperX + layout.wrapperWidth / 2
|
||||
const wrapperCenterY = layout.wrapperY + layout.wrapperHeight / 2
|
||||
const paintedVideo = getPaintedVideoRect(layout)
|
||||
const paintedVideoCenterX = paintedVideo.x + paintedVideo.width / 2
|
||||
const paintedVideoCenterY = paintedVideo.y + paintedVideo.height / 2
|
||||
|
||||
expect(
|
||||
Math.abs(paintedVideoCenterX - wrapperCenterX),
|
||||
'painted video should be horizontally centered in the preview space'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(
|
||||
Math.abs(paintedVideoCenterY - wrapperCenterY),
|
||||
'painted video should be vertically centered in the preview space'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(layout.videoBoxWidth).toBeLessThanOrEqual(
|
||||
layout.wrapperWidth + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(layout.videoBoxHeight).toBeLessThanOrEqual(
|
||||
layout.wrapperHeight + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(paintedVideo.width).toBeLessThanOrEqual(
|
||||
layout.wrapperWidth + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(paintedVideo.height).toBeLessThanOrEqual(
|
||||
layout.wrapperHeight + CENTER_TOLERANCE_PX
|
||||
)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'VideoPreview',
|
||||
{ tag: ['@vue-nodes', '@node', '@widget'] },
|
||||
() => {
|
||||
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
|
||||
const loadVideo = new VideoPreview(loadVideoNode)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Video')
|
||||
await expect(loadVideoNode).toHaveCount(1)
|
||||
await expect(loadVideoNode).toBeVisible()
|
||||
})
|
||||
|
||||
const loadVideoFixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Video')
|
||||
|
||||
await test.step('Upload a video file', async () => {
|
||||
await loadVideo.upload.setInputFiles(
|
||||
assetPath(`workflowInMedia/${file1}`)
|
||||
)
|
||||
},
|
||||
[file1, file2]
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file1)
|
||||
await expect(loadVideo.video).toBeVisible()
|
||||
|
||||
await expect(loadVideo.navigationDots).toHaveCount(2)
|
||||
await loadVideo.navigationDots.nth(0).click()
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
|
||||
await loadVideo.navigationDots.nth(1).click()
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
|
||||
})
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(layout.videoIntrinsicWidth).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
await test.step('Can redownload uploaded file', async () => {
|
||||
await loadVideo.video.hover()
|
||||
await expect(loadVideo.download).toBeVisible()
|
||||
await test.step('Update displayed video across thumbnail shapes', async () => {
|
||||
for (const [filename, shape] of videoShapeFixtures) {
|
||||
const initialSrc = await loadVideo.videoSrc()
|
||||
const nodeBoxBeforeLoad = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
`Load Video node before loading ${filename}`
|
||||
)
|
||||
await loadVideo.upload.setInputFiles(assetPath(`video/${filename}`))
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename,
|
||||
type: 'input'
|
||||
})
|
||||
await expect(loadVideoNode).toContainText(filename)
|
||||
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await loadVideo.download.click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe(file2)
|
||||
})
|
||||
})
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
await expectNodeBoxUnchanged(
|
||||
loadVideoNode,
|
||||
nodeBoxBeforeLoad,
|
||||
`Load Video node after loading ${filename}`
|
||||
)
|
||||
const updatedVideoAspectRatio =
|
||||
layout.videoIntrinsicWidth / layout.videoIntrinsicHeight
|
||||
|
||||
expectAspectRatioMatchesShape(updatedVideoAspectRatio, shape)
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Keep video centered after horizontal resize', async () => {
|
||||
const nodeBox = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
'Load Video node before horizontal resize'
|
||||
)
|
||||
const initialLayout = await expectCenteredVideoPreview(
|
||||
loadVideo.preview
|
||||
)
|
||||
|
||||
await loadVideoFixture.resizeFromCorner('SE', 180, 0)
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(loadVideoFixture.pollWidth)
|
||||
.toBeGreaterThan(nodeBox.width + 100)
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(
|
||||
layout.wrapperWidth - initialLayout.wrapperWidth,
|
||||
'video preview space should grow with a wider node'
|
||||
).toBeGreaterThan(100)
|
||||
expect(
|
||||
Math.abs(layout.wrapperHeight - initialLayout.wrapperHeight),
|
||||
'horizontal resize should not change the preview space height'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
})
|
||||
|
||||
await test.step('Keep video centered after vertical resize', async () => {
|
||||
const nodeBox = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
'Load Video node before vertical resize'
|
||||
)
|
||||
const initialLayout = await expectCenteredVideoPreview(
|
||||
loadVideo.preview
|
||||
)
|
||||
|
||||
await loadVideoFixture.resizeFromCorner('SE', 0, 180)
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(loadVideoFixture.pollHeight)
|
||||
.toBeGreaterThan(nodeBox.height + 100)
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(
|
||||
layout.wrapperHeight - initialLayout.wrapperHeight,
|
||||
'video preview space should grow with a taller node'
|
||||
).toBeGreaterThan(100)
|
||||
expect(
|
||||
Math.abs(layout.wrapperWidth - initialLayout.wrapperWidth),
|
||||
'vertical resize should not change the preview space width'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
})
|
||||
|
||||
await test.step('Display multiple videos', async () => {
|
||||
await expect(loadVideo.navigationDots).toBeHidden()
|
||||
|
||||
try {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.evaluate(
|
||||
(names) => {
|
||||
graph!.nodes[0].images.splice(
|
||||
0,
|
||||
1,
|
||||
...names.map((filename) => ({
|
||||
type: 'input',
|
||||
filename,
|
||||
subfolder: ''
|
||||
}))
|
||||
)
|
||||
},
|
||||
[file1, file2]
|
||||
)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await expect(loadVideo.navigationDots).toHaveCount(2)
|
||||
await loadVideo.navigationDots.nth(0).press('Enter')
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
|
||||
await loadVideo.navigationDots.nth(1).press('Enter')
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
|
||||
})
|
||||
|
||||
await test.step('Can redownload uploaded file', async () => {
|
||||
await loadVideo.video.hover()
|
||||
await expect(loadVideo.download).toBeVisible()
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await loadVideo.download.click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe(file2)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
@@ -70,8 +70,6 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<!-- Items declaring an icon key (even empty) keep the slot so labels align
|
||||
within icon-bearing menus; icon-less menus render labels flush-left. -->
|
||||
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
|
||||
@@ -24,7 +24,7 @@ function toggleCategory(category: string) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenu button-class="icon-[lucide--list-filter]">
|
||||
<DropdownMenu>
|
||||
<template #button>
|
||||
<Button size="icon" :aria-label="$t('g.filter')">
|
||||
<i class="icon-[lucide--list-filter]" />
|
||||
@@ -52,7 +52,7 @@ function toggleCategory(category: string) {
|
||||
>
|
||||
<span
|
||||
class="flex-1"
|
||||
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
|
||||
v-text="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
|
||||
/>
|
||||
<DropdownMenuItemIndicator class="size-4 shrink-0">
|
||||
<i class="icon-[lucide--check]" />
|
||||
|
||||
@@ -51,7 +51,8 @@ const mockHandleNativeDrop = vi.fn()
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
// startDrag/cancelDrag toggle the litegraph ghost-placement flag; stub the
|
||||
// store so the composable runs without an active Pinia in this component test.
|
||||
// A single shared instance lets startDrag and the component observe the same flag.
|
||||
const { mockCanvasStore } = vi.hoisted(() => ({
|
||||
mockCanvasStore: { isGhostPlacing: false, canvas: undefined }
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore)
|
||||
}))
|
||||
|
||||
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
|
||||
|
||||
function moveMouse(clientX: number, clientY: number) {
|
||||
@@ -28,6 +39,7 @@ function ghostElement() {
|
||||
describe('NodeDragPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockCanvasStore.isGhostPlacing = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mapValues } from 'es-toolkit'
|
||||
import { useEventListener, useLocalStorage } from '@vueuse/core'
|
||||
import { mapValues } from 'es-toolkit'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
|
||||
import {
|
||||
|
||||
@@ -16,15 +16,19 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const { mockStartDrag, mockHandleNativeDrop } = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockHandleNativeDrop: vi.fn()
|
||||
}))
|
||||
const { mockStartDrag, mockHandleNativeDrop, mockIsPlacingNode } = vi.hoisted(
|
||||
() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockHandleNativeDrop: vi.fn(),
|
||||
mockIsPlacingNode: { value: false }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: mockIsPlacingNode
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
|
||||
@@ -7,30 +8,33 @@ const {
|
||||
mockAddNodeOnGraph,
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas,
|
||||
mockToastAdd
|
||||
mockCanvasStore,
|
||||
mockToastAdd,
|
||||
canvasElement
|
||||
} = vi.hoisted(() => {
|
||||
const canvasElement = document.createElement(
|
||||
'canvas'
|
||||
) as HTMLCanvasElement & { getBoundingClientRect: Mock }
|
||||
canvasElement.getBoundingClientRect = vi.fn()
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
const mockSelectItems = vi.fn()
|
||||
const mockCanvas = {
|
||||
canvas: canvasElement,
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
|
||||
selectItems: mockSelectItems
|
||||
}
|
||||
return {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockToastAdd: vi.fn(),
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
},
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
|
||||
selectItems: mockSelectItems
|
||||
}
|
||||
canvasElement,
|
||||
mockCanvasStore: { canvas: mockCanvas, isGhostPlacing: false }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
canvas: mockCanvas
|
||||
}))
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
@@ -45,8 +49,11 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
const CANVAS_RECT = { left: 0, right: 500, top: 0, bottom: 500 }
|
||||
|
||||
describe('useNodeDragToCanvas', () => {
|
||||
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
|
||||
let panelElement: HTMLElement
|
||||
|
||||
const mockNodeDef = {
|
||||
name: 'TestNode',
|
||||
@@ -57,6 +64,11 @@ describe('useNodeDragToCanvas', () => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
|
||||
document.body.appendChild(canvasElement)
|
||||
panelElement = document.createElement('div')
|
||||
document.body.appendChild(panelElement)
|
||||
mockCanvasStore.isGhostPlacing = false
|
||||
|
||||
const module = await import('./useNodeDragToCanvas')
|
||||
useNodeDragToCanvas = module.useNodeDragToCanvas
|
||||
})
|
||||
@@ -64,9 +76,24 @@ describe('useNodeDragToCanvas', () => {
|
||||
afterEach(() => {
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
cancelDrag()
|
||||
canvasElement.remove()
|
||||
panelElement.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// The canvas is full-bleed under the sidebar/properties panels, so the click
|
||||
// path commits based on the event target rather than geometry. Dispatch on the
|
||||
// real element so `isCanvasTarget` (canvas.contains(target)) behaves as in the app.
|
||||
function dispatchPointerUp(
|
||||
x: number,
|
||||
y: number,
|
||||
target: EventTarget = canvasElement
|
||||
) {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent('pointerup', { clientX: x, clientY: y, bubbles: true })
|
||||
)
|
||||
}
|
||||
|
||||
describe('startDrag', () => {
|
||||
it('should set isDragging to true and store the node definition', () => {
|
||||
const { isDragging, draggedNode, startDrag } = useNodeDragToCanvas()
|
||||
@@ -96,6 +123,37 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ghost placement flag', () => {
|
||||
it('should mark ghost placement active for the duration of the drag', () => {
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(true)
|
||||
|
||||
cancelDrag()
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(false)
|
||||
})
|
||||
|
||||
it('should not clear ghost placement when cancelling without a drag', () => {
|
||||
mockCanvasStore.isGhostPlacing = true
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
cancelDrag()
|
||||
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(true)
|
||||
})
|
||||
|
||||
it('should leave node interaction intact during a native drag', () => {
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(false)
|
||||
|
||||
cancelDrag()
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag listener lifecycle', () => {
|
||||
it('should attach document listeners on startDrag', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
@@ -167,47 +225,42 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
describe('endDrag behavior', () => {
|
||||
it('should add node when pointer is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
it('should add node when released over the canvas', () => {
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
pos: [150, 150]
|
||||
})
|
||||
})
|
||||
|
||||
it('should not add node when pointer is outside canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
it('should not add node when released outside the canvas', () => {
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 600,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
dispatchPointerUp(600, 250, panelElement)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not add node when released over a panel within canvas bounds', () => {
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
// FE-688: the panel overlays the full-bleed canvas, so a release at a
|
||||
// point inside the canvas rect but on the panel must not place a hidden
|
||||
// node behind the panel.
|
||||
dispatchPointerUp(250, 250, panelElement)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
@@ -236,12 +289,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should select the placed node when one is returned from the graph', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1 }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
@@ -249,24 +297,13 @@ describe('useNodeDragToCanvas', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
})
|
||||
|
||||
it('should apply the requested widget values to the placed node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const widget = { name: 'ckpt_name', value: '' }
|
||||
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
|
||||
@@ -276,24 +313,13 @@ describe('useNodeDragToCanvas', () => {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(widget.value).toBe('model.safetensors')
|
||||
})
|
||||
|
||||
it('should warn but still place the node when a requested widget is missing', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1, widgets: [] }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
@@ -306,13 +332,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
@@ -327,12 +347,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should show an error toast when the graph fails to add the node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -340,13 +355,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -357,12 +366,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not call selectItems when graph returns no node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -370,35 +374,19 @@ describe('useNodeDragToCanvas', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockSelectItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not add node on pointerup when in native drag mode', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
dispatchPointerUp(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(true)
|
||||
@@ -407,12 +395,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
describe('handleNativeDrop', () => {
|
||||
it('should add node when drop position is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
@@ -426,12 +409,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not add node when drop position is outside canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
@@ -443,12 +421,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not add node when dragMode is click', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
@@ -460,12 +433,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should reset drag state after drop', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
@@ -478,7 +446,11 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
describe('blockCommitPointerDown', () => {
|
||||
function dispatchPointerDown(x: number, y: number) {
|
||||
function dispatchPointerDown(
|
||||
x: number,
|
||||
y: number,
|
||||
target: EventTarget = canvasElement
|
||||
) {
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
@@ -486,17 +458,12 @@ describe('useNodeDragToCanvas', () => {
|
||||
cancelable: true
|
||||
})
|
||||
const stopSpy = vi.spyOn(event, 'stopImmediatePropagation')
|
||||
document.dispatchEvent(event)
|
||||
target.dispatchEvent(event)
|
||||
return stopSpy
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
})
|
||||
|
||||
it('should stop propagation when in click-drag mode over canvas', () => {
|
||||
@@ -521,22 +488,17 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when pointer is outside canvas', () => {
|
||||
it('should not stop propagation when pointer is over a panel', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
expect(dispatchPointerDown(250, 250, panelElement)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('native drag position tracking', () => {
|
||||
beforeEach(() => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([300, 300])
|
||||
})
|
||||
|
||||
|
||||
@@ -66,6 +66,17 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// The canvas is full-bleed and sidebar/properties panels are pointer-events-auto
|
||||
// overlays painted on top of it, so a point inside the canvas rect can still be
|
||||
// over a panel. Hit-test the actual event target instead, mirroring how native
|
||||
// drag treats the canvas as its only drop target: releasing over a panel cancels.
|
||||
function isCanvasTarget(target: EventTarget | null): boolean {
|
||||
const canvasElement = useCanvasStore().canvas?.canvas
|
||||
return (
|
||||
!!canvasElement && target instanceof Node && canvasElement.contains(target)
|
||||
)
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
const nodeDef = draggedNode.value
|
||||
if (!nodeDef) return false
|
||||
@@ -101,7 +112,7 @@ function endDrag(e: PointerEvent) {
|
||||
if (dragMode.value !== 'click') return
|
||||
|
||||
try {
|
||||
addNodeAtPosition(e.clientX, e.clientY)
|
||||
if (isCanvasTarget(e.target)) addNodeAtPosition(e.clientX, e.clientY)
|
||||
} finally {
|
||||
cancelDrag()
|
||||
}
|
||||
@@ -114,7 +125,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
// Prevent LiteGraph's empty-canvas hit-test from deselecting the placed node on pointerup.
|
||||
function blockCommitPointerDown(e: PointerEvent) {
|
||||
if (!isDragging.value || dragMode.value !== 'click') return
|
||||
if (!isOverCanvas(e.clientX, e.clientY)) return
|
||||
if (!isCanvasTarget(e.target)) return
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
@@ -139,6 +150,13 @@ function cleanupGlobalListeners() {
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
// isGhostPlacing is shared with litegraph's own ghost-placement event. We
|
||||
// both set and clear it only for a click drag, assuming a litegraph ghost and
|
||||
// a library click-drag never overlap. If that ever changes, a litegraph reset
|
||||
// could flip the flag off mid-drag, re-enable node hit-testing, and reintroduce
|
||||
// FE-688 — a dedicated flag OR'd into the inert computed would be needed then.
|
||||
if (isDragging.value && dragMode.value === 'click')
|
||||
useCanvasStore().isGhostPlacing = false
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
@@ -162,6 +180,13 @@ export function useNodeDragToCanvas() {
|
||||
dragMode.value = mode
|
||||
pendingWidgetValues.value = widgetValues
|
||||
pendingSource.value = source
|
||||
// Reuse the litegraph ghost-placement flag: Vue nodes render inert while
|
||||
// it is set, so the release hit-tests the canvas instead of an existing
|
||||
// node's DOM and placement over occupied areas isn't silently cancelled.
|
||||
// Only the click path relies on DOM hit-testing; the native path commits
|
||||
// via geometric coords, so leave node interaction (its @dragover/@drop)
|
||||
// intact during a native drag.
|
||||
if (mode === 'click') useCanvasStore().isGhostPlacing = true
|
||||
setupGlobalListeners()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import { useNodePreviewAndDrag } from './useNodePreviewAndDrag'
|
||||
|
||||
const mockStartDrag = vi.fn()
|
||||
const mockHandleNativeDrop = vi.fn()
|
||||
const mockIsPlacingNode = ref(false)
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: mockIsPlacingNode
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -29,6 +31,7 @@ describe('useNodePreviewAndDrag', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsPlacingNode.value = false
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
@@ -51,6 +54,17 @@ describe('useNodePreviewAndDrag', () => {
|
||||
result.isDragging.value = true
|
||||
expect(result.showPreview.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should hide preview while a node is being placed elsewhere', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef, ref(null))
|
||||
|
||||
result.isHovered.value = true
|
||||
expect(result.showPreview.value).toBe(true)
|
||||
|
||||
mockIsPlacingNode.value = true
|
||||
expect(result.showPreview.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleMouseEnter', () => {
|
||||
|
||||
@@ -25,7 +25,11 @@ export function useNodePreviewAndDrag(
|
||||
previewRef: Ref<HTMLElement | null>,
|
||||
panelRef?: MaybeRefOrGetter<HTMLElement | null>
|
||||
): UseNodePreviewAndDragReturn {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const {
|
||||
startDrag,
|
||||
handleNativeDrop,
|
||||
isDragging: isPlacingNode
|
||||
} = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -33,7 +37,11 @@ export function useNodePreviewAndDrag(
|
||||
|
||||
const isHovered = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const showPreview = computed(() => isHovered.value && !isDragging.value)
|
||||
// Hide the hover preview while a node is being placed (click or drag) so it
|
||||
// doesn't compete with the cursor-following placement preview.
|
||||
const showPreview = computed(
|
||||
() => isHovered.value && !isDragging.value && !isPlacingNode.value
|
||||
)
|
||||
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
|
||||
@@ -12,12 +12,11 @@ export function resolveEssentialTileNodeDef(
|
||||
): ComfyNodeDefImpl | undefined {
|
||||
const name = tile.nodeName
|
||||
if (!name) return undefined
|
||||
const byName = nodeDefStore.allNodeDefsByName[name]
|
||||
if (byName) return byName
|
||||
const target = name.startsWith(BLUEPRINT_TYPE_PREFIX)
|
||||
? name.slice(BLUEPRINT_TYPE_PREFIX.length)
|
||||
: name
|
||||
return nodeDefStore.nodeDefs.find((d) => d.display_name === target)
|
||||
if (!name.startsWith(BLUEPRINT_TYPE_PREFIX))
|
||||
return nodeDefStore.allNodeDefsByName[name]
|
||||
|
||||
const subgraphName = name.slice(BLUEPRINT_TYPE_PREFIX.length)
|
||||
return nodeDefStore.allNodeDefsByDisplayName[subgraphName]
|
||||
}
|
||||
|
||||
export function useEssentialTileNodeDef(tile: MaybeRefOrGetter<EssentialTile>) {
|
||||
|
||||
101
src/config/comfyApi.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (route: string) => `/api${route}`,
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
|
||||
describe('getComfyApiBaseUrl', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors the server-provided override', () => {
|
||||
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
|
||||
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the key is absent', () => {
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the value is empty', () => {
|
||||
remoteConfig.value = { comfy_api_base_url: '' }
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComfyPlatformBaseUrl', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors the server-provided override', () => {
|
||||
remoteConfig.value = {
|
||||
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
|
||||
}
|
||||
expect(getComfyPlatformBaseUrl()).toBe(
|
||||
'https://my-ephem-platform.example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the key is absent', () => {
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the value is empty', () => {
|
||||
remoteConfig.value = { comfy_platform_base_url: '' }
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compatibility with comfyui servers that predate the override keys', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('falls back to build-time defaults when /features omits the URL keys', async () => {
|
||||
// An older comfyui server has /features but doesn't know about
|
||||
// comfy_api_base_url / comfy_platform_base_url yet.
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
supports_preview_metadata: true,
|
||||
max_upload_size: 104857600
|
||||
})
|
||||
} as Response)
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
@@ -20,10 +19,6 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
STAGING_PLATFORM_BASE_URL)
|
||||
|
||||
export function getComfyApiBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_API_BASE_URL
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_api_base_url',
|
||||
@@ -32,10 +27,6 @@ export function getComfyApiBaseUrl(): string {
|
||||
}
|
||||
|
||||
export function getComfyPlatformBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_PLATFORM_BASE_URL
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_platform_base_url',
|
||||
|
||||
45
src/config/firebase.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function loadFirebase(useProdConfig: boolean) {
|
||||
vi.resetModules()
|
||||
vi.stubGlobal('__USE_PROD_CONFIG__', useProdConfig)
|
||||
const { remoteConfig } = await import('@/platform/remoteConfig/remoteConfig')
|
||||
const { getFirebaseConfig } = await import('./firebase')
|
||||
return { getFirebaseConfig, remoteConfig }
|
||||
}
|
||||
|
||||
describe('getFirebaseConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('honors a full server-provided firebase_config (cloud builds)', async () => {
|
||||
const cloud = {
|
||||
apiKey: 'cloud-key',
|
||||
authDomain: 'cloud.example.com',
|
||||
projectId: 'some-cloud-project',
|
||||
storageBucket: 'cloud.appspot.com',
|
||||
messagingSenderId: '1',
|
||||
appId: '1:1:web:abc'
|
||||
}
|
||||
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
|
||||
remoteConfig.value = { firebase_config: cloud }
|
||||
expect(getFirebaseConfig()).toEqual(cloud)
|
||||
})
|
||||
|
||||
it('uses the dev project when the server reports firebase_env "dev", even if the build-time fallback is prod', async () => {
|
||||
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
|
||||
remoteConfig.value = { firebase_env: 'dev' }
|
||||
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
})
|
||||
|
||||
it('falls back to the build-time config when the server reports no firebase_env', async () => {
|
||||
const prod = await loadFirebase(true)
|
||||
prod.remoteConfig.value = {}
|
||||
expect(prod.getFirebaseConfig().projectId).toBe('dreamboothy')
|
||||
|
||||
const dev = await loadFirebase(false)
|
||||
dev.remoteConfig.value = {}
|
||||
expect(dev.getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FirebaseOptions } from 'firebase/app'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
const DEV_CONFIG: FirebaseOptions = {
|
||||
@@ -28,15 +27,12 @@ const PROD_CONFIG: FirebaseOptions = {
|
||||
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
|
||||
|
||||
/**
|
||||
* Returns the Firebase configuration for the current environment.
|
||||
* - Cloud builds use runtime configuration delivered via feature flags
|
||||
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
|
||||
* Firebase config for the current backend: the server's firebase_config (cloud builds),
|
||||
* else the bundled DEV_CONFIG when the server reports a dev-tier backend, else the build-time default.
|
||||
*/
|
||||
export function getFirebaseConfig(): FirebaseOptions {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
const runtimeConfig = remoteConfig.value.firebase_config
|
||||
return runtimeConfig ?? BUILD_TIME_CONFIG
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
if (remoteConfig.value.firebase_env === 'dev') return DEV_CONFIG
|
||||
return BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
@@ -815,8 +815,10 @@ export class GroupNodeConfig {
|
||||
* `configure`. The load-time migration unpacks each instance via
|
||||
* {@link convertToNodes} and {@link LGraph.convertToSubgraph} repackages the
|
||||
* result as a subgraph.
|
||||
*
|
||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||
*/
|
||||
class GroupNodeHandler {
|
||||
export class GroupNodeHandler {
|
||||
node: LGraphNode
|
||||
groupData: GroupNodeConfig
|
||||
|
||||
|
||||
11
src/main.ts
@@ -32,13 +32,12 @@ import { i18n } from './i18n'
|
||||
|
||||
const isCloud = __DISTRIBUTION__ === 'cloud'
|
||||
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
|
||||
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
|
||||
|
||||
if (requiresRemoteConfigBootstrap) {
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
}
|
||||
// Load remote config before initializeApp() below, so getFirebaseConfig() resolves
|
||||
// against the server's runtime values instead of the build-time defaults.
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
if (isCloud) {
|
||||
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
|
||||
|
||||
117
src/platform/keybindings/keybindingService.dialog.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { markRaw, reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
function createTestDialogInstance(
|
||||
key: string,
|
||||
overrides: Partial<DialogInstance> = {}
|
||||
): DialogInstance {
|
||||
return {
|
||||
key,
|
||||
visible: true,
|
||||
component: markRaw({ template: '<div />' }),
|
||||
contentProps: {},
|
||||
dialogComponentProps: {},
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => {
|
||||
const dialogStack = reactive<DialogInstance[]>([])
|
||||
return {
|
||||
useDialogStore: () => ({ dialogStack })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: null
|
||||
}
|
||||
}))
|
||||
|
||||
describe('keybindingService - dialog gate', () => {
|
||||
let keybindingService: ReturnType<typeof useKeybindingService>
|
||||
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
mockCommandExecute = vi.fn()
|
||||
commandStore.execute = mockCommandExecute
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.dialogStack.length = 0
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
keybindingService.registerCoreKeybindings()
|
||||
})
|
||||
|
||||
function createKeyboardEvent(
|
||||
key: string,
|
||||
target: HTMLElement = document.body
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [target])
|
||||
return event
|
||||
}
|
||||
|
||||
it('executes a global keybinding when no dialog is open', async () => {
|
||||
const event = createKeyboardEvent('w')
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith(
|
||||
'Workspace.ToggleSidebarTab.workflows'
|
||||
)
|
||||
})
|
||||
|
||||
it('does NOT execute a global keybinding while a dialog is open', async () => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.dialogStack.push(createTestDialogInstance('templates-dialog'))
|
||||
|
||||
const event = createKeyboardEvent('w')
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still executes a keybinding whose target lives inside the open dialog', async () => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.dialogStack.push(createTestDialogInstance('templates-dialog'))
|
||||
|
||||
const dialog = document.createElement('div')
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
const inner = document.createElement('button')
|
||||
dialog.appendChild(inner)
|
||||
document.body.appendChild(dialog)
|
||||
|
||||
try {
|
||||
const event = createKeyboardEvent('w', inner)
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith(
|
||||
'Workspace.ToggleSidebarTab.workflows'
|
||||
)
|
||||
} finally {
|
||||
document.body.removeChild(dialog)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -108,7 +108,7 @@ describe('keybindingService - Escape key handling', () => {
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute Escape keybinding with modifiers regardless of dialog state', async () => {
|
||||
it('should NOT execute Escape keybinding with modifiers when a dialog is open', async () => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.dialogStack.push(createTestDialogInstance('test-dialog'))
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('keybindingService - Escape key handling', () => {
|
||||
const event = createKeyboardEvent('Escape', { ctrlKey: true })
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith('Test.CtrlEscape')
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should verify Escape keybinding exists in CORE_KEYBINDINGS', () => {
|
||||
|
||||
@@ -55,6 +55,18 @@ export function useKeybindingService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block global keybindings from triggering background actions while a
|
||||
* modal dialog is open. Keybindings whose event target lives inside an
|
||||
* open dialog still fire, so dialog-scoped shortcuts keep working.
|
||||
*/
|
||||
if (dialogStore.dialogStack.length > 0) {
|
||||
const inDialog = target.closest?.('[role="dialog"]') != null
|
||||
if (!inDialog) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const runCommandIds = new Set([
|
||||
'Comfy.QueuePrompt',
|
||||
|
||||
@@ -7,7 +7,8 @@ import { remoteConfig } from './remoteConfig'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
fetchApi: vi.fn(),
|
||||
apiURL: vi.fn((route: string) => `/ComfyUI/api${route}`)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -43,9 +44,10 @@ describe('refreshRemoteConfig', () => {
|
||||
|
||||
await refreshRemoteConfig({ useAuth: true })
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith('/features', {
|
||||
cache: 'no-store'
|
||||
})
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/features',
|
||||
expect.objectContaining({ cache: 'no-store' })
|
||||
)
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
expect(remoteConfig.value).toEqual(mockConfig)
|
||||
expect(window.__CONFIG__).toEqual(mockConfig)
|
||||
@@ -59,23 +61,56 @@ describe('refreshRemoteConfig', () => {
|
||||
expect(api.fetchApi).toHaveBeenCalled()
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not pass an abort signal on the authed branch (so it is never aborted)', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await refreshRemoteConfig({ useAuth: true })
|
||||
|
||||
const init = vi.mocked(api.fetchApi).mock.calls[0][1]
|
||||
expect(init?.signal).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('without auth', () => {
|
||||
it('uses raw fetch when useAuth is false', async () => {
|
||||
it('builds the no-auth url via api.apiURL so a path prefix is respected', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/features', {
|
||||
cache: 'no-store'
|
||||
})
|
||||
expect(api.apiURL).toHaveBeenCalledWith('/features')
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/ComfyUI/api/features',
|
||||
expect.objectContaining({ cache: 'no-store' })
|
||||
)
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
expect(remoteConfig.value).toEqual(mockConfig)
|
||||
expect(window.__CONFIG__).toEqual(mockConfig)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout', () => {
|
||||
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
const init = vi.mocked(global.fetch).mock.calls[0][1]
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('falls back to empty config when the request aborts', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(
|
||||
new DOMException('Aborted', 'AbortError')
|
||||
)
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(remoteConfig.value).toEqual({})
|
||||
expect(window.__CONFIG__).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('clears config on 401 response', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
remoteConfigState
|
||||
} from './remoteConfig'
|
||||
|
||||
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
|
||||
// A same-origin GET against the local comfyui server should resolve in well under a second;
|
||||
// on timeout the catch below clears remoteConfig and consumers fall back to build-time defaults.
|
||||
const FEATURES_FETCH_TIMEOUT_MS = 5_000
|
||||
|
||||
interface RefreshRemoteConfigOptions {
|
||||
/**
|
||||
* Whether to use authenticated API (default: true).
|
||||
@@ -12,10 +17,14 @@ interface RefreshRemoteConfigOptions {
|
||||
useAuth?: boolean
|
||||
}
|
||||
|
||||
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
|
||||
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
|
||||
|
||||
async function fetchRemoteConfig(
|
||||
useAuth: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<Response> {
|
||||
const { api } = await import('@/scripts/api')
|
||||
if (!useAuth) {
|
||||
return fetch(api.apiURL('/features'), { cache: 'no-store', signal })
|
||||
}
|
||||
return api.fetchApi('/features', { cache: 'no-store' })
|
||||
}
|
||||
|
||||
@@ -33,8 +42,13 @@ export async function refreshRemoteConfig(
|
||||
): Promise<void> {
|
||||
const { useAuth = true } = options
|
||||
|
||||
const controller = useAuth ? null : new AbortController()
|
||||
const timeoutId = controller
|
||||
? setTimeout(() => controller.abort(), FEATURES_FETCH_TIMEOUT_MS)
|
||||
: null
|
||||
|
||||
try {
|
||||
const response = await fetchRemoteConfig(useAuth)
|
||||
const response = await fetchRemoteConfig(useAuth, controller?.signal)
|
||||
|
||||
if (response.ok) {
|
||||
const config = await response.json()
|
||||
@@ -59,5 +73,7 @@ export async function refreshRemoteConfig(
|
||||
window.__CONFIG__ = {}
|
||||
remoteConfig.value = {}
|
||||
remoteConfigState.value = 'error'
|
||||
} finally {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export type RemoteConfig = {
|
||||
comfy_api_base_url?: string
|
||||
comfy_platform_base_url?: string
|
||||
firebase_config?: FirebaseRuntimeConfig
|
||||
firebase_env?: 'dev'
|
||||
telemetry_disabled_events?: TelemetryEventName[]
|
||||
enable_telemetry?: boolean
|
||||
model_upload_button_enabled?: boolean
|
||||
|
||||
@@ -9,13 +9,6 @@ import { i18n } from '@/i18n'
|
||||
const flushPromises = () =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const trackSettingChanged = vi.fn()
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackSettingChanged
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockSet = vi.fn()
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -40,7 +33,7 @@ const FormItemUpdateStub = defineComponent({
|
||||
template: '<div data-testid="form-item-stub" />'
|
||||
})
|
||||
|
||||
describe('SettingItem (telemetry UI tracking)', () => {
|
||||
describe('SettingItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
emitFormValue = null
|
||||
@@ -61,15 +54,15 @@ describe('SettingItem (telemetry UI tracking)', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('tracks telemetry when value changes via UI (uses normalized value)', async () => {
|
||||
it('persists setting updates through the setting store', async () => {
|
||||
const settingParams: SettingParams = {
|
||||
id: 'main.sub.setting.name',
|
||||
name: 'Telemetry Visible',
|
||||
name: 'Visible Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default'
|
||||
}
|
||||
|
||||
mockGet.mockReturnValueOnce('default').mockReturnValueOnce('normalized')
|
||||
mockGet.mockReturnValue('default')
|
||||
mockSet.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent(settingParams)
|
||||
@@ -78,33 +71,6 @@ describe('SettingItem (telemetry UI tracking)', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setting_id: 'main.sub.setting.name',
|
||||
previous_value: 'default',
|
||||
new_value: 'normalized'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track telemetry when normalized value does not change', async () => {
|
||||
const settingParams: SettingParams = {
|
||||
id: 'main.sub.setting.name',
|
||||
name: 'Telemetry Visible',
|
||||
type: 'text',
|
||||
defaultValue: 'same'
|
||||
}
|
||||
|
||||
mockGet.mockReturnValueOnce('same').mockReturnValueOnce('same')
|
||||
mockSet.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent(settingParams)
|
||||
|
||||
emitFormValue!('same')
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
expect(mockSet).toHaveBeenCalledWith('main.sub.setting.name', 'newvalue')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,6 @@ import FormItem from '@/components/common/FormItem.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingOption, SettingParams } from '@/platform/settings/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
@@ -81,19 +80,6 @@ const settingValue = computed(() => settingStore.get(props.setting.id))
|
||||
const updateSettingValue = async <K extends keyof Settings>(
|
||||
newValue: Settings[K]
|
||||
) => {
|
||||
const telemetry = useTelemetry()
|
||||
const settingId = props.setting.id
|
||||
const previousValue = settingValue.value
|
||||
|
||||
await settingStore.set(settingId, newValue)
|
||||
|
||||
const normalizedValue = settingStore.get(settingId)
|
||||
if (previousValue !== normalizedValue) {
|
||||
telemetry?.trackSettingChanged({
|
||||
setting_id: settingId,
|
||||
previous_value: previousValue,
|
||||
new_value: normalizedValue
|
||||
})
|
||||
}
|
||||
await settingStore.set(props.setting.id, newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -945,6 +945,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'hidden',
|
||||
defaultValue: 'dark',
|
||||
versionModified: '1.6.7',
|
||||
telemetry: { trackChanges: true, includeValues: true },
|
||||
migrateDeprecatedValue(val: unknown) {
|
||||
const value = val as string
|
||||
// Legacy custom palettes were prefixed with 'custom_'
|
||||
|
||||
@@ -11,6 +11,16 @@ import type { Settings } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { trackSettingChanged } = vi.hoisted(() => ({
|
||||
trackSettingChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackSettingChanged
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the api
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -399,11 +409,6 @@ describe('useSettingStore', () => {
|
||||
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
|
||||
expect(api.storeSetting).toHaveBeenCalledWith('test.setting', 'newvalue')
|
||||
|
||||
// Set the same value again, it should not trigger onChange
|
||||
await store.set('test.setting', 'newvalue')
|
||||
expect(onChangeMock).toHaveBeenCalledTimes(2)
|
||||
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Set a different value, it should trigger onChange
|
||||
await store.set('test.setting', 'differentvalue')
|
||||
expect(onChangeMock).toHaveBeenCalledWith('differentvalue', 'newvalue')
|
||||
@@ -415,6 +420,120 @@ describe('useSettingStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks visible settings with values by default', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default'
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'test.setting',
|
||||
previous_value: 'default',
|
||||
new_value: 'newvalue'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track hidden settings by default', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'hidden',
|
||||
defaultValue: 'default'
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not track visible settings that opt out', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default',
|
||||
telemetry: { trackChanges: false }
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks visible settings without values when values opt out', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default',
|
||||
telemetry: { includeValues: false }
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'test.setting'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks hidden settings that opt in, without shipping values by default', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'hidden',
|
||||
defaultValue: 'default',
|
||||
telemetry: { trackChanges: true }
|
||||
})
|
||||
|
||||
await store.set('test.setting', 'newvalue')
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'test.setting'
|
||||
})
|
||||
|
||||
// Setting the same value again is a no-op and should not re-emit
|
||||
await store.set('test.setting', 'newvalue')
|
||||
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ships previous/new values when the setting opts into includeValues', async () => {
|
||||
store.addSetting({
|
||||
id: 'Comfy.ColorPalette',
|
||||
name: 'The active color palette id',
|
||||
type: 'hidden',
|
||||
defaultValue: 'dark',
|
||||
telemetry: { trackChanges: true, includeValues: true }
|
||||
})
|
||||
|
||||
await store.set('Comfy.ColorPalette', 'light')
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'Comfy.ColorPalette',
|
||||
previous_value: 'dark',
|
||||
new_value: 'light'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track telemetry when persistence fails', async () => {
|
||||
store.addSetting({
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default',
|
||||
telemetry: { trackChanges: true }
|
||||
})
|
||||
vi.mocked(api.storeSetting).mockRejectedValueOnce(new Error('failed'))
|
||||
|
||||
await expect(store.set('test.setting', 'newvalue')).rejects.toThrow(
|
||||
'failed'
|
||||
)
|
||||
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('object mutation prevention', () => {
|
||||
beforeEach(() => {
|
||||
const setting: SettingParams = {
|
||||
@@ -542,6 +661,34 @@ describe('useSettingStore', () => {
|
||||
expect(api.storeSetting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks only the settings in a batch that opt in', async () => {
|
||||
store.addSetting({
|
||||
id: 'Comfy.ColorPalette',
|
||||
name: 'The active color palette id',
|
||||
type: 'hidden',
|
||||
defaultValue: 'dark',
|
||||
telemetry: { trackChanges: true, includeValues: true }
|
||||
})
|
||||
store.addSetting({
|
||||
id: 'Comfy.Release.Version',
|
||||
name: 'Release Version',
|
||||
type: 'hidden',
|
||||
defaultValue: ''
|
||||
})
|
||||
|
||||
await store.setMany({
|
||||
'Comfy.ColorPalette': 'light',
|
||||
'Comfy.Release.Version': '1.0.0'
|
||||
})
|
||||
|
||||
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
|
||||
expect(trackSettingChanged).toHaveBeenCalledWith({
|
||||
setting_id: 'Comfy.ColorPalette',
|
||||
previous_value: 'dark',
|
||||
new_value: 'light'
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip unchanged values', async () => {
|
||||
store.addSetting({
|
||||
id: 'Comfy.Release.Version',
|
||||
@@ -581,6 +728,7 @@ describe('useSettingStore', () => {
|
||||
await store.setMany({ 'Comfy.Release.Version': 'existing' })
|
||||
|
||||
expect(api.storeSettings).not.toHaveBeenCalled()
|
||||
expect(trackSettingChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,8 @@ import { compare, valid } from 'semver'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SettingChangedMetadata } from '@/platform/telemetry/types'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -23,6 +25,11 @@ export interface SettingTreeNode extends TreeNode {
|
||||
data?: SettingParams
|
||||
}
|
||||
|
||||
interface AppliedSetting<TValue> {
|
||||
previousValue: TValue
|
||||
newValue: TValue
|
||||
}
|
||||
|
||||
function tryMigrateDeprecatedValue(
|
||||
setting: SettingParams | undefined,
|
||||
value: unknown
|
||||
@@ -45,6 +52,28 @@ function onChange(
|
||||
}
|
||||
}
|
||||
|
||||
function settingChangedEvent<K extends keyof Settings>(
|
||||
setting: SettingParams | undefined,
|
||||
key: K,
|
||||
applied: AppliedSetting<Settings[K]>
|
||||
): SettingChangedMetadata | undefined {
|
||||
if (!setting) return undefined
|
||||
|
||||
const telemetry = setting.telemetry
|
||||
const isVisible = setting.type !== 'hidden'
|
||||
const trackChanges = telemetry?.trackChanges ?? isVisible
|
||||
if (!trackChanges) return undefined
|
||||
|
||||
const includeValues = telemetry?.includeValues ?? isVisible
|
||||
return includeValues
|
||||
? {
|
||||
setting_id: key,
|
||||
previous_value: applied.previousValue,
|
||||
new_value: applied.newValue
|
||||
}
|
||||
: { setting_id: key }
|
||||
}
|
||||
|
||||
export const useSettingStore = defineStore('setting', () => {
|
||||
const settingValues = ref<Partial<Settings>>({})
|
||||
const settingsById = ref<Record<string, SettingParams>>({})
|
||||
@@ -99,7 +128,7 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
function applySettingLocally<K extends keyof Settings>(
|
||||
key: K,
|
||||
value: Settings[K]
|
||||
): Settings[K] | undefined {
|
||||
): AppliedSetting<Settings[K]> | undefined {
|
||||
const clonedValue = _.cloneDeep(value)
|
||||
const newValue = tryMigrateDeprecatedValue(
|
||||
settingsById.value[key],
|
||||
@@ -109,8 +138,12 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
if (newValue === oldValue) return undefined
|
||||
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
settingValues.value[key] = newValue
|
||||
return newValue as Settings[K]
|
||||
const typedNewValue = newValue as Settings[K]
|
||||
settingValues.value[key] = typedNewValue
|
||||
return {
|
||||
previousValue: oldValue,
|
||||
newValue: typedNewValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +154,10 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
|
||||
const applied = applySettingLocally(key, value)
|
||||
if (applied === undefined) return
|
||||
await api.storeSetting(key, applied)
|
||||
await api.storeSetting(key, applied.newValue)
|
||||
|
||||
const event = settingChangedEvent(settingsById.value[key], key, applied)
|
||||
if (event) useTelemetry()?.trackSettingChanged(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +166,7 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
*/
|
||||
async function setMany(settings: Partial<Settings>) {
|
||||
const updatedSettings: Partial<Settings> = {}
|
||||
const telemetryEvents: SettingChangedMetadata[] = []
|
||||
|
||||
for (const key of Object.keys(settings) as (keyof Settings)[]) {
|
||||
const applied = applySettingLocally(
|
||||
@@ -137,12 +174,18 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
settings[key] as Settings[typeof key]
|
||||
)
|
||||
if (applied !== undefined) {
|
||||
updatedSettings[key] = applied
|
||||
updatedSettings[key] = applied.newValue
|
||||
const event = settingChangedEvent(settingsById.value[key], key, applied)
|
||||
if (event) telemetryEvents.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updatedSettings).length > 0) {
|
||||
await api.storeSettings(updatedSettings)
|
||||
const telemetry = useTelemetry()
|
||||
for (const event of telemetryEvents) {
|
||||
telemetry?.trackSettingChanged(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,22 @@ export interface SettingOption {
|
||||
value?: string | number
|
||||
}
|
||||
|
||||
type SettingTelemetryOptions =
|
||||
| {
|
||||
trackChanges: false
|
||||
includeValues?: never
|
||||
}
|
||||
| {
|
||||
trackChanges?: true
|
||||
includeValues?: boolean
|
||||
}
|
||||
|
||||
export interface SettingParams<TValue = unknown> extends FormItem {
|
||||
id: keyof Settings
|
||||
defaultValue: TValue | (() => TValue)
|
||||
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
|
||||
onChange?(newValue: TValue, oldValue?: TValue): void
|
||||
telemetry?: SettingTelemetryOptions
|
||||
// By default category is id.split('.'). However, changing id to assign
|
||||
// new category has poor backward compatibility. Use this field to overwrite
|
||||
// default category from id.
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
api_host: 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
@@ -635,7 +635,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures page view with page_name property', async () => {
|
||||
it('captures legacy page view event with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -645,9 +645,13 @@ describe('PostHogTelemetryProvider', () => {
|
||||
TelemetryEvents.PAGE_VIEW,
|
||||
{ page_name: 'workflow_editor' }
|
||||
)
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards additional metadata', async () => {
|
||||
it('forwards additional metadata to legacy page view event', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -660,6 +664,20 @@ describe('PostHogTelemetryProvider', () => {
|
||||
{ page_name: 'workflow_editor', path: '/workflows/123' }
|
||||
)
|
||||
})
|
||||
|
||||
it('queues legacy page view event before initialization', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackPageView('workflow_editor')
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.PAGE_VIEW,
|
||||
{ page_name: 'workflow_editor' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('before_send', () => {
|
||||
|
||||
@@ -126,7 +126,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
|
||||
class="video-preview flex size-full min-h-55 min-w-16 flex-col px-2"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Video Wrapper -->
|
||||
@@ -45,7 +45,12 @@
|
||||
<video
|
||||
v-if="!videoError"
|
||||
:src="currentVideoUrl"
|
||||
:class="cn('block size-full object-contain', showLoader && 'invisible')"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full object-contain',
|
||||
showLoader && 'invisible'
|
||||
)
|
||||
"
|
||||
preload="metadata"
|
||||
controls
|
||||
loop
|
||||
|
||||
@@ -79,7 +79,6 @@ function createMockNodeElement(
|
||||
element.setAttribute('data-node-id', 'test-node')
|
||||
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
|
||||
element.getBoundingClientRect = () => {
|
||||
// When --node-height is '0px', return the content-driven minimum height
|
||||
const nodeHeight = element.style.getPropertyValue('--node-height')
|
||||
const h = nodeHeight === '0px' ? minContentHeight : height
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import FormDropdown from './FormDropdown.vue'
|
||||
import { DROPDOWN_PANEL_CLASS } from './shared'
|
||||
import type { FormDropdownItem } from './types'
|
||||
|
||||
function createItem(id: string, name: string): FormDropdownItem {
|
||||
@@ -20,6 +22,14 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const transformState = vi.hoisted(() => ({ camera: { x: 0, y: 0, z: 1 } }))
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
transformState.camera = reactive(transformState.camera)
|
||||
return { useTransformState: () => ({ camera: transformState.camera }) }
|
||||
})
|
||||
|
||||
const MockFormDropdownMenu = {
|
||||
name: 'FormDropdownMenu',
|
||||
props: [
|
||||
@@ -71,6 +81,7 @@ interface MountDropdownOptions {
|
||||
multiple?: boolean | number
|
||||
searchQuery?: string
|
||||
onUpdateSelected?: (selected: Set<string>) => void
|
||||
onUpdateIsOpen?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
function flushPromises() {
|
||||
@@ -88,10 +99,11 @@ function mountDropdown(
|
||||
multiple: options.multiple,
|
||||
searcher: options.searcher,
|
||||
searchQuery: options.searchQuery,
|
||||
'onUpdate:selected': options.onUpdateSelected
|
||||
'onUpdate:selected': options.onUpdateSelected,
|
||||
'onUpdate:isOpen': options.onUpdateIsOpen
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
stubs: {
|
||||
FormDropdownInput: MockFormDropdownInput,
|
||||
Popover: MockPopover,
|
||||
@@ -123,6 +135,12 @@ async function openDropdown(user: ReturnType<typeof userEvent.setup>) {
|
||||
}
|
||||
|
||||
describe('FormDropdown', () => {
|
||||
beforeEach(() => {
|
||||
transformState.camera.x = 0
|
||||
transformState.camera.y = 0
|
||||
transformState.camera.z = 1
|
||||
})
|
||||
|
||||
describe('filteredItems updates when items prop changes', () => {
|
||||
it('updates displayed items when items prop changes', async () => {
|
||||
const { rerender } = mountDropdown([
|
||||
@@ -362,6 +380,74 @@ describe('FormDropdown', () => {
|
||||
expect(onUpdateSelected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes on a pointerdown outside the menu and trigger', async () => {
|
||||
const onUpdateIsOpen = vi.fn()
|
||||
const { user } = mountDropdown([createItem('1', 'alpha')], {
|
||||
onUpdateIsOpen
|
||||
})
|
||||
await openDropdown(user)
|
||||
|
||||
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
|
||||
|
||||
const outside = document.createElement('div')
|
||||
document.body.appendChild(outside)
|
||||
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
|
||||
outside.remove()
|
||||
})
|
||||
|
||||
it('closes when the canvas viewport moves', async () => {
|
||||
const onUpdateIsOpen = vi.fn()
|
||||
const { user } = mountDropdown([createItem('1', 'alpha')], {
|
||||
onUpdateIsOpen
|
||||
})
|
||||
await openDropdown(user)
|
||||
|
||||
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
|
||||
|
||||
transformState.camera.x += 77
|
||||
await flushPromises()
|
||||
|
||||
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('stays open on a pointerdown inside the menu', async () => {
|
||||
const onUpdateIsOpen = vi.fn()
|
||||
const { user } = mountDropdown([createItem('1', 'alpha')], {
|
||||
onUpdateIsOpen
|
||||
})
|
||||
await openDropdown(user)
|
||||
|
||||
screen
|
||||
.getByTestId('dropdown-menu')
|
||||
.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
|
||||
it('stays open on a pointerdown inside a body-teleported sub-popover panel', async () => {
|
||||
const onUpdateIsOpen = vi.fn()
|
||||
const { user } = mountDropdown([createItem('1', 'alpha')], {
|
||||
onUpdateIsOpen
|
||||
})
|
||||
await openDropdown(user)
|
||||
|
||||
const panel = document.createElement('div')
|
||||
panel.classList.add(DROPDOWN_PANEL_CLASS)
|
||||
const option = document.createElement('button')
|
||||
panel.appendChild(option)
|
||||
document.body.appendChild(panel)
|
||||
|
||||
option.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
|
||||
panel.remove()
|
||||
})
|
||||
|
||||
it('does not select a search result from multi-select dropdowns', async () => {
|
||||
const onUpdateSelected = vi.fn()
|
||||
const { user } = mountDropdown(
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync, refDebounced } from '@vueuse/core'
|
||||
import {
|
||||
computedAsync,
|
||||
refDebounced,
|
||||
unrefElement,
|
||||
useEventListener
|
||||
} from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDismissOnCanvasGesture } from '@/renderer/extensions/vueNodes/widgets/composables/useDismissOnCanvasGesture'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -14,7 +21,11 @@ import type {
|
||||
|
||||
import FormDropdownInput from './FormDropdownInput.vue'
|
||||
import FormDropdownMenu from './FormDropdownMenu.vue'
|
||||
import { defaultSearcher, getDefaultSortOptions } from './shared'
|
||||
import {
|
||||
DROPDOWN_PANEL_CLASS,
|
||||
defaultSearcher,
|
||||
getDefaultSortOptions
|
||||
} from './shared'
|
||||
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
|
||||
|
||||
interface Props {
|
||||
@@ -102,6 +113,7 @@ const isOpen = defineModel<boolean>('isOpen', { default: false })
|
||||
const toastStore = useToastStore()
|
||||
const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const triggerAnchorRef = useTemplateRef<HTMLElement>('triggerAnchorRef')
|
||||
const menuRef = useTemplateRef<ComponentPublicInstance>('menuRef')
|
||||
const triggerRef =
|
||||
useTemplateRef<InstanceType<typeof FormDropdownInput>>('triggerRef')
|
||||
const displayedSearchQuery = ref('')
|
||||
@@ -204,6 +216,43 @@ const closeDropdown = ({ restoreFocus = false } = {}) => {
|
||||
if (restoreFocus) focusTrigger()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss on `pointerdown` rather than PrimeVue's default `click` (mouseup) so
|
||||
* the dropdown closes the instant an outside press lands, and a focused inner
|
||||
* scrollbar cannot swallow the first outside click. Presses on the trigger and
|
||||
* on the menu's body-teleported sub-popovers (Sort / Ownership / Base-model)
|
||||
* are excluded so they keep working instead of closing the parent.
|
||||
*/
|
||||
useEventListener(
|
||||
window,
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
if (!isOpen.value) return
|
||||
const menuEl = unrefElement(menuRef)
|
||||
const triggerEl = triggerAnchorRef.value
|
||||
const path = event.composedPath()
|
||||
if (menuEl && path.includes(menuEl)) return
|
||||
if (triggerEl && path.includes(triggerEl)) return
|
||||
if (path.some(isInsideDropdownPanel)) return
|
||||
closeDropdown()
|
||||
},
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
function isInsideDropdownPanel(target: EventTarget): boolean {
|
||||
return (
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains(DROPDOWN_PANEL_CLASS)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The popover is teleported to `document.body`, so canvas gestures (pan, zoom,
|
||||
* box select — any input device) move the node while the popover stays put.
|
||||
* Dismiss as soon as such a gesture begins.
|
||||
*/
|
||||
useDismissOnCanvasGesture(isOpen, () => closeDropdown())
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
if (disabled) return
|
||||
const target = event.target
|
||||
@@ -268,6 +317,11 @@ async function selectTopSearchResult() {
|
||||
function handleSearchEnter() {
|
||||
void selectTopSearchResult()
|
||||
}
|
||||
|
||||
function showPicker() {
|
||||
triggerRef.value!.showPicker()
|
||||
closeDropdown()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -290,7 +344,7 @@ function handleSearchEnter() {
|
||||
/>
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:dismissable="false"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
@@ -304,12 +358,14 @@ function handleSearchEnter() {
|
||||
@hide="isOpen = false"
|
||||
>
|
||||
<FormDropdownMenu
|
||||
ref="menuRef"
|
||||
v-model:filter-selected="filterSelected"
|
||||
v-model:layout-mode="layoutMode"
|
||||
v-model:sort-selected="sortSelected"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:uploadable
|
||||
:filter-options
|
||||
:sort-options
|
||||
:show-ownership-filter
|
||||
@@ -326,6 +382,7 @@ function handleSearchEnter() {
|
||||
@close="closeDropdown"
|
||||
@search-enter="handleSearchEnter"
|
||||
@item-click="handleSelection"
|
||||
@show-picker="showPicker"
|
||||
@approach-end="emit('approach-end')"
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import FormDropdownInput from './FormDropdownInput.vue'
|
||||
@@ -132,4 +133,57 @@ describe('FormDropdownInput', () => {
|
||||
expect(onFileChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Exposed showPicker', () => {
|
||||
/** Mount a harness that captures the FormDropdownInput instance so we can
|
||||
* invoke its exposed methods, mirroring how FormDropdown drives it. */
|
||||
async function mountWithRef(props: Partial<FormDropdownInputProps> = {}) {
|
||||
const inputRef = ref<InstanceType<typeof FormDropdownInput> | null>(null)
|
||||
const Harness = defineComponent({
|
||||
components: { FormDropdownInput },
|
||||
setup: () => ({
|
||||
inputRef,
|
||||
bindings: {
|
||||
items,
|
||||
selected: new Set<string>(),
|
||||
maxSelectable: 1,
|
||||
uploadable: true,
|
||||
disabled: false,
|
||||
...props
|
||||
}
|
||||
}),
|
||||
template: '<FormDropdownInput ref="inputRef" v-bind="bindings" />'
|
||||
})
|
||||
render(Harness, { global: { plugins: [i18n] } })
|
||||
await nextTick()
|
||||
return inputRef
|
||||
}
|
||||
|
||||
it('calls showPicker on the file input when available', async () => {
|
||||
const showPickerSpy = vi.fn()
|
||||
Object.defineProperty(HTMLInputElement.prototype, 'showPicker', {
|
||||
value: showPickerSpy,
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
const inputRef = await mountWithRef()
|
||||
inputRef.value!.showPicker()
|
||||
expect(showPickerSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to click() when showPicker is unavailable', async () => {
|
||||
// Simulate older browsers
|
||||
// @ts-expect-error -- intentional removal for fallback path
|
||||
delete HTMLInputElement.prototype.showPicker
|
||||
const clickSpy = vi.fn()
|
||||
Object.defineProperty(HTMLInputElement.prototype, 'click', {
|
||||
value: clickSpy,
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
const inputRef = await mountWithRef()
|
||||
inputRef.value!.showPicker()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -43,12 +43,29 @@ const theButtonStyle = computed(() =>
|
||||
)
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement>()
|
||||
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
|
||||
|
||||
function focus() {
|
||||
buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
/**
|
||||
* Open the native file picker without a user click on the input itself.
|
||||
* Must be invoked synchronously from a user-initiated event handler so the
|
||||
* browser's transient activation requirement is satisfied. Falls back to
|
||||
* `click()` on browsers that predate showPicker (Chrome <99, Firefox <101,
|
||||
* Safari <16).
|
||||
*/
|
||||
function showPicker() {
|
||||
const input = fileInputRef.value!
|
||||
if (typeof input.showPicker === 'function') {
|
||||
input.showPicker()
|
||||
} else {
|
||||
input.click()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ focus, showPicker })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,6 +125,7 @@ defineExpose({ focus })
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="absolute inset-0 -z-1 opacity-0"
|
||||
:aria-label="t('g.upload')"
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('FormDropdownMenu', () => {
|
||||
const defaultProps = {
|
||||
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
|
||||
isSelected: () => false,
|
||||
uploadable: false,
|
||||
filterOptions: [],
|
||||
sortOptions: []
|
||||
}
|
||||
@@ -158,6 +159,58 @@ describe('FormDropdownMenu', () => {
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
/** Stub that surfaces `uploadable` as a data attribute and exposes a button
|
||||
* that emits `show-picker`, so the parent's prop-forwarding and event
|
||||
* re-emission can be asserted from the DOM. */
|
||||
const FormDropdownMenuFilterStub = {
|
||||
name: 'FormDropdownMenuFilter',
|
||||
props: ['uploadable', 'filterOptions'],
|
||||
emits: ['show-picker'],
|
||||
template:
|
||||
'<button data-testid="filter-stub" :data-uploadable="String(uploadable)" @click="$emit(\'show-picker\')" />'
|
||||
}
|
||||
|
||||
it('forwards uploadable prop to FormDropdownMenuFilter', () => {
|
||||
render(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
uploadable: true,
|
||||
filterOptions: [{ name: 'All', value: 'all' }]
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: FormDropdownMenuFilterStub,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: VirtualGridStub
|
||||
},
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('filter-stub').dataset.uploadable).toBe('true')
|
||||
})
|
||||
|
||||
it('re-emits show-picker when FormDropdownMenuFilter emits it', async () => {
|
||||
const { emitted } = render(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
uploadable: true,
|
||||
filterOptions: [{ name: 'All', value: 'all' }]
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: FormDropdownMenuFilterStub,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: VirtualGridStub
|
||||
},
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('filter-stub'))
|
||||
expect(emitted('show-picker')).toHaveLength(1)
|
||||
})
|
||||
|
||||
/** Vertical scrolling must remain native so the dropdown's own scroll
|
||||
* container can scroll its content. */
|
||||
it('does not suppress vertical scroll', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { FormDropdownItem, LayoutMode, SortOption } from './types'
|
||||
interface Props {
|
||||
items: FormDropdownItem[]
|
||||
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||
uploadable: boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
showOwnershipFilter?: boolean
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
const {
|
||||
items,
|
||||
isSelected,
|
||||
uploadable,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
showOwnershipFilter,
|
||||
@@ -46,6 +48,7 @@ const {
|
||||
const emit = defineEmits<{
|
||||
(e: 'item-click', item: FormDropdownItem, index: number): void
|
||||
(e: 'search-enter'): void
|
||||
(e: 'show-picker'): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
@@ -126,6 +129,8 @@ const onWheel = (event: WheelEvent) => {
|
||||
v-if="filterOptions.length > 0"
|
||||
v-model:filter-selected="filterSelected"
|
||||
:filter-options
|
||||
:uploadable
|
||||
@show-picker="emit('show-picker')"
|
||||
/>
|
||||
<FormDropdownMenuActions
|
||||
v-model:layout-mode="layoutMode"
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { DROPDOWN_PANEL_CLASS } from './shared'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -157,7 +158,7 @@ function handleSearchEnter(event: KeyboardEvent) {
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-50'
|
||||
class: ['absolute z-50', DROPDOWN_PANEL_CLASS]
|
||||
},
|
||||
content: {
|
||||
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
|
||||
@@ -219,7 +220,7 @@ function handleSearchEnter(event: KeyboardEvent) {
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-50'
|
||||
class: ['absolute z-50', DROPDOWN_PANEL_CLASS]
|
||||
},
|
||||
content: {
|
||||
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
|
||||
@@ -281,7 +282,7 @@ function handleSearchEnter(event: KeyboardEvent) {
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-50'
|
||||
class: ['absolute z-50', DROPDOWN_PANEL_CLASS]
|
||||
},
|
||||
content: {
|
||||
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
|
||||
|
||||
@@ -34,7 +34,7 @@ function getUploadMock() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { import: 'Import' } } }
|
||||
messages: { en: { g: { import: 'Import', upload: 'Upload' } } }
|
||||
})
|
||||
|
||||
const ButtonStub = {
|
||||
@@ -52,14 +52,16 @@ const singleOption: FilterOption[] = [{ value: 'all', name: 'All' }]
|
||||
|
||||
function renderMenu(
|
||||
filterOptions: FilterOption[] = options,
|
||||
modelValue: string | undefined = 'all'
|
||||
modelValue: string | undefined = 'all',
|
||||
extraProps: { uploadable?: boolean } = {}
|
||||
) {
|
||||
const value = ref<string | undefined>(modelValue)
|
||||
const onShowPicker = vi.fn()
|
||||
const Harness = defineComponent({
|
||||
components: { FormDropdownMenuFilter },
|
||||
setup: () => ({ value, filterOptions }),
|
||||
setup: () => ({ value, filterOptions, extraProps, onShowPicker }),
|
||||
template:
|
||||
'<FormDropdownMenuFilter v-model:filter-selected="value" :filter-options="filterOptions" />'
|
||||
'<FormDropdownMenuFilter v-model:filter-selected="value" :filter-options="filterOptions" :uploadable="extraProps.uploadable ?? false" @show-picker="onShowPicker" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
@@ -67,7 +69,7 @@ function renderMenu(
|
||||
stubs: { Button: ButtonStub }
|
||||
}
|
||||
})
|
||||
return { ...utils, value }
|
||||
return { ...utils, value, onShowPicker }
|
||||
}
|
||||
|
||||
describe('FormDropdownMenuFilter', () => {
|
||||
@@ -134,4 +136,39 @@ describe('FormDropdownMenuFilter', () => {
|
||||
expect(upload.showUploadDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local-upload button (uploadable branch)', () => {
|
||||
it('renders when uploadable is true and the Import button is disabled', () => {
|
||||
getUploadMock().isUploadButtonEnabled.value = false
|
||||
renderMenu(singleOption, 'all', { uploadable: true })
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Upload/i })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when uploadable is false', () => {
|
||||
getUploadMock().isUploadButtonEnabled.value = false
|
||||
renderMenu(singleOption, 'all', { uploadable: false })
|
||||
expect(screen.queryByRole('button', { name: /Upload/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the Import button over Upload when both gates allow it', () => {
|
||||
getUploadMock().isUploadButtonEnabled.value = true
|
||||
renderMenu(singleOption, 'all', { uploadable: true })
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Import/i })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Upload/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('emits show-picker when the upload button is clicked', async () => {
|
||||
getUploadMock().isUploadButtonEnabled.value = false
|
||||
const { onShowPicker } = renderMenu(singleOption, 'all', {
|
||||
uploadable: true
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /Upload/i }))
|
||||
expect(onShowPicker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,10 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { filterOptions } = defineProps<{
|
||||
filterOptions: FilterOption[]
|
||||
uploadable: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'show-picker'): void
|
||||
}>()
|
||||
|
||||
const filterSelected = defineModel<string>('filterSelected')
|
||||
@@ -15,6 +19,12 @@ const filterSelected = defineModel<string>('filterSelected')
|
||||
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
|
||||
|
||||
const singleFilterOption = computed(() => filterOptions.length === 1)
|
||||
|
||||
const uploadButtonStyle = cn(
|
||||
'ml-auto h-8 rounded-lg bg-base-foreground text-base-background',
|
||||
'flex items-center justify-center gap-2 p-2',
|
||||
'transition-all duration-150 hover:bg-base-foreground/90 active:scale-95'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,13 +50,24 @@ const singleFilterOption = computed(() => filterOptions.length === 1)
|
||||
</button>
|
||||
<Button
|
||||
v-if="isUploadButtonEnabled && singleFilterOption"
|
||||
class="ml-auto"
|
||||
size="md"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
:class="uploadButtonStyle"
|
||||
@click="showUploadDialog"
|
||||
>
|
||||
<i class="icon-[lucide--folder-input]" />
|
||||
<span>{{ $t('g.import') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="uploadable"
|
||||
:title="$t('g.upload')"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
:class="uploadButtonStyle"
|
||||
@click="emit('show-picker')"
|
||||
>
|
||||
<i class="icon-[lucide--folder-search] size-4" />
|
||||
<span>{{ $t('g.upload') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,14 @@ import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
|
||||
|
||||
import type { FormDropdownItem, SortOption } from './types'
|
||||
|
||||
/**
|
||||
* Marker class for the dropdown's sub-popover panels (Sort / Ownership /
|
||||
* Base-model). Those panels teleport to `document.body`, so they render outside
|
||||
* the menu's DOM subtree; the outside-press dismiss logic uses this class to
|
||||
* recognize a press inside them as still "inside" the dropdown.
|
||||
*/
|
||||
export const DROPDOWN_PANEL_CLASS = 'comfy-form-dropdown-panel'
|
||||
|
||||
export async function defaultSearcher(
|
||||
query: string,
|
||||
items: FormDropdownItem[]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
import { useDismissOnCanvasGesture } from './useDismissOnCanvasGesture'
|
||||
|
||||
function fakeCanvas(overrides: Partial<LGraphCanvas> = {}): LGraphCanvas {
|
||||
return {
|
||||
ds: { offset: [0, 0], scale: 1 },
|
||||
dragging_rectangle: null,
|
||||
...overrides
|
||||
} as LGraphCanvas
|
||||
}
|
||||
|
||||
function panCanvasTo(x: number, y: number) {
|
||||
useTransformState().syncWithCanvas(
|
||||
fakeCanvas({ ds: { offset: [x, y], scale: 1 } } as Partial<LGraphCanvas>)
|
||||
)
|
||||
}
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
describe('useDismissOnCanvasGesture', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
panCanvasTo(0, 0)
|
||||
})
|
||||
|
||||
function setup(active = ref(true)) {
|
||||
const onGesture = vi.fn()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useDismissOnCanvasGesture(active, onGesture))
|
||||
return { onGesture, active, scope }
|
||||
}
|
||||
|
||||
it('fires when the canvas viewport moves while active', async () => {
|
||||
const { onGesture, scope } = setup()
|
||||
|
||||
panCanvasTo(120, 40)
|
||||
await nextTick()
|
||||
|
||||
expect(onGesture).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('ignores viewport moves while inactive', async () => {
|
||||
const { onGesture, scope } = setup(ref(false))
|
||||
|
||||
panCanvasTo(300, 0)
|
||||
await nextTick()
|
||||
|
||||
expect(onGesture).not.toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('fires when a box selection starts while active', async () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = fakeCanvas({
|
||||
dragging_rectangle: new Float64Array([0, 0, 10, 10])
|
||||
})
|
||||
const { onGesture, scope } = setup()
|
||||
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
|
||||
expect(onGesture).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('does not poll for box selection while inactive', async () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = fakeCanvas({
|
||||
dragging_rectangle: new Float64Array([0, 0, 10, 10])
|
||||
})
|
||||
const { onGesture, scope } = setup(ref(false))
|
||||
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
|
||||
expect(onGesture).not.toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
/**
|
||||
* Invokes `onGesture` when a canvas gesture begins while `active` is true.
|
||||
*
|
||||
* Watches the canvas at the semantic layer instead of raw pointer events, so
|
||||
* every input device is covered: panning and zooming are observed through the
|
||||
* shared camera transform (mouse drag, trackpad, wheel — anything that moves
|
||||
* the viewport), and box selection through the canvas selection rectangle.
|
||||
* Intended for dismissing popups that anchor to a node but are teleported to
|
||||
* `document.body`, which would otherwise be left stranded as the node moves.
|
||||
*/
|
||||
export function useDismissOnCanvasGesture(
|
||||
active: MaybeRefOrGetter<boolean>,
|
||||
onGesture: () => void
|
||||
) {
|
||||
const { camera } = useTransformState()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
watch(
|
||||
() => [camera.x, camera.y, camera.z],
|
||||
() => {
|
||||
if (toValue(active)) onGesture()
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* `dragging_rectangle` lives on the raw LGraphCanvas instance and is not
|
||||
* reactive, so it is polled per frame — same approach as
|
||||
* SelectionRectangle.vue. Runs only while `active`.
|
||||
*/
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
if (canvasStore.canvas?.dragging_rectangle) onGesture()
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => toValue(active),
|
||||
(isActive) => (isActive ? resume() : pause()),
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -374,6 +374,9 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
}
|
||||
return map
|
||||
})
|
||||
const allNodeDefsByDisplayName = computed(() => {
|
||||
return Object.fromEntries(nodeDefs.value.map((d) => [d.display_name, d]))
|
||||
})
|
||||
|
||||
const visibleNodeDefs = computed(() => {
|
||||
return nodeDefs.value.filter((nodeDef) =>
|
||||
@@ -508,6 +511,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
nodeDefsByName,
|
||||
nodeDefsByDisplayName,
|
||||
allNodeDefsByName,
|
||||
allNodeDefsByDisplayName,
|
||||
showDeprecated,
|
||||
showExperimental,
|
||||
showDevOnly,
|
||||
|
||||
@@ -29,23 +29,6 @@ const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true'
|
||||
const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
|
||||
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
|
||||
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'
|
||||
const COVERAGE_CRITICAL = process.env.COVERAGE_CRITICAL === 'true'
|
||||
|
||||
const CRITICAL_COVERAGE_INCLUDE = [
|
||||
'src/base/**/*.{ts,vue}',
|
||||
'src/composables/**/*.{ts,vue}',
|
||||
'src/scripts/**/*.{ts,vue}',
|
||||
'src/stores/**/*.{ts,vue}',
|
||||
'src/utils/**/*.{ts,vue}',
|
||||
'src/workbench/extensions/manager/composables/**/*.{ts,vue}'
|
||||
]
|
||||
|
||||
const CRITICAL_COVERAGE_THRESHOLDS = {
|
||||
statements: 66,
|
||||
branches: 56,
|
||||
functions: 64,
|
||||
lines: 68
|
||||
}
|
||||
|
||||
// Open Graph / Twitter Meta Tags Constants
|
||||
const VITE_OG_URL = 'https://cloud.comfy.org'
|
||||
@@ -685,9 +668,7 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
include: COVERAGE_CRITICAL
|
||||
? CRITICAL_COVERAGE_INCLUDE
|
||||
: ['src/**/*.{ts,vue}'],
|
||||
include: ['src/**/*.{ts,vue}'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.spec.ts',
|
||||
@@ -696,8 +677,7 @@ export default defineConfig({
|
||||
'src/locales/**',
|
||||
'src/lib/litegraph/**',
|
||||
'src/assets/**'
|
||||
],
|
||||
...(COVERAGE_CRITICAL ? { thresholds: CRITICAL_COVERAGE_THRESHOLDS } : {})
|
||||
]
|
||||
},
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
|
||||