mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-06 05:46:39 +00:00
Compare commits
7 Commits
feat/model
...
test/fe-74
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc4e5ce7f | ||
|
|
b907423526 | ||
|
|
7d99189211 | ||
|
|
7f707cfef5 | ||
|
|
4fff42edb7 | ||
|
|
2025cbe78a | ||
|
|
b1b410b5fb |
@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
@@ -86,20 +86,6 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
// Stub entries that exist only to issue 301 redirects from old slugs to
|
||||
// their new canonical slugs. Keeps renames reproducible across regenerations.
|
||||
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
|
||||
{
|
||||
slug: 'grok-image',
|
||||
canonicalSlug: 'grok-imagine',
|
||||
name: 'Grok Image',
|
||||
displayName: 'Grok Image',
|
||||
directory: 'partner_nodes',
|
||||
huggingFaceUrl: '',
|
||||
workflowCount: 0
|
||||
}
|
||||
]
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
@@ -313,8 +299,7 @@ function run(): void {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error }
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -382,7 +367,7 @@ function run(): void {
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
|
||||
const combined = [...apiOutput, ...output]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
|
||||
@@ -7,16 +7,12 @@ const {
|
||||
item,
|
||||
locale = 'en',
|
||||
aspect = 'var(--aspect-ratio-gallery-card)',
|
||||
mobile = false,
|
||||
objectPosition = 'center',
|
||||
objectFit = 'cover'
|
||||
mobile = false
|
||||
} = defineProps<{
|
||||
item: GalleryItem
|
||||
locale?: Locale
|
||||
aspect?: string
|
||||
mobile?: boolean
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
@@ -35,15 +31,13 @@ defineEmits<{ click: [] }>()
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="size-full transition-transform duration-300 group-hover:scale-105"
|
||||
:style="{ objectPosition, objectFit }"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="item.image"
|
||||
:alt="item.title"
|
||||
class="size-full transition-transform duration-300 group-hover:scale-105"
|
||||
:style="{ objectPosition, objectFit }"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<!-- Desktop hover overlay -->
|
||||
<div
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryCard from '../gallery/GalleryCard.vue'
|
||||
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const modelName = 'Grok'
|
||||
const ctaHref = 'https://comfy.org/workflows/model/grok'
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
id: 'subway-swan',
|
||||
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
|
||||
title: 'Subway Swan',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'milos-little-wonder',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
|
||||
title: 'Milos Little Wonder',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'amber-passage',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
|
||||
title: 'Amber Passage',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats',
|
||||
objectPosition: 'bottom'
|
||||
},
|
||||
{
|
||||
id: 'neon-revenant',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
|
||||
title: 'Neon Revenant',
|
||||
userAlias: 'Eric Solorio',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.instagram.com/enigmatic_e'
|
||||
},
|
||||
{
|
||||
id: 'midnight-umami',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
|
||||
title: 'Midnight Umami',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
}
|
||||
]
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
function openDetail(index: number) {
|
||||
modalIndex.value = index
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const title = t('models.list.creations.title', locale).replace(
|
||||
'{name}',
|
||||
modelName
|
||||
)
|
||||
const ctaLabel = t('models.list.creations.cta', locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-testid="model-creations"
|
||||
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
|
||||
>
|
||||
<h2
|
||||
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-16 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ ctaLabel }}
|
||||
</BrandButton>
|
||||
|
||||
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(0, 2)"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(2, 5)"
|
||||
:key="i + 2"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
@click="openDetail(i + 2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
|
||||
>
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
mobile
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GalleryDetailModal
|
||||
v-if="modalOpen"
|
||||
:items
|
||||
:initial-index="modalIndex"
|
||||
:locale
|
||||
@close="modalOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
modelName,
|
||||
ctaHref,
|
||||
videoSrc,
|
||||
videoAriaLabel
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
modelName: string
|
||||
ctaHref: string
|
||||
videoSrc: string
|
||||
videoAriaLabel?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col items-center px-6 pt-16 text-center lg:pt-36">
|
||||
<h1
|
||||
class="max-w-4xl text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ modelName }} in <span class="text-primary-comfy-yellow">ComfyUI</span>
|
||||
</h1>
|
||||
<p
|
||||
class="mt-6 max-w-2xl text-sm text-pretty text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-10 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
|
||||
</BrandButton>
|
||||
<div class="mt-16 w-full max-w-5xl">
|
||||
<video
|
||||
:src="videoSrc"
|
||||
:aria-label="videoAriaLabel || undefined"
|
||||
:aria-hidden="videoAriaLabel ? undefined : true"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="rounded-4.5xl size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
|
||||
<p
|
||||
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
|
||||
>
|
||||
{{ t('cloud.aiModels.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
|
||||
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
|
||||
>
|
||||
{{ t('cloud.aiModels.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="mt-24 w-full">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
@@ -180,15 +180,14 @@ function getCardClass(layoutClass: string): string {
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
|
||||
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
|
||||
>
|
||||
<!-- <span class="lg:hidden"> -->
|
||||
{{ t('cloud.aiModels.ctaMobile', locale) }}
|
||||
<!-- </span> -->
|
||||
<!-- <span class="hidden lg:inline">{{
|
||||
<span class="lg:hidden">{{
|
||||
t('cloud.aiModels.ctaMobile', locale)
|
||||
}}</span>
|
||||
<span class="hidden lg:inline">{{
|
||||
t('cloud.aiModels.ctaDesktop', locale)
|
||||
}}</span> -->
|
||||
}}</span>
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
|
||||
hubSlug: 'seedance',
|
||||
featured: true
|
||||
},
|
||||
'grok-imagine': {
|
||||
'grok-image': {
|
||||
hubSlug: 'grok',
|
||||
featured: false
|
||||
},
|
||||
|
||||
@@ -7,8 +7,6 @@ export interface GalleryItem {
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
/** Defaults to true. Set to false to hide this item from rendered lists. */
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
@@ -4458,76 +4458,6 @@ const translations = {
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
|
||||
// Models list page (/models)
|
||||
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
|
||||
'models.list.heroCta': {
|
||||
en: 'Try {name} Now',
|
||||
'zh-CN': '立即试用 {name}'
|
||||
},
|
||||
'models.list.creations.title': {
|
||||
en: '{name} Image and Video Creations',
|
||||
'zh-CN': '{name} 图像与视频创作'
|
||||
},
|
||||
'models.list.creations.cta': {
|
||||
en: 'Explore Workflows',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.list.heroTitle.before': {
|
||||
en: 'Run the world’s leading AI models in',
|
||||
'zh-CN': '在以下平台运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.list.heroSubtitle': {
|
||||
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
|
||||
'zh-CN':
|
||||
'从开源扩散模型到合作伙伴 API,涵盖每一个主流模型,并附带可直接运行的社区工作流模板。'
|
||||
},
|
||||
'models.list.card.workflows': {
|
||||
en: '{count} workflows',
|
||||
'zh-CN': '{count} 个工作流'
|
||||
},
|
||||
'models.list.contact.label': {
|
||||
en: 'COMFY HUB',
|
||||
'zh-CN': 'COMFY HUB'
|
||||
},
|
||||
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
|
||||
'models.showcase.heading': {
|
||||
en: 'Run the world’s\nleading AI models',
|
||||
'zh-CN': '运行全球领先的\nAI 模型'
|
||||
},
|
||||
'models.showcase.subtitle': {
|
||||
en: 'New models are added as they launch.',
|
||||
'zh-CN': '新模型发布后会第一时间上线。'
|
||||
},
|
||||
'models.showcase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.showcase.card.grokImagine': {
|
||||
en: 'Grok Imagine',
|
||||
'zh-CN': 'Grok Imagine'
|
||||
},
|
||||
'models.showcase.card.nanoBananaPro': {
|
||||
en: 'Nano Banana Pro',
|
||||
'zh-CN': 'Nano Banana Pro'
|
||||
},
|
||||
'models.showcase.card.ltx23': {
|
||||
en: 'LTX 2.3',
|
||||
'zh-CN': 'LTX 2.3'
|
||||
},
|
||||
'models.showcase.card.qwenAdvancedEdit': {
|
||||
en: 'Advanced image\nediting with Qwen',
|
||||
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
|
||||
},
|
||||
'models.showcase.card.wan22TextToVideo': {
|
||||
en: 'Wan 2.2\ntext to video',
|
||||
'zh-CN': 'Wan 2.2\n文字转视频'
|
||||
},
|
||||
'models.list.contact.heading': {
|
||||
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
|
||||
'zh-CN':
|
||||
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
'payment.success.label': {
|
||||
en: 'PAYMENT',
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
|
||||
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Models — Comfy"
|
||||
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
|
||||
>
|
||||
<ModelsHeroSection
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/p/supported-models/grok-imagine"
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="Grok Imagine output created with ComfyUI"
|
||||
/>
|
||||
<ModelCreationsSection client:load />
|
||||
<AIModelsSection client:load />
|
||||
<ProductShowcaseSection client:load />
|
||||
</BaseLayout>
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
|
||||
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
|
||||
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="模型 — Comfy"
|
||||
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
|
||||
>
|
||||
<ModelsHeroSection
|
||||
locale="zh-CN"
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/zh-CN/p/supported-models/grok-imagine"
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
|
||||
/>
|
||||
<ModelCreationsSection client:load locale="zh-CN" />
|
||||
<AIModelsSection client:load locale="zh-CN" />
|
||||
<ProductShowcaseSection client:load locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
@@ -91,3 +91,21 @@ export async function measureSelectionBounds(
|
||||
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
|
||||
export async function intersection(a: Locator, b: Locator) {
|
||||
const aBounds = await a.boundingBox()
|
||||
const bBounds = await b.boundingBox()
|
||||
if (!aBounds || !bBounds) return undefined
|
||||
|
||||
const y = Math.max(aBounds.y, bBounds.y)
|
||||
const x = Math.max(aBounds.x, bBounds.x)
|
||||
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
|
||||
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
|
||||
|
||||
if (y > bot || x > right) return undefined
|
||||
|
||||
const width = right - x
|
||||
const height = bot - y
|
||||
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
@@ -180,4 +180,44 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'requests lightweight thumbnail URLs for grid cells',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
await expect(previewImage).toBeVisible()
|
||||
})
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
const gridImages = node.imageGrid.locator('img')
|
||||
|
||||
await test.step('Inject a multi-image grid', async () => {
|
||||
const images = Array.from({ length: 4 }, (_, index) => ({
|
||||
filename: `grid-${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}))
|
||||
execution.executed('', '1', { images })
|
||||
await expect(gridImages).toHaveCount(4)
|
||||
})
|
||||
|
||||
// FE-741: small on-node grid cells must request a server re-encoded
|
||||
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
|
||||
// downloading the full-resolution image, while still pointing at the
|
||||
// real `/api/view` URL for that output. Verifies the full path: WS
|
||||
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
|
||||
// rendered grid `<img>`.
|
||||
for (const cell of await gridImages.all()) {
|
||||
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
|
||||
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
|
||||
|
||||
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
async function openSamplerDropdown(comfyPage: ComfyPage) {
|
||||
@@ -278,4 +280,31 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
.getByRole('combobox', { name: 'scheduler', exact: true })
|
||||
await expect(schedulerComboAfterReload).toContainText('karras')
|
||||
})
|
||||
|
||||
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const nodeName = 'Resize Image/Mask'
|
||||
await comfyPage.searchBoxV2.addNode(nodeName, {
|
||||
position: { x: 200, y: 630 }
|
||||
})
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
|
||||
await node.select()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
|
||||
await combo.click()
|
||||
const dropdown = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.selectDefaultViewport
|
||||
)
|
||||
await expect(dropdown).toBeVisible()
|
||||
|
||||
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
|
||||
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
|
||||
const cX = bounds.x + bounds.width / 2
|
||||
const cY = bounds.y + bounds.height / 2
|
||||
const dropdownBounds = (await dropdown.boundingBox())!
|
||||
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
|
||||
await dropdown.click({ position, trial: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -50,7 +49,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<ArrangeButton v-if="showArrange" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
|
||||
<ConfigureSubgraph v-if="showSubgraphButtons" />
|
||||
@@ -49,6 +50,7 @@
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ArrangeButton from '@/components/graph/selectionToolbox/ArrangeButton.vue'
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
|
||||
@@ -110,6 +112,7 @@ const {
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
const showArrange = computed(() => hasMultipleSelection.value)
|
||||
const showFrameNodes = computed(() => hasMultipleSelection.value)
|
||||
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
@@ -128,6 +131,7 @@ const showAnyPrimaryActions = computed(
|
||||
() =>
|
||||
showColorPicker.value ||
|
||||
showConvertToSubgraph.value ||
|
||||
showArrange.value ||
|
||||
showFrameNodes.value ||
|
||||
showSubgraphButtons.value
|
||||
)
|
||||
|
||||
115
src/components/graph/selectionToolbox/ArrangeButton.vue
Normal file
115
src/components/graph/selectionToolbox/ArrangeButton.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
v-tooltip.top="{ value: t('g.arrange'), showDelay: 1000 }"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrange')"
|
||||
>
|
||||
<div class="flex items-center gap-1 px-0">
|
||||
<i class="icon-[lucide--layout-grid]" />
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
|
||||
>
|
||||
<div
|
||||
v-if="activeLayout"
|
||||
class="flex w-32 flex-row items-center px-2 py-1"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[gap]"
|
||||
:min="MIN_ARRANGE_GAP"
|
||||
:max="MAX_ARRANGE_GAP"
|
||||
:step="1"
|
||||
:aria-label="t('g.arrangeSpacing')"
|
||||
@update:model-value="onSliderUpdate"
|
||||
@value-commit="onSliderCommit"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-row gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('g.arrangeVertically'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeVertically')"
|
||||
@click="start('vertical')"
|
||||
>
|
||||
<i class="icon-[lucide--stretch-horizontal]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('g.arrangeHorizontally'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeHorizontally')"
|
||||
@click="start('horizontal')"
|
||||
>
|
||||
<i class="icon-[lucide--stretch-vertical]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="{ value: t('g.arrangeAsGrid'), showDelay: 1000 }"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeAsGrid')"
|
||||
@click="start('grid')"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3]" />
|
||||
</Button>
|
||||
</div>
|
||||
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
MAX_ARRANGE_GAP,
|
||||
MIN_ARRANGE_GAP
|
||||
} from '@/composables/graph/useArrangeNodes'
|
||||
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { activeLayout, gap, start, previewGap, commitGap, reset } =
|
||||
useArrangeSession()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) reset()
|
||||
})
|
||||
|
||||
const firstValue = (value: number[] | undefined): number | undefined =>
|
||||
value?.[0]
|
||||
|
||||
const onSliderUpdate = (value: number[] | undefined) => {
|
||||
const next = firstValue(value)
|
||||
if (next !== undefined) previewGap(next)
|
||||
}
|
||||
|
||||
const onSliderCommit = (value: number[]) => {
|
||||
const next = firstValue(value)
|
||||
if (next !== undefined) commitGap(next)
|
||||
}
|
||||
</script>
|
||||
182
src/composables/graph/useArrangeNodes.test.ts
Normal file
182
src/composables/graph/useArrangeNodes.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
interface MockNodeSpec {
|
||||
id: number | string
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
title_mode?: TitleMode
|
||||
}
|
||||
|
||||
const makeNode = (spec: MockNodeSpec): LGraphNode =>
|
||||
({
|
||||
id: spec.id,
|
||||
pos: spec.pos,
|
||||
size: spec.size,
|
||||
title_mode: spec.title_mode
|
||||
}) as unknown as LGraphNode
|
||||
|
||||
const GAP = 12
|
||||
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
|
||||
|
||||
describe('computeArrangement', () => {
|
||||
it('returns no updates when fewer than 2 nodes are selected', () => {
|
||||
expect(computeArrangement([], 'vertical')).toEqual([])
|
||||
expect(
|
||||
computeArrangement(
|
||||
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
|
||||
'grid'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
describe('vertical', () => {
|
||||
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
|
||||
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
|
||||
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
|
||||
]
|
||||
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
|
||||
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
|
||||
// Each node's pos.y = visualTop + its titleHeight (30).
|
||||
// b: pos.y = 70+30 = 100; visualTop += (30+30)+12 = 142
|
||||
// a: pos.y = 142+30 = 172; visualTop += (50+30)+12 = 234
|
||||
// c: pos.y = 234+30 = 264
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 'b', position: { x: 10, y: 100 } },
|
||||
{ nodeId: 'a', position: { x: 10, y: 172 } },
|
||||
{ nodeId: 'c', position: { x: 10, y: 264 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the title-height contribution for NO_TITLE nodes', () => {
|
||||
const nodes = [
|
||||
makeNode({
|
||||
id: 1,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
}),
|
||||
makeNode({
|
||||
id: 2,
|
||||
pos: [0, 200],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
})
|
||||
]
|
||||
// No titles: visualHeight = size[1] = 100. visualTop = 0. Gap = 12.
|
||||
// 1: pos.y = 0; visualTop = 0 + 100 + 12 = 112.
|
||||
// 2: pos.y = 112.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves heterogeneous heights when computing gaps', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
|
||||
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
|
||||
]
|
||||
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
|
||||
// 2: pos.y = 212+30 = 242.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('horizontal', () => {
|
||||
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
|
||||
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
|
||||
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
|
||||
]
|
||||
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
|
||||
// Sort by X: b(0), a(100), c(300)
|
||||
// Lay out from (100, 50):
|
||||
// b at (100, 50)
|
||||
// a at (100 + 60 + 12, 50) = (172, 50)
|
||||
// c at (172 + 80 + 12, 50) = (264, 50)
|
||||
const result = computeArrangement(nodes, 'horizontal')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 'b', position: { x: 100, y: 50 } },
|
||||
{ nodeId: 'a', position: { x: 172, y: 50 } },
|
||||
{ nodeId: 'c', position: { x: 264, y: 50 } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid', () => {
|
||||
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
|
||||
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
|
||||
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
|
||||
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
|
||||
]
|
||||
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
|
||||
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
|
||||
// Row visual heights: row0=max(50+30,60+30)=90; row1=max(40+30,30+30)=70.
|
||||
// colX=[0, 132]. rowVisualTop=[-30, -30+90+12=72].
|
||||
// pos.y = rowVisualTop + 30 (titleHeight).
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 132, y: 0 } },
|
||||
{ nodeId: 3, position: { x: 0, y: 102 } },
|
||||
{ nodeId: 4, position: { x: 132, y: 102 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
|
||||
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
|
||||
const nodes = Array.from({ length: 5 }, (_, i) =>
|
||||
makeNode({
|
||||
id: i + 1,
|
||||
pos: [i * 50, i * 50],
|
||||
size: [40, 40]
|
||||
})
|
||||
)
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toHaveLength(5)
|
||||
// Sorted by Y then X = original order. Anchor = node 1 at (0,0).
|
||||
// colWidths=[40,40,40]. rowVisualHeight = 40+30 = 70 each.
|
||||
// colX=[0,52,104]. rowVisualTop=[-30, -30+70+12=52]. pos.y = visualTop+30.
|
||||
expect(result[0].position).toEqual({ x: 0, y: 0 })
|
||||
expect(result[1].position).toEqual({ x: 52, y: 0 })
|
||||
expect(result[2].position).toEqual({ x: 104, y: 0 })
|
||||
expect(result[3].position).toEqual({ x: 0, y: 82 })
|
||||
expect(result[4].position).toEqual({ x: 52, y: 82 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('anchor selection', () => {
|
||||
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
|
||||
const nodes = [
|
||||
// min y but large x: x+y = 1000
|
||||
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
|
||||
// min x but large y: x+y = 1000
|
||||
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
|
||||
// smallest x+y: 600
|
||||
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
|
||||
]
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).
|
||||
expect(result[0]).toEqual({
|
||||
nodeId: 'minY',
|
||||
position: { x: 300, y: 300 }
|
||||
})
|
||||
expect(result.every((u) => u.position.x === 300)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
186
src/composables/graph/useArrangeNodes.ts
Normal file
186
src/composables/graph/useArrangeNodes.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export type ArrangeLayout = 'vertical' | 'horizontal' | 'grid'
|
||||
|
||||
export const DEFAULT_ARRANGE_GAP = 12
|
||||
export const MIN_ARRANGE_GAP = 0
|
||||
export const MAX_ARRANGE_GAP = 48
|
||||
|
||||
interface NodeBox {
|
||||
id: NodeId
|
||||
posX: number
|
||||
posY: number
|
||||
visualWidth: number
|
||||
visualHeight: number
|
||||
titleHeight: number
|
||||
}
|
||||
|
||||
interface ArrangeUpdate {
|
||||
nodeId: NodeId
|
||||
position: Point
|
||||
}
|
||||
|
||||
const titleHeightOf = (node: LGraphNode): number => {
|
||||
const mode = node.title_mode
|
||||
if (mode === TitleMode.TRANSPARENT_TITLE || mode === TitleMode.NO_TITLE) {
|
||||
return 0
|
||||
}
|
||||
return LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
const toBox = (node: LGraphNode): NodeBox => {
|
||||
const titleHeight = titleHeightOf(node)
|
||||
return {
|
||||
id: node.id,
|
||||
posX: node.pos[0],
|
||||
posY: node.pos[1],
|
||||
visualWidth: node.size[0],
|
||||
visualHeight: node.size[1] + titleHeight,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
|
||||
const byTopDown = (a: NodeBox, b: NodeBox) => a.posY - b.posY || a.posX - b.posX
|
||||
|
||||
const byLeftRight = (a: NodeBox, b: NodeBox) =>
|
||||
a.posX - b.posX || a.posY - b.posY
|
||||
|
||||
const findAnchor = (boxes: NodeBox[]): NodeBox =>
|
||||
boxes.reduce((best, box) =>
|
||||
box.posX + box.posY < best.posX + best.posY ? box : best
|
||||
)
|
||||
|
||||
const cumulativeOffsets = (
|
||||
sizes: number[],
|
||||
origin: number,
|
||||
gap: number
|
||||
): number[] => {
|
||||
const offsets: number[] = [origin]
|
||||
for (let i = 1; i < sizes.length; i++) {
|
||||
offsets.push(offsets[i - 1] + sizes[i - 1] + gap)
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
const arrangeVertical = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byTopDown)
|
||||
let visualTop = anchor.posY - anchor.titleHeight
|
||||
return sorted.map((box) => {
|
||||
const update: ArrangeUpdate = {
|
||||
nodeId: box.id,
|
||||
position: { x: anchor.posX, y: visualTop + box.titleHeight }
|
||||
}
|
||||
visualTop += box.visualHeight + gap
|
||||
return update
|
||||
})
|
||||
}
|
||||
|
||||
const arrangeHorizontal = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byLeftRight)
|
||||
const visualTop = anchor.posY - anchor.titleHeight
|
||||
let cursorX = anchor.posX
|
||||
return sorted.map((box) => {
|
||||
const update: ArrangeUpdate = {
|
||||
nodeId: box.id,
|
||||
position: { x: cursorX, y: visualTop + box.titleHeight }
|
||||
}
|
||||
cursorX += box.visualWidth + gap
|
||||
return update
|
||||
})
|
||||
}
|
||||
|
||||
const arrangeGrid = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byTopDown)
|
||||
const cols = Math.ceil(Math.sqrt(sorted.length))
|
||||
const rows = Math.ceil(sorted.length / cols)
|
||||
|
||||
const colWidths = new Array<number>(cols).fill(0)
|
||||
const rowHeights = new Array<number>(rows).fill(0)
|
||||
sorted.forEach((box, i) => {
|
||||
const col = i % cols
|
||||
const row = Math.floor(i / cols)
|
||||
if (box.visualWidth > colWidths[col]) colWidths[col] = box.visualWidth
|
||||
if (box.visualHeight > rowHeights[row]) rowHeights[row] = box.visualHeight
|
||||
})
|
||||
|
||||
const colX = cumulativeOffsets(colWidths, anchor.posX, gap)
|
||||
const rowVisualTop = cumulativeOffsets(
|
||||
rowHeights,
|
||||
anchor.posY - anchor.titleHeight,
|
||||
gap
|
||||
)
|
||||
|
||||
return sorted.map((box, i) => {
|
||||
const col = i % cols
|
||||
const row = Math.floor(i / cols)
|
||||
return {
|
||||
nodeId: box.id,
|
||||
position: {
|
||||
x: colX[col],
|
||||
y: rowVisualTop[row] + box.titleHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function computeArrangement(
|
||||
nodes: LGraphNode[],
|
||||
layout: ArrangeLayout,
|
||||
gap: number = DEFAULT_ARRANGE_GAP
|
||||
): ArrangeUpdate[] {
|
||||
if (nodes.length < 2) return []
|
||||
const boxes = nodes.map(toBox)
|
||||
const anchor = findAnchor(boxes)
|
||||
if (layout === 'vertical') return arrangeVertical(boxes, anchor, gap)
|
||||
if (layout === 'horizontal') return arrangeHorizontal(boxes, anchor, gap)
|
||||
return arrangeGrid(boxes, anchor, gap)
|
||||
}
|
||||
|
||||
interface ArrangeOptions {
|
||||
gap?: number
|
||||
captureUndo?: boolean
|
||||
}
|
||||
|
||||
export function useArrangeNodes() {
|
||||
const { selectedNodes, hasMultipleSelection } = useSelectionState()
|
||||
const mutations = useLayoutMutations()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const arrangeNodes = (
|
||||
layout: ArrangeLayout,
|
||||
{ gap = DEFAULT_ARRANGE_GAP, captureUndo = true }: ArrangeOptions = {}
|
||||
) => {
|
||||
if (!hasMultipleSelection.value) return
|
||||
const updates = computeArrangement(selectedNodes.value, layout, gap)
|
||||
if (updates.length === 0) return
|
||||
|
||||
mutations.setSource(LayoutSource.Canvas)
|
||||
mutations.batchMoveNodes(updates)
|
||||
app.canvas?.setDirty(true, true)
|
||||
if (captureUndo) {
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
}
|
||||
|
||||
return { arrangeNodes, canArrange: hasMultipleSelection }
|
||||
}
|
||||
115
src/composables/graph/useArrangeSession.test.ts
Normal file
115
src/composables/graph/useArrangeSession.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as ArrangeNodesModule from '@/composables/graph/useArrangeNodes'
|
||||
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
|
||||
|
||||
const mockArrangeNodes = vi.fn()
|
||||
|
||||
vi.mock('@/composables/graph/useArrangeNodes', async () => {
|
||||
const actual = await vi.importActual<typeof ArrangeNodesModule>(
|
||||
'@/composables/graph/useArrangeNodes'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useArrangeNodes: () => ({ arrangeNodes: mockArrangeNodes })
|
||||
}
|
||||
})
|
||||
|
||||
describe('useArrangeSession', () => {
|
||||
let frameCallbacks: Array<FrameRequestCallback>
|
||||
let nextHandle: number
|
||||
|
||||
beforeEach(() => {
|
||||
mockArrangeNodes.mockReset()
|
||||
frameCallbacks = []
|
||||
nextHandle = 1
|
||||
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(
|
||||
(cb: FrameRequestCallback) => {
|
||||
frameCallbacks.push(cb)
|
||||
return nextHandle++
|
||||
}
|
||||
)
|
||||
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => {
|
||||
frameCallbacks[id - 1] = () => {}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const flushFrames = () => {
|
||||
const callbacks = frameCallbacks
|
||||
frameCallbacks = []
|
||||
callbacks.forEach((cb) => cb(performance.now()))
|
||||
}
|
||||
|
||||
it('start() applies layout immediately and captures undo', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('vertical')
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('vertical', {
|
||||
gap: 12,
|
||||
captureUndo: true
|
||||
})
|
||||
expect(session.activeLayout.value).toBe('vertical')
|
||||
expect(session.gap.value).toBe(12)
|
||||
})
|
||||
|
||||
it('previewGap() throttles repeated calls into a single frame', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('grid')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(20)
|
||||
session.previewGap(30)
|
||||
session.previewGap(40)
|
||||
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('grid', {
|
||||
gap: 40,
|
||||
captureUndo: false
|
||||
})
|
||||
})
|
||||
|
||||
it('previewGap() is a no-op outside an active session', () => {
|
||||
const session = useArrangeSession()
|
||||
session.previewGap(20)
|
||||
flushFrames()
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commitGap() cancels any pending preview frame', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('horizontal')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(25)
|
||||
session.commitGap(36)
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('horizontal', {
|
||||
gap: 36,
|
||||
captureUndo: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reset() ends the session and prevents pending frames from arranging', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('vertical')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(40)
|
||||
session.reset()
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
expect(session.activeLayout.value).toBeNull()
|
||||
expect(session.gap.value).toBe(12)
|
||||
})
|
||||
})
|
||||
60
src/composables/graph/useArrangeSession.ts
Normal file
60
src/composables/graph/useArrangeSession.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
import {
|
||||
DEFAULT_ARRANGE_GAP,
|
||||
useArrangeNodes
|
||||
} from '@/composables/graph/useArrangeNodes'
|
||||
import type { ArrangeLayout } from '@/composables/graph/useArrangeNodes'
|
||||
|
||||
export function useArrangeSession() {
|
||||
const { arrangeNodes } = useArrangeNodes()
|
||||
|
||||
const activeLayout = ref<ArrangeLayout | null>(null)
|
||||
const gap = ref(DEFAULT_ARRANGE_GAP)
|
||||
let pendingFrame: number | null = null
|
||||
|
||||
const cancelPendingFrame = () => {
|
||||
if (pendingFrame === null) return
|
||||
cancelAnimationFrame(pendingFrame)
|
||||
pendingFrame = null
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
cancelPendingFrame()
|
||||
activeLayout.value = null
|
||||
gap.value = DEFAULT_ARRANGE_GAP
|
||||
}
|
||||
|
||||
const start = (layout: ArrangeLayout) => {
|
||||
gap.value = DEFAULT_ARRANGE_GAP
|
||||
activeLayout.value = layout
|
||||
arrangeNodes(layout, { gap: gap.value, captureUndo: true })
|
||||
}
|
||||
|
||||
const previewGap = (nextGap: number) => {
|
||||
if (activeLayout.value === null) return
|
||||
gap.value = nextGap
|
||||
cancelPendingFrame()
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = null
|
||||
if (activeLayout.value === null) return
|
||||
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: false })
|
||||
})
|
||||
}
|
||||
|
||||
const commitGap = (nextGap: number) => {
|
||||
if (activeLayout.value === null) return
|
||||
cancelPendingFrame()
|
||||
gap.value = nextGap
|
||||
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: true })
|
||||
}
|
||||
|
||||
return {
|
||||
activeLayout: readonly(activeLayout),
|
||||
gap: readonly(gap),
|
||||
start,
|
||||
previewGap,
|
||||
commitGap,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { HintedString } from '@primevue/core'
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
/**
|
||||
* Options for configuring transform-compatible overlay props
|
||||
*/
|
||||
interface TransformCompatOverlayOptions {
|
||||
/**
|
||||
* Where to append the overlay. 'self' keeps overlay within component
|
||||
* for proper transform inheritance, 'body' teleports to document body
|
||||
*/
|
||||
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
// Future: other props needed for transform compatibility
|
||||
// scrollTarget?: string | HTMLElement
|
||||
// autoZIndex?: boolean
|
||||
}
|
||||
|
||||
export const OverlayAppendToKey: InjectionKey<
|
||||
HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
> = Symbol('OverlayAppendTo')
|
||||
|
||||
/**
|
||||
* Composable that provides props to make PrimeVue overlay components
|
||||
* compatible with CSS-transformed parent elements.
|
||||
*
|
||||
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
|
||||
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
|
||||
* body by default, breaking transform inheritance. This composable provides
|
||||
* the necessary props to keep overlays within their component elements.
|
||||
*
|
||||
* @param overrides - Optional overrides for specific use cases
|
||||
* @returns Computed props object to spread on PrimeVue overlay components
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <template>
|
||||
* <Select v-bind="overlayProps" />
|
||||
* </template>
|
||||
*
|
||||
* <script setup>
|
||||
* const overlayProps = useTransformCompatOverlayProps()
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useTransformCompatOverlayProps(
|
||||
overrides: TransformCompatOverlayOptions = {}
|
||||
) {
|
||||
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
|
||||
|
||||
return computed(() => ({
|
||||
appendTo: injectedAppendTo ?? ('self' as const),
|
||||
...overrides
|
||||
}))
|
||||
}
|
||||
@@ -350,6 +350,11 @@
|
||||
"nodeSlotsError": "Node Slots Error",
|
||||
"nodeWidgetsError": "Node Widgets Error",
|
||||
"frameNodes": "Frame Nodes",
|
||||
"arrange": "Arrange",
|
||||
"arrangeVertically": "Arrange vertically",
|
||||
"arrangeHorizontally": "Arrange horizontally",
|
||||
"arrangeAsGrid": "Arrange as grid",
|
||||
"arrangeSpacing": "Arrangement spacing",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"play": "Play",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal :to="portalTarget" :disabled="isPortalDisabled">
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
data-capture-wheel="true"
|
||||
data-testid="widget-select-default-overlay"
|
||||
@@ -161,7 +161,6 @@ import {
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useRestoreFocusOnViewportPointer } from '@/renderer/extensions/vueNodes/widgets/composables/useRestoreFocusOnViewportPointer'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -242,19 +241,10 @@ const searchInputContainerRef = ref<HTMLElement>()
|
||||
const { handleFocusOutside, handleViewportPointerDown } =
|
||||
useRestoreFocusOnViewportPointer(focusSearchInput)
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const widgetOptions = computed(
|
||||
() => widget.options as SelectWidgetOptions | undefined
|
||||
)
|
||||
|
||||
const portalTarget = computed(() => {
|
||||
const appendTo = transformCompatProps.value.appendTo
|
||||
return appendTo === 'self' ? undefined : appendTo
|
||||
})
|
||||
|
||||
const isPortalDisabled = computed(() => !portalTarget.value)
|
||||
|
||||
const disabled = computed(() => Boolean(widgetOptions.value?.disabled))
|
||||
const placeholder = computed(() => widgetOptions.value?.placeholder ?? '')
|
||||
const filterPlaceholder = computed(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, provide, ref, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useFlatOutputAssets } from '@/platform/assets/composables/media/useFlatOutputAssets'
|
||||
@@ -53,12 +52,9 @@ const outputMediaAssets = isCloud
|
||||
? useFlatOutputAssets()
|
||||
: useAssetsApi('output')
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
const combinedProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const getAssetData = () => {
|
||||
const nodeType: string | undefined =
|
||||
|
||||
Reference in New Issue
Block a user