mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-04 19:49:09 +00:00
Compare commits
22 Commits
dev/remote
...
feat/app-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2859343b66 | ||
|
|
562e1c3216 | ||
|
|
535e7c6ea4 | ||
|
|
9aa3b5604b | ||
|
|
79fb6b7d0a | ||
|
|
78891e406b | ||
|
|
491fb9bf01 | ||
|
|
5c07cc8548 | ||
|
|
067e778e8a | ||
|
|
accc91bb20 | ||
|
|
a2ea5a9a9c | ||
|
|
8d95cd8bbc | ||
|
|
b40fb33e7a | ||
|
|
ec3c7bd8fe | ||
|
|
07e3b92266 | ||
|
|
6c3f4a6d99 | ||
|
|
7c3b75534b | ||
|
|
4b74c0182a | ||
|
|
2eb2514d25 | ||
|
|
6fb8004829 | ||
|
|
2a788f1f52 | ||
|
|
3d0a145061 |
@@ -95,7 +95,7 @@ jobs:
|
||||
if npx license-checker-rseidelsohn@4 \
|
||||
--production \
|
||||
--summary \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--clarificationsFile .github/license-clarifications.json \
|
||||
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
|
||||
echo ''
|
||||
|
||||
13
.github/workflows/ci-tests-unit.yaml
vendored
13
.github/workflows/ci-tests-unit.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
# Description: Unit and component testing with Vitest + coverage reporting
|
||||
# Description: Unit and component testing with Vitest
|
||||
name: 'CI: Tests Unit'
|
||||
|
||||
on:
|
||||
@@ -23,12 +23,5 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
fail_ci_if_error: false
|
||||
- name: Run Vitest tests
|
||||
run: pnpm test:unit
|
||||
|
||||
38
CODEOWNERS
38
CODEOWNERS
@@ -41,46 +41,12 @@
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
COMFY ACADEMY
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.label"
|
||||
class="flex items-center gap-2 text-sm text-white"
|
||||
>
|
||||
<span aria-hidden="true">{{ feature.icon }}</span>
|
||||
<span>{{ feature.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
EXPLORE ACADEMY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const cards = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
Choose Your Way to Comfy
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:href="card.href"
|
||||
class="flex flex-1 flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<span class="text-4xl" aria-hidden="true">{{ card.icon }}</span>
|
||||
<h3 class="mt-4 text-xl font-semibold text-white">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
<span
|
||||
class="mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
card.outlined
|
||||
? 'border border-brand-yellow text-brand-yellow'
|
||||
: 'bg-brand-yellow text-black'
|
||||
"
|
||||
>
|
||||
{{ card.cta }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,77 +0,0 @@
|
||||
<!-- TODO: Replace placeholder content with real quotes and case studies -->
|
||||
<script setup lang="ts">
|
||||
const studies = [
|
||||
{
|
||||
title: 'New Pipelines with Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'md:row-span-2'
|
||||
},
|
||||
{
|
||||
title: 'AI-Assisted Texture and Environment',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[300px] lg:col-span-2'
|
||||
},
|
||||
{
|
||||
title: 'Open-sourced the Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[200px]'
|
||||
},
|
||||
{
|
||||
title: 'Environment Generation',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: true,
|
||||
gridClass: 'min-h-[200px]'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-12">
|
||||
<h2 class="text-3xl font-bold text-white">Customer Stories</h2>
|
||||
<p class="mt-2 text-smoke-700">
|
||||
See how leading studios use Comfy in production
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Bento grid -->
|
||||
<div
|
||||
class="relative grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
v-for="study in studies"
|
||||
:key="study.title"
|
||||
class="flex flex-col justify-end rounded-2xl border border-brand-yellow/30 p-6"
|
||||
:class="[
|
||||
study.gridClass,
|
||||
study.highlight ? 'bg-brand-yellow' : 'bg-charcoal-600'
|
||||
]"
|
||||
>
|
||||
<h3
|
||||
class="font-semibold"
|
||||
:class="study.highlight ? 'text-black' : 'text-white'"
|
||||
>
|
||||
{{ study.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="mt-2 text-sm"
|
||||
:class="study.highlight ? 'text-black/70' : 'text-smoke-700'"
|
||||
>
|
||||
{{ study.body }}
|
||||
</p>
|
||||
<a
|
||||
href="/case-studies"
|
||||
class="mt-4 text-sm underline"
|
||||
:class="study.highlight ? 'text-black' : 'text-brand-yellow'"
|
||||
>
|
||||
READ CASE STUDY
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
From download to your first AI-generated output in three simple steps
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div v-for="(step, index) in steps" :key="step.number" class="relative">
|
||||
<!-- Connecting line between steps (desktop only) -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<span class="text-6xl font-bold text-brand-yellow/20">
|
||||
{{ step.number }}
|
||||
</span>
|
||||
<h3 class="mt-2 text-xl font-semibold text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
DOWNLOAD COMFY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: 'GET STARTED',
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: 'LEARN MORE',
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-screen items-center overflow-hidden bg-black pt-16"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-12 px-6 md:flex-row md:gap-0"
|
||||
>
|
||||
<!-- Left: C Monogram -->
|
||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||
<div
|
||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
||||
>
|
||||
<!-- Gap on the right side to form "C" shape -->
|
||||
<div
|
||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Text content -->
|
||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
Professional Control of Visual AI
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
v-for="btn in ctaButtons"
|
||||
:key="btn.label"
|
||||
:href="btn.href"
|
||||
class="rounded-full px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
btn.variant === 'solid'
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-brand-yellow text-brand-yellow'
|
||||
"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<!-- Decorative quote mark -->
|
||||
<span class="text-6xl text-brand-yellow opacity-30" aria-hidden="true">
|
||||
«
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
Method, Not Magic
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
<div
|
||||
class="mx-auto mt-8 h-0.5 w-24 bg-brand-yellow opacity-30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,51 +0,0 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder video area -->
|
||||
<div
|
||||
class="mt-12 flex aspect-video items-center justify-center rounded-2xl border border-white/10 bg-charcoal-600"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Play button triangle -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature labels -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-6">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -49,12 +49,12 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
|
||||
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
|
||||
Comfy
|
||||
</a>
|
||||
|
||||
@@ -77,8 +77,8 @@ onUnmounted(() => {
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
|
||||
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-sm font-semibold"
|
||||
>
|
||||
@@ -135,8 +135,8 @@ onUnmounted(() => {
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
|
||||
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
|
||||
>
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
'Nike',
|
||||
'HP',
|
||||
'Autodesk',
|
||||
'Apple',
|
||||
'Ubisoft',
|
||||
'Lucid',
|
||||
'Amazon',
|
||||
'Netflix',
|
||||
'Pixomondo',
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-y border-white/10 bg-black py-16">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Heading -->
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
Trusted by Industry Leaders
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
<div
|
||||
class="mt-10 flex flex-wrap items-center justify-center gap-4 md:gap-6"
|
||||
>
|
||||
<span
|
||||
v-for="company in logos"
|
||||
:key="company"
|
||||
class="rounded-full border border-white/10 px-6 py-2 text-sm text-smoke-700"
|
||||
>
|
||||
{{ company }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics row -->
|
||||
<div
|
||||
class="mt-14 flex flex-col items-center justify-center gap-10 sm:flex-row sm:gap-12"
|
||||
>
|
||||
<div v-for="metric in metrics" :key="metric.label" class="text-center">
|
||||
<p class="text-3xl font-bold text-white">{{ metric.value }}</p>
|
||||
<p class="mt-1 text-sm text-smoke-700">{{ metric.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,92 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
'Comfy has transformed our VFX pipeline. The node-based approach gives us unprecedented control over every step of the generation process.',
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'The level of control over AI generation is unmatched. We can iterate on game assets faster than ever before.',
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'We\u2019ve cut our iteration time by 70%. Comfy workflows let our team produce high-quality creative assets at scale.',
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising'
|
||||
}
|
||||
]
|
||||
|
||||
const filteredTestimonials = computed(() => {
|
||||
if (activeFilter.value === 'All') return testimonials
|
||||
return testimonials.filter((t) => t.industry === activeFilter.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
What Professionals Say
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industries"
|
||||
:key="industry"
|
||||
class="cursor-pointer rounded-full px-4 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
activeFilter === industry
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-white/10 text-smoke-700 hover:border-brand-yellow'
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Testimonial cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<article
|
||||
v-for="testimonial in filteredTestimonials"
|
||||
:key="testimonial.name"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||
>
|
||||
<blockquote class="text-base italic text-white">
|
||||
“{{ testimonial.quote }}”
|
||||
</blockquote>
|
||||
|
||||
<p class="mt-4 text-sm font-semibold text-white">
|
||||
{{ testimonial.name }}
|
||||
</p>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ testimonial.title }}, {{ testimonial.company }}
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,74 +0,0 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="flex flex-col items-center gap-12 lg:flex-row lg:gap-8">
|
||||
<!-- Left placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
Built for Every Creative Industry
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
class="mt-10 flex flex-col items-center gap-4"
|
||||
aria-label="Industry categories"
|
||||
>
|
||||
<button
|
||||
v-for="(category, index) in categories"
|
||||
:key="category"
|
||||
class="transition-colors"
|
||||
:class="
|
||||
index === activeCategory
|
||||
? 'text-2xl text-white'
|
||||
: 'text-xl text-ash-500 hover:text-white/70'
|
||||
"
|
||||
@click="activeCategory = index"
|
||||
>
|
||||
{{ category }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
EXPLORE WORKFLOWS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Right placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const pillars = [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
The Building Blocks of AI Production
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Five powerful capabilities that give you complete control
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<article
|
||||
v-for="pillar in pillars"
|
||||
:key="pillar.title"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
>
|
||||
{{ pillar.icon }}
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-white">
|
||||
{{ pillar.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ pillar.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../components/SiteNav.vue'
|
||||
import HeroSection from '../components/HeroSection.vue'
|
||||
import SocialProofBar from '../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../components/GetStartedSection.vue'
|
||||
import CTASection from '../components/CTASection.vue'
|
||||
import ManifestoSection from '../components/ManifestoSection.vue'
|
||||
import AcademySection from '../components/AcademySection.vue'
|
||||
import SiteFooter from '../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../../components/SiteNav.vue'
|
||||
import HeroSection from '../../components/HeroSection.vue'
|
||||
import SocialProofBar from '../../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../../components/GetStartedSection.vue'
|
||||
import CTASection from '../../components/CTASection.vue'
|
||||
import ManifestoSection from '../../components/ManifestoSection.vue'
|
||||
import AcademySection from '../../components/AcademySection.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 42,
|
||||
"steps": 20,
|
||||
"cfg": 8.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0
|
||||
},
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"2": {
|
||||
"class_type": "NonExistentCustomNode_XYZ_12345",
|
||||
"inputs": {
|
||||
"input1": "test"
|
||||
},
|
||||
"_meta": { "title": "Missing Node" }
|
||||
},
|
||||
"3": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
},
|
||||
"_meta": { "title": "Empty Latent Image" }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import type {
|
||||
APIRequestContext,
|
||||
ExpectMatcherState,
|
||||
Locator,
|
||||
Page
|
||||
} from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { sleep } from './utils/timing'
|
||||
import { comfyExpect } from './utils/customMatchers'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
@@ -15,7 +18,6 @@ import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import { QueuePanel } from './components/QueuePanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
@@ -37,6 +39,7 @@ import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import { assetPath } from './utils/paths'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
@@ -122,7 +125,7 @@ type KeysOfType<T, Match> = {
|
||||
}[keyof T]
|
||||
|
||||
class ConfirmDialog {
|
||||
public readonly root: Locator
|
||||
private readonly root: Locator
|
||||
public readonly delete: Locator
|
||||
public readonly overwrite: Locator
|
||||
public readonly reject: Locator
|
||||
@@ -197,7 +200,6 @@ export class ComfyPage {
|
||||
public readonly featureFlags: FeatureFlagHelper
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
@@ -245,7 +247,6 @@ export class ComfyPage {
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
@@ -359,7 +360,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return sleep(ms)
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,4 +478,49 @@ export const comfyPageFixture = base.extend<{
|
||||
}
|
||||
})
|
||||
|
||||
export { comfyExpect }
|
||||
const makeMatcher = function <T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
type: string
|
||||
) {
|
||||
return async function (
|
||||
this: ExpectMatcherState,
|
||||
node: NodeReference,
|
||||
options?: { timeout?: number; intervals?: number[] }
|
||||
) {
|
||||
const value = await getValue(node)
|
||||
let assertion = expect(
|
||||
value,
|
||||
'Node is ' + (this.isNot ? '' : 'not ') + type
|
||||
)
|
||||
if (this.isNot) {
|
||||
assertion = assertion.not
|
||||
}
|
||||
await expect(async () => {
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
)
|
||||
|
||||
await expect(async () => {
|
||||
expect(isFocused).toBe(!this.isNot)
|
||||
}).toPass(options)
|
||||
|
||||
return {
|
||||
pass: isFocused,
|
||||
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class QueuePanel {
|
||||
readonly overlayToggle: Locator
|
||||
readonly moreOptionsButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
|
||||
this.moreOptionsButton = page.getByLabel(/More options/i).first()
|
||||
}
|
||||
|
||||
async openClearHistoryDialog() {
|
||||
await this.moreOptionsButton.click()
|
||||
|
||||
const clearHistoryAction = this.page.getByTestId(
|
||||
TestIds.queue.clearHistoryAction
|
||||
)
|
||||
await expect(clearHistoryAction).toBeVisible()
|
||||
await clearHistoryAction.click()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
@@ -175,8 +174,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
@@ -185,8 +182,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
// --- Empty state ---
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
@@ -197,169 +192,8 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/** Dismiss all visible toast notifications by clicking their close buttons. */
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
.toHaveCount(0, { timeout: 5000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async switchToImported() {
|
||||
await this.dismissToasts()
|
||||
await this.importedTab.click()
|
||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async switchToGenerated() {
|
||||
await this.dismissToasts()
|
||||
await this.generatedTab.click()
|
||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async openSettingsMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.settingsButton.click()
|
||||
// Wait for popover content to render
|
||||
await this.listViewOption
|
||||
.or(this.gridViewOption)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async waitForAssets(count?: number) {
|
||||
if (count !== undefined) {
|
||||
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
|
||||
} else {
|
||||
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from './BaseDialog'
|
||||
|
||||
export class SignInDialog extends BaseDialog {
|
||||
readonly emailInput: Locator
|
||||
readonly passwordInput: Locator
|
||||
readonly signInButton: Locator
|
||||
readonly forgotPasswordLink: Locator
|
||||
readonly apiKeyButton: Locator
|
||||
readonly termsLink: Locator
|
||||
readonly privacyLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.emailInput = this.root.locator('#comfy-org-sign-in-email')
|
||||
this.passwordInput = this.root.locator('#comfy-org-sign-in-password')
|
||||
this.signInButton = this.root.getByRole('button', { name: 'Sign in' })
|
||||
this.forgotPasswordLink = this.root.getByText('Forgot password?')
|
||||
this.apiKeyButton = this.root.getByRole('button', {
|
||||
name: 'Comfy API Key'
|
||||
})
|
||||
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
|
||||
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.page.evaluate(() => {
|
||||
void window.app!.extensionManager.dialog.showSignInDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
get heading() {
|
||||
return this.root.getByRole('heading').first()
|
||||
}
|
||||
|
||||
get signUpLink() {
|
||||
return this.root.getByText('Sign up', { exact: true })
|
||||
}
|
||||
|
||||
get signInLink() {
|
||||
return this.root.getByText('Sign in', { exact: true })
|
||||
}
|
||||
|
||||
get signUpEmailInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-email')
|
||||
}
|
||||
|
||||
get signUpPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-password')
|
||||
}
|
||||
|
||||
get signUpConfirmPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-confirm-password')
|
||||
}
|
||||
|
||||
get signUpButton() {
|
||||
return this.root.getByRole('button', { name: 'Sign up', exact: true })
|
||||
}
|
||||
|
||||
get apiKeyHeading() {
|
||||
return this.root.getByRole('heading', { name: 'API Key' })
|
||||
}
|
||||
|
||||
get apiKeyInput() {
|
||||
return this.root.locator('#comfy-org-api-key')
|
||||
}
|
||||
|
||||
get backButton() {
|
||||
return this.root.getByRole('button', { name: 'Back' })
|
||||
}
|
||||
|
||||
get dividerText() {
|
||||
return this.root.getByText('Or continue with')
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
# Mock Data Fixtures
|
||||
|
||||
Deterministic mock data for browser (Playwright) tests. Each fixture
|
||||
exports typed objects that conform to generated types from
|
||||
`packages/ingest-types` or Zod schemas in `src/schemas/`.
|
||||
|
||||
## Usage with `page.route()`
|
||||
|
||||
> **Note:** `comfyPageFixture` navigates to the app during `setup()`,
|
||||
> before the test body runs. Routes must be registered before navigation
|
||||
> to intercept initial page-load requests. Set up routes in a custom
|
||||
> fixture or `test.beforeEach` that runs before `comfyPage.setup()`.
|
||||
|
||||
```ts
|
||||
import { createMockNodeDefinitions } from '../fixtures/data/nodeDefinitions'
|
||||
import { mockSystemStats } from '../fixtures/data/systemStats'
|
||||
|
||||
// Extend the base set with test-specific nodes
|
||||
const nodeDefs = createMockNodeDefinitions({
|
||||
MyCustomNode: {
|
||||
/* ... */
|
||||
}
|
||||
})
|
||||
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill({ json: nodeDefs })
|
||||
)
|
||||
|
||||
await page.route('**/api/system_stats', (route) =>
|
||||
route.fulfill({ json: mockSystemStats })
|
||||
)
|
||||
```
|
||||
|
||||
## Adding new fixtures
|
||||
|
||||
1. Locate the generated type in `packages/ingest-types` or Zod schema
|
||||
in `src/schemas/` for the endpoint you need.
|
||||
2. Create a new `.ts` file here that imports and satisfies the
|
||||
corresponding TypeScript type.
|
||||
3. Keep values realistic but stable — avoid dates, random IDs, or
|
||||
values that would cause test flakiness.
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Base node definitions covering the default workflow.
|
||||
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
|
||||
*/
|
||||
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
|
||||
KSampler: {
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
seed: [
|
||||
'INT',
|
||||
{
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 0xfffffffffffff,
|
||||
control_after_generate: true
|
||||
}
|
||||
],
|
||||
steps: ['INT', { default: 20, min: 1, max: 10000 }],
|
||||
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
|
||||
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
|
||||
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}],
|
||||
latent_image: ['LATENT', {}]
|
||||
},
|
||||
optional: {
|
||||
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
description: 'Samples latents using the provided model and conditioning.',
|
||||
category: 'sampling',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CheckpointLoaderSimple: {
|
||||
input: {
|
||||
required: {
|
||||
ckpt_name: [
|
||||
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MODEL', 'CLIP', 'VAE'],
|
||||
output_is_list: [false, false, false],
|
||||
output_name: ['MODEL', 'CLIP', 'VAE'],
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
description: 'Loads a diffusion model checkpoint.',
|
||||
category: 'loaders',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CLIPTextEncode: {
|
||||
input: {
|
||||
required: {
|
||||
text: ['STRING', { multiline: true, dynamicPrompts: true }],
|
||||
clip: ['CLIP', {}]
|
||||
}
|
||||
},
|
||||
output: ['CONDITIONING'],
|
||||
output_is_list: [false],
|
||||
output_name: ['CONDITIONING'],
|
||||
name: 'CLIPTextEncode',
|
||||
display_name: 'CLIP Text Encode (Prompt)',
|
||||
description: 'Encodes a text prompt using a CLIP model.',
|
||||
category: 'conditioning',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
EmptyLatentImage: {
|
||||
input: {
|
||||
required: {
|
||||
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'EmptyLatentImage',
|
||||
display_name: 'Empty Latent Image',
|
||||
description: 'Creates an empty latent image of the specified dimensions.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
VAEDecode: {
|
||||
input: {
|
||||
required: {
|
||||
samples: ['LATENT', {}],
|
||||
vae: ['VAE', {}]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_is_list: [false],
|
||||
output_name: ['IMAGE'],
|
||||
name: 'VAEDecode',
|
||||
display_name: 'VAE Decode',
|
||||
description: 'Decodes latent images back into pixel space.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
SaveImage: {
|
||||
input: {
|
||||
required: {
|
||||
images: ['IMAGE', {}],
|
||||
filename_prefix: ['STRING', { default: 'ComfyUI' }]
|
||||
}
|
||||
},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
name: 'SaveImage',
|
||||
display_name: 'Save Image',
|
||||
description: 'Saves images to the output directory.',
|
||||
category: 'image',
|
||||
output_node: true,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockNodeDefinitions(
|
||||
overrides?: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const base = structuredClone(baseNodeDefinitions)
|
||||
return overrides ? { ...base, ...overrides } : base
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { SystemStatsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
export const mockSystemStats: SystemStatsResponse = {
|
||||
system: {
|
||||
os: 'posix',
|
||||
python_version: '3.11.9 (main, Apr 2 2024, 08:25:04) [GCC 13.2.0]',
|
||||
embedded_python: false,
|
||||
comfyui_version: '0.3.10',
|
||||
pytorch_version: '2.4.0+cu124',
|
||||
argv: ['main.py', '--listen', '0.0.0.0'],
|
||||
ram_total: 67108864000,
|
||||
ram_free: 52428800000
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
name: 'NVIDIA GeForce RTX 4090',
|
||||
type: 'cuda',
|
||||
vram_total: 25769803776,
|
||||
vram_free: 23622320128
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,63 +5,6 @@ import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/j
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now() / 1000
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now() / 1000
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60,
|
||||
execution_start_time: now - i * 60,
|
||||
execution_end_time: now - i * 60 + 5 + i,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
|
||||
@@ -147,16 +147,6 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
|
||||
?.isBusy,
|
||||
undefined,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
|
||||
async getExportedWorkflow(options?: {
|
||||
api?: false
|
||||
|
||||
@@ -98,10 +98,6 @@ export const TestIds = {
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
@@ -130,5 +126,4 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ExpectMatcherState, Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeReference } from './litegraphUtils'
|
||||
|
||||
function makeMatcher<T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
type: string
|
||||
) {
|
||||
return async function (
|
||||
this: ExpectMatcherState,
|
||||
node: NodeReference,
|
||||
options?: { timeout?: number; intervals?: number[] }
|
||||
) {
|
||||
await expect(async () => {
|
||||
const value = await getValue(node)
|
||||
const assertion = this.isNot
|
||||
? expect(value, 'Node is ' + type).not
|
||||
: expect(value, 'Node is not ' + type)
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
await expect
|
||||
.poll(
|
||||
() => locator.evaluate((el) => el === document.activeElement),
|
||||
options
|
||||
)
|
||||
.toBe(!this.isNot)
|
||||
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
)
|
||||
return {
|
||||
pass: isFocused,
|
||||
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export function logMeasurement(
|
||||
if (formatter) return formatter(m)
|
||||
return `${f}=${m[f]}`
|
||||
})
|
||||
|
||||
// oxlint-disable-next-line no-console -- perf reporter intentionally logs
|
||||
console.log(`${label}: ${parts.join(', ')}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,11 +85,13 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the codec widget is at the clipping edge
|
||||
// Scroll to bottom so the codec widget is at the clipping edge.
|
||||
// In the zone layout, overflow-y-auto is on the inner zone div.
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
await widgetList.evaluate((el) => {
|
||||
const scrollable = el.querySelector('[class*="overflow-y"]') ?? el
|
||||
scrollable.scrollTo({ top: scrollable.scrollHeight, behavior: 'instant' })
|
||||
})
|
||||
|
||||
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
|
||||
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
|
||||
@@ -129,11 +131,13 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the image widget is at the clipping edge
|
||||
// Scroll to bottom so the image widget is at the clipping edge.
|
||||
// In the zone layout, overflow-y-auto is on the inner zone div.
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
await widgetList.evaluate((el) => {
|
||||
const scrollable = el.querySelector('[class*="overflow-y"]') ?? el
|
||||
scrollable.scrollTo({ top: scrollable.scrollHeight, behavior: 'instant' })
|
||||
})
|
||||
|
||||
// Click the FormDropdown trigger button for the image widget.
|
||||
// The button emits 'select-click' which toggles the Popover.
|
||||
|
||||
@@ -64,38 +64,21 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
|
||||
test('Rename persists in app mode after save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
// Rename via builder inputs step (app mode view has no inline rename)
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.renameInputViaMenu('seed', 'App Mode Seed')
|
||||
|
||||
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.select.renameWidget(menu, 'Preview Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.footer.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-preview`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from app mode', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Enter app mode from builder
|
||||
// Exit builder and enter app mode
|
||||
await appMode.footer.exitBuilder()
|
||||
await appMode.toggleAppMode()
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const menu = appMode.getAppModeWidgetMenu('seed')
|
||||
await appMode.select.renameWidget(menu, 'App Mode Seed')
|
||||
|
||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||
|
||||
// Verify persistence after save/reload
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.queuePanel.overlayToggle.click()
|
||||
})
|
||||
|
||||
test('Dialog opens from queue panel history actions menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Dialog shows confirmation message with title, description, and assets note', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'All the finished or failed jobs below will be removed from this Job queue panel.'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel button closes dialog without clearing history', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
let clearCalled = false
|
||||
await comfyPage.page.route('**/api/history', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
clearCalled = true
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
})
|
||||
|
||||
test('Close (X) button closes dialog without clearing history', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
let clearCalled = false
|
||||
await comfyPage.page.route('**/api/history', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
clearCalled = true
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await dialog.getByLabel('Close').click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
})
|
||||
|
||||
test('Confirm clears queue history and closes dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const clearPromise = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().includes('/api/history') && req.method() === 'POST'
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Clear' }).click()
|
||||
|
||||
const request = await clearPromise
|
||||
expect(request.postDataJSON()).toEqual({ clear: true })
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await expect(clearButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { SignInDialog } from '../../fixtures/components/SignInDialog'
|
||||
|
||||
test.describe('Sign In dialog', { tag: '@ui' }, () => {
|
||||
let dialog: SignInDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
dialog = new SignInDialog(comfyPage.page)
|
||||
await dialog.open()
|
||||
})
|
||||
|
||||
test('Should open and show the sign-in form by default', async () => {
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.emailInput).toBeVisible()
|
||||
await expect(dialog.passwordInput).toBeVisible()
|
||||
await expect(dialog.signInButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should toggle from sign-in to sign-up form', async () => {
|
||||
await dialog.signUpLink.click()
|
||||
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Create an account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.signUpEmailInput).toBeVisible()
|
||||
await expect(dialog.signUpPasswordInput).toBeVisible()
|
||||
await expect(dialog.signUpConfirmPasswordInput).toBeVisible()
|
||||
await expect(dialog.signUpButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should toggle back from sign-up to sign-in form', async () => {
|
||||
await dialog.signUpLink.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Create an account' })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.signInLink.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.emailInput).toBeVisible()
|
||||
await expect(dialog.passwordInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should navigate to the API Key form and back', async () => {
|
||||
await dialog.apiKeyButton.click()
|
||||
|
||||
await expect(dialog.apiKeyHeading).toBeVisible()
|
||||
await expect(dialog.apiKeyInput).toBeVisible()
|
||||
|
||||
await dialog.backButton.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display Terms of Service and Privacy Policy links', async () => {
|
||||
await expect(dialog.termsLink).toBeVisible()
|
||||
await expect(dialog.termsLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.comfy.org/terms-of-service'
|
||||
)
|
||||
|
||||
await expect(dialog.privacyLink).toBeVisible()
|
||||
await expect(dialog.privacyLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.comfy.org/privacy'
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display the "Or continue with" divider and API key button', async () => {
|
||||
await expect(dialog.dividerText).toBeVisible()
|
||||
await expect(dialog.apiKeyButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show forgot password link on sign-in form', async () => {
|
||||
await expect(dialog.forgotPasswordLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should close dialog via close button', async () => {
|
||||
await dialog.close()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should close dialog via Escape key', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,3 +1,5 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
@@ -9,10 +11,58 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function enterAppMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
// LinearControls requires hasOutputs to be true. Serialize the current
|
||||
// graph, inject linearData with output node IDs, then reload so the
|
||||
// appModeStore picks up the outputs via its activeWorkflow watcher.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
// Serialize, inject linearData, and reload to sync stores
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: [], outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Toggle to app mode via the command which sets canvasStore.linearMode
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function enterGraphMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -20,29 +70,29 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
test('Run controls visible in app mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await enterGraphMode(comfyPage)
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
@@ -51,7 +101,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Canvas not visible in app mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Properties Panel E2E Tests
|
||||
|
||||
Tests for the right-side properties panel (`RightSidePanel.vue`).
|
||||
|
||||
## Structure
|
||||
|
||||
| File | Coverage |
|
||||
| --------------------------------- | ----------------------------------------------------------- |
|
||||
| `openClose.spec.ts` | Panel open/close via actionbar and close button |
|
||||
| `workflowOverview.spec.ts` | No-selection state: tabs, nodes list, global settings |
|
||||
| `nodeSelection.spec.ts` | Single/multi-node selection, selection changes, tab labels |
|
||||
| `titleEditing.spec.ts` | Node title editing via pencil icon |
|
||||
| `searchFiltering.spec.ts` | Widget search and clear |
|
||||
| `nodeSettings.spec.ts` | Settings tab: node state, color, pinned (requires VueNodes) |
|
||||
| `infoTab.spec.ts` | Node help content |
|
||||
| `errorsTab.spec.ts` | Errors tab visibility |
|
||||
| `propertiesPanelPosition.spec.ts` | Panel position relative to sidebar |
|
||||
|
||||
## Shared Helper
|
||||
|
||||
`PropertiesPanelHelper.ts` — Encapsulates panel locators and actions. Instantiated in `beforeEach`:
|
||||
|
||||
```typescript
|
||||
let panel: PropertiesPanelHelper
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Tests requiring VueNodes rendering enable it in `beforeEach` via `comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)` and call `comfyPage.vueNodes.waitForNodes()`.
|
||||
- Verify node state changes via user-facing indicators (text labels like "Bypassed"/"Muted", pin indicator test IDs) rather than internal properties.
|
||||
- Color changes are verified via `page.evaluate` accessing node properties, per the guidance in `docs/guidance/playwright.md`.
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
export class PropertiesPanelHelper {
|
||||
readonly root: Locator
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.closeButton = this.root.locator('button[aria-pressed]')
|
||||
}
|
||||
|
||||
get tabs(): Locator {
|
||||
return this.root.locator('nav button')
|
||||
}
|
||||
|
||||
getTab(label: string): Locator {
|
||||
return this.root.locator('nav button', { hasText: label })
|
||||
}
|
||||
|
||||
get titleEditIcon(): Locator {
|
||||
return this.panelTitle.locator('i[class*="lucide--pencil"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.root.getByTestId(TestIds.node.titleInput)
|
||||
}
|
||||
|
||||
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
|
||||
return this.root.locator('button', { hasText: state })
|
||||
}
|
||||
|
||||
getColorSwatch(colorName: string): Locator {
|
||||
return this.root.locator(`[data-testid="${colorName}"]`)
|
||||
}
|
||||
|
||||
get pinnedSwitch(): Locator {
|
||||
return this.root.locator('[data-p-checked]').first()
|
||||
}
|
||||
|
||||
get subgraphEditButton(): Locator {
|
||||
return this.root.locator('button:has(i[class*="lucide--settings-2"])')
|
||||
}
|
||||
|
||||
get contentArea(): Locator {
|
||||
return this.root.locator('.scrollbar-thin')
|
||||
}
|
||||
|
||||
get errorsTabIcon(): Locator {
|
||||
return this.root.locator('nav i[class*="lucide--octagon-alert"]')
|
||||
}
|
||||
|
||||
get viewAllSettingsButton(): Locator {
|
||||
return this.root.getByRole('button', { name: /view all settings/i })
|
||||
}
|
||||
|
||||
get collapseToggleButton(): Locator {
|
||||
return this.root.locator(
|
||||
'button:has(i[class*="lucide--chevrons-down-up"]), button:has(i[class*="lucide--chevrons-up-down"])'
|
||||
)
|
||||
}
|
||||
|
||||
async open(actionbar: Locator): Promise<void> {
|
||||
if (!(await this.root.isVisible())) {
|
||||
await actionbar.click()
|
||||
await expect(this.root).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (await this.root.isVisible()) {
|
||||
await this.closeButton.click()
|
||||
await expect(this.root).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async switchToTab(label: string): Promise<void> {
|
||||
await this.getTab(label).click()
|
||||
}
|
||||
|
||||
async editTitle(newTitle: string): Promise<void> {
|
||||
await this.titleEditIcon.click()
|
||||
await this.titleInput.fill(newTitle)
|
||||
await this.titleInput.press('Enter')
|
||||
}
|
||||
|
||||
async searchWidgets(query: string): Promise<void> {
|
||||
await this.searchBox.fill(query)
|
||||
}
|
||||
|
||||
async clearSearch(): Promise<void> {
|
||||
await this.searchBox.fill('')
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Errors tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(panel.errorsTabIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Errors tab when errors are disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.errorsTabIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Info tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await panel.switchToTab('Info')
|
||||
})
|
||||
|
||||
test('should show node help content', async () => {
|
||||
await expect(panel.contentArea).toBeVisible()
|
||||
await expect(
|
||||
panel.contentArea.getByRole('heading', { name: 'Inputs' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Node selection', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
})
|
||||
|
||||
test.describe('Single node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('should show node title in panel header', async () => {
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should show Parameters, Info, and Settings tabs', async () => {
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await expect(panel.getTab('Info')).toBeVisible()
|
||||
await expect(panel.getTab('Settings')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Nodes tab for single node', async () => {
|
||||
await expect(panel.getTab('Nodes')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display node widgets in Parameters tab', async () => {
|
||||
await expect(panel.contentArea.getByText('seed')).toBeVisible()
|
||||
await expect(panel.contentArea.getByText('steps')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-node', () => {
|
||||
test('should show item count in title', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.panelTitle).toContainText('3 items selected')
|
||||
})
|
||||
|
||||
test('should list all selected nodes in Parameters tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
panel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should not show Info tab for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.getTab('Info')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Selection changes', () => {
|
||||
test('should update from no selection to node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should update from node selection back to no selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('should update between different single node selections', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nodeOps.selectNodes(['Empty Latent Image'])
|
||||
await expect(panel.panelTitle).toContainText('Empty Latent Image')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tab labels', () => {
|
||||
test('should show "Parameters" tab for single node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "Nodes" tab label for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.getTab('Nodes')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Node settings', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await panel.switchToTab('Settings')
|
||||
})
|
||||
|
||||
test.describe('Node state', () => {
|
||||
test('should show Normal, Bypass, and Mute state buttons', async () => {
|
||||
await expect(panel.getNodeStateButton('Normal')).toBeVisible()
|
||||
await expect(panel.getNodeStateButton('Bypass')).toBeVisible()
|
||||
await expect(panel.getNodeStateButton('Mute')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should set node to Bypass mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Bypass').click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should set node to Mute mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Mute').click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Muted')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should restore node to Normal mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Bypass').click()
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
|
||||
|
||||
await panel.getNodeStateButton('Normal').click()
|
||||
await expect(nodeLocator.getByText('Bypassed')).not.toBeVisible()
|
||||
await expect(nodeLocator.getByText('Muted')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node color', () => {
|
||||
test('should display color swatches', async () => {
|
||||
await expect(panel.getColorSwatch('noColor')).toBeVisible()
|
||||
await expect(panel.getColorSwatch('red')).toBeVisible()
|
||||
await expect(panel.getColorSwatch('blue')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should apply color to node', async ({ comfyPage }) => {
|
||||
await panel.getColorSwatch('red').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color != null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('should remove color with noColor swatch', async ({ comfyPage }) => {
|
||||
await panel.getColorSwatch('red').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color != null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await panel.getColorSwatch('noColor').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color
|
||||
})
|
||||
)
|
||||
.toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pinned state', () => {
|
||||
test('should display pinned toggle', async () => {
|
||||
await expect(panel.pinnedSwitch).toBeVisible()
|
||||
})
|
||||
|
||||
test('should toggle pinned state', async ({ comfyPage }) => {
|
||||
await panel.pinnedSwitch.click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should unpin previously pinned node', async ({ comfyPage }) => {
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await panel.pinnedSwitch.click()
|
||||
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
|
||||
|
||||
await panel.pinnedSwitch.click()
|
||||
await expect(
|
||||
nodeLocator.getByTestId('node-pin-indicator')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Open and close', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should open via actionbar toggle button', async ({ comfyPage }) => {
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close via panel close button', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await panel.closeButton.click()
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should close via close button after opening', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await panel.close()
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
36
browser_tests/tests/propertiesPanel/propertiesPanel.spec.ts
Normal file
36
browser_tests/tests/propertiesPanel/propertiesPanel.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Properties panel', () => {
|
||||
test('opens and updates title based on selection', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
|
||||
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
|
||||
await propertiesPanel.searchBox.fill('seed')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(0)
|
||||
|
||||
await propertiesPanel.searchBox.fill('')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Search filtering', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
})
|
||||
|
||||
test('should filter nodes by search query', async () => {
|
||||
await panel.searchWidgets('seed')
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('should restore all nodes when search is cleared', async () => {
|
||||
await panel.searchWidgets('seed')
|
||||
await panel.clearSearch()
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
test('should show empty state for no matches', async () => {
|
||||
await panel.searchWidgets('nonexistent_widget_xyz')
|
||||
await expect(
|
||||
panel.contentArea.getByText(/no .* match|no results|no items/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Title editing', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('should show pencil icon for editable title', async () => {
|
||||
await expect(panel.titleEditIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('should enter edit mode on pencil click', async () => {
|
||||
await panel.titleEditIcon.click()
|
||||
await expect(panel.titleInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('should update node title on edit', async () => {
|
||||
const newTitle = 'My Custom Sampler'
|
||||
await panel.editTitle(newTitle)
|
||||
await expect(panel.panelTitle).toContainText(newTitle)
|
||||
})
|
||||
|
||||
test('should not show pencil icon for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.titleEditIcon).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show pencil icon when nothing is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
await expect(panel.titleEditIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Workflow Overview', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "Workflow Overview" title when nothing is selected', async () => {
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('should show Parameters, Nodes, and Settings tabs', async () => {
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await expect(panel.getTab('Nodes')).toBeVisible()
|
||||
await expect(panel.getTab('Settings')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Info tab when nothing is selected', async () => {
|
||||
await expect(panel.getTab('Info')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch to Nodes tab and list all workflow nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await panel.switchToTab('Nodes')
|
||||
const nodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCount).toBeGreaterThan(0)
|
||||
await expect(panel.contentArea.locator('text=KSampler')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should filter nodes by search in Nodes tab', async () => {
|
||||
await panel.switchToTab('Nodes')
|
||||
await panel.searchWidgets('KSampler')
|
||||
await expect(panel.contentArea.getByText('KSampler').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch to Settings tab and show global settings', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.viewAllSettingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "View all settings" button', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.viewAllSettingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Nodes section with toggles', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(
|
||||
panel.contentArea.getByRole('button', { name: 'NODES' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Canvas section with grid settings', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.contentArea.getByText('Canvas')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Connection Links section', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.contentArea.getByText('Connection Links')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,72 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '../../fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2003,
|
||||
preview_output: {
|
||||
filename: 'portrait.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3020,
|
||||
preview_output: {
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
]
|
||||
|
||||
const SAMPLE_IMPORTED_FILES = [
|
||||
'reference_photo.png',
|
||||
'background.jpg',
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - empty states', () => {
|
||||
test.describe('Assets sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
@@ -76,587 +12,19 @@ test.describe('Assets sidebar - empty states', () => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
test('Shows empty-state copy for generated and imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
await tab.importedTab.click()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Tab navigation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - tab navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Generated tab is active by default', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('Can switch between Generated and Imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Asset display - grid view
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - grid view display', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays generated assets as cards in grid view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.waitForAssets()
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Displays imported files when switching to Imported tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. View mode toggle (grid <-> list)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Can switch back to grid view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Search functionality
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Search input is visible', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filtering assets by search query reduces displayed count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect(async () => {
|
||||
const filteredCount = await tab.assetCards.count()
|
||||
expect(filteredCount).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Clearing search restores all assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(async () => {
|
||||
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_file_xyz')
|
||||
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Asset selection
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Selection shows footer with count and actions', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Deselect all clears selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Context menu
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - context menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Context menu contains Download action for output asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Download')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Inspect action for image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Delete action for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Delete')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Copy job ID for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains workflow actions for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await expect(
|
||||
tab.contextMenuItem('Open as workflow in new tab')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bulk context menu shows when multiple assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: click first, then Ctrl/Cmd+click second
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Right-click on a selected card (retry to let grid layout settle)
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(async () => {
|
||||
await cards.first().click({ button: 'right' })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
}).toPass({ intervals: [300], timeout: 5000 })
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 8. Bulk actions (footer)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - bulk actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Footer shows download button when assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Footer shows delete button when output assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select two assets
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
const text = await tab.selectionCountButton.textContent()
|
||||
expect(text).toMatch(/Assets Selected: \d+/)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Initially loads a batch of assets with has_more pagination', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Create a large set of jobs to trigger pagination
|
||||
const manyJobs = createMockJobs(30)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Should load at least the first batch
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 10. Settings menu visibility
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.listViewOption).toBeVisible()
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflow Persistence', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Rapid tab switching does not desync workflow and graph state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9533 — desynced workflow/graph state during loading'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('rapid-A')
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.menu.topbar.saveWorkflow('rapid-B')
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await tab.switchToWorkflow('rapid-A')
|
||||
await tab.switchToWorkflow('rapid-B')
|
||||
}
|
||||
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
|
||||
await tab.switchToWorkflow('rapid-A')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountA)
|
||||
})
|
||||
|
||||
test('Node outputs are preserved when switching workflow tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #9380 — ChangeTracker.store() did not save nodeOutputs, losing preview images on tab switch'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
|
||||
|
||||
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
expect(firstNode).toBeTruthy()
|
||||
const nodeId = firstNode!.id
|
||||
|
||||
// Simulate node outputs as if execution completed
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
const outputStore = window.app!.nodeOutputs
|
||||
if (outputStore) {
|
||||
outputStore[id] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
}, String(nodeId))
|
||||
|
||||
// Trigger changeTracker to capture current state including outputs
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputsBefore = await comfyPage.page.evaluate((id) => {
|
||||
return window.app!.nodeOutputs?.[id]
|
||||
}, String(nodeId))
|
||||
expect(outputsBefore).toBeTruthy()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('outputs-test')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputsAfter = await comfyPage.page.evaluate((id) => {
|
||||
return window.app!.nodeOutputs?.[id]
|
||||
}, String(nodeId))
|
||||
expect(outputsAfter).toBeTruthy()
|
||||
expect(outputsAfter?.images).toBeDefined()
|
||||
})
|
||||
|
||||
test('Loading a new workflow cleanly replaces the previous graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Commit 44bb6f13 — canvas graph not reset before workflow load'
|
||||
})
|
||||
|
||||
const defaultNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(defaultNodeCount).toBeGreaterThan(1)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(1)
|
||||
|
||||
const nodes = await comfyPage.nodeOps.getNodes()
|
||||
expect(nodes[0].type).toBe('KSampler')
|
||||
})
|
||||
|
||||
test('Widget values on nodes are preserved across workflow tab switches', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #7648 — component widget state lost on graph change'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('widget-state-test')
|
||||
|
||||
// Read widget values via page.evaluate — these are internal LiteGraph
|
||||
// state not exposed through DOM
|
||||
const widgetValuesBefore = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph.nodes
|
||||
const results: Record<string, unknown[]> = {}
|
||||
for (const node of nodes) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
results[node.id] = node.widgets.map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
expect(Object.keys(widgetValuesBefore).length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('widget-state-test')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValuesAfter = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph.nodes
|
||||
const results: Record<string, unknown[]> = {}
|
||||
for (const node of nodes) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
results[node.id] = node.widgets.map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
expect(widgetValuesAfter).toEqual(widgetValuesBefore)
|
||||
})
|
||||
|
||||
test('API format workflow with missing node types partially loads', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9694 — loadApiJson early-returned on missing node types'
|
||||
})
|
||||
|
||||
const fixturePath = comfyPage.assetPath(
|
||||
'nodes/api_workflow_with_missing_nodes.json'
|
||||
)
|
||||
const apiWorkflow = JSON.parse(readFileSync(fixturePath, 'utf-8'))
|
||||
|
||||
await comfyPage.page.evaluate(async (workflow) => {
|
||||
await window.app!.loadApiJson(workflow, 'test-api-workflow.json')
|
||||
}, apiWorkflow)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Known nodes (KSampler, EmptyLatentImage) should load; unknown node skipped
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
|
||||
const nodeTypes = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.nodes.map((n: { type: string }) => n.type)
|
||||
})
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
expect(nodeTypes).toContain('EmptyLatentImage')
|
||||
expect(nodeTypes).not.toContain('NonExistentCustomNode_XYZ_12345')
|
||||
})
|
||||
|
||||
test('Canvas has auxclick handler to prevent middle-click paste', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #8259 — middle-click paste duplicates entire workflow on Linux'
|
||||
})
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
button: 'middle',
|
||||
position: { x: 100, y: 100 }
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountAfter = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountAfter).toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Exported workflow does not contain transient blob: URLs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #8715 — transient image URLs leaked into workflow serialization'
|
||||
})
|
||||
|
||||
const exportedWorkflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
for (const node of exportedWorkflow.nodes) {
|
||||
if (node.widgets_values && Array.isArray(node.widgets_values)) {
|
||||
for (const value of node.widgets_values) {
|
||||
if (typeof value === 'string') {
|
||||
expect(value).not.toMatch(/^blob:/)
|
||||
expect(value).not.toMatch(/^https?:\/\/.*\/api\/view/)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Changing locale does not break workflow operations', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #8963 — template workflows not reloaded on locale change'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.menu.topbar.saveWorkflow('locale-test')
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
|
||||
await expect.poll(() => tab.getActiveWorkflowName()).toBe('locale-test')
|
||||
})
|
||||
|
||||
test('Node links survive save/load/switch cycles', async ({ comfyPage }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9533 — node links must survive serialization roundtrips'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
// Link count requires internal graph state — not exposed via DOM
|
||||
const linkCountBefore = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.links
|
||||
? Object.keys(window.app!.graph.links).length
|
||||
: 0
|
||||
})
|
||||
expect(linkCountBefore).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('links-test')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('links-test')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
const linkCountAfter = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.links
|
||||
? Object.keys(window.app!.graph.links).length
|
||||
: 0
|
||||
})
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Commits 91f197d9d + a1b7e57bc — splitter panel size drift on reload'
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'Comfy.Splitter.MainSplitter',
|
||||
JSON.stringify([30, 70])
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const storedSizes = await comfyPage.page.evaluate(() => {
|
||||
const raw = localStorage.getItem('Comfy.Splitter.MainSplitter')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
|
||||
expect(storedSizes).toBeTruthy()
|
||||
expect(Array.isArray(storedSizes)).toBe(true)
|
||||
for (const size of storedSizes as number[]) {
|
||||
expect(typeof size).toBe('number')
|
||||
expect(size).toBeGreaterThanOrEqual(0)
|
||||
expect(size).not.toBeNaN()
|
||||
}
|
||||
const total = (storedSizes as number[]).reduce(
|
||||
(a: number, b: number) => a + b,
|
||||
0
|
||||
)
|
||||
expect(total).toBeGreaterThan(90)
|
||||
expect(total).toBeLessThanOrEqual(101)
|
||||
})
|
||||
})
|
||||
@@ -102,7 +102,8 @@ export default defineConfig([
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts'
|
||||
'vite.types.config.mts',
|
||||
'apps/website/astro.config.ts'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const config: KnipConfig = {
|
||||
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreBinaries: ['python3', 'gh'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.10",
|
||||
"version": "1.43.9",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -45,7 +45,6 @@
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
@@ -58,7 +57,6 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/ingest-types": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -410,9 +410,6 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/ingest-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/ingest-types
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* PrimeVue tooltip override — white on black, consistent everywhere. */
|
||||
.p-tooltip .p-tooltip-text {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border: 1px solid #333;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
max-width: 18.75rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.p-tooltip-top .p-tooltip-arrow {
|
||||
border-top-color: #000;
|
||||
}
|
||||
|
||||
.p-tooltip-bottom .p-tooltip-arrow {
|
||||
border-bottom-color: #000;
|
||||
}
|
||||
|
||||
.p-tooltip-left .p-tooltip-arrow {
|
||||
border-left-color: #000;
|
||||
}
|
||||
|
||||
.p-tooltip-right .p-tooltip-arrow {
|
||||
border-right-color: #000;
|
||||
}
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
|
||||
@@ -46,7 +46,7 @@ function showApps() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex w-fit flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildDropIndicator } from '@/components/builder/dropIndicatorUtil'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
@@ -12,19 +11,14 @@ 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'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
@@ -34,9 +28,18 @@ interface WidgetEntry {
|
||||
action: { widget: IBaseWidget; node: LGraphNode }
|
||||
}
|
||||
|
||||
const { mobile = false, builderMode = false } = defineProps<{
|
||||
const {
|
||||
mobile = false,
|
||||
builderMode = false,
|
||||
zoneId,
|
||||
itemKeys
|
||||
} = defineProps<{
|
||||
mobile?: boolean
|
||||
builderMode?: boolean
|
||||
/** When set, only show inputs assigned to this zone. */
|
||||
zoneId?: string
|
||||
/** When set, only render these specific input keys in the given order. */
|
||||
itemKeys?: string[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -47,13 +50,61 @@ const maskEditor = useMaskEditor()
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph?.nodes ?? [])
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
() => app.rootGraph?.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
() => (graphNodes.value = app.rootGraph?.nodes ?? [])
|
||||
)
|
||||
|
||||
const groupedItemKeys = computed(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const group of appModeStore.inputGroups) {
|
||||
for (const item of group.items) keys.add(item.key)
|
||||
}
|
||||
return keys
|
||||
})
|
||||
|
||||
function resolveInputEntry(
|
||||
nodeId: string | number,
|
||||
widgetName: string,
|
||||
nodeDataByNode: Map<LGraphNode, ReturnType<typeof nodeToNodeData>>
|
||||
): WidgetEntry | null {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return null
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
}
|
||||
const fullNodeData = nodeDataByNode.get(node)!
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
})
|
||||
if (!matchingWidget) return null
|
||||
|
||||
matchingWidget.slotMetadata = undefined
|
||||
matchingWidget.nodeId = String(node.id)
|
||||
|
||||
return {
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
widgets: [matchingWidget]
|
||||
},
|
||||
action: { widget, node }
|
||||
}
|
||||
}
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
const nodeDataByNode = new Map<
|
||||
@@ -61,70 +112,42 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
if (itemKeys) {
|
||||
const results: WidgetEntry[] = []
|
||||
for (const key of itemKeys) {
|
||||
if (!key.startsWith('input:')) continue
|
||||
const parts = key.split(':')
|
||||
const nodeId = parts[1]
|
||||
const widgetName = parts.slice(2).join(':')
|
||||
const entry = resolveInputEntry(nodeId, widgetName, nodeDataByNode)
|
||||
if (entry) results.push(entry)
|
||||
}
|
||||
const fullNodeData = nodeDataByNode.get(node)!
|
||||
return results
|
||||
}
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
const inputs = zoneId
|
||||
? appModeStore.selectedInputs.filter(
|
||||
([nId, wName]) => appModeStore.getZone(nId, wName) === zoneId
|
||||
)
|
||||
: appModeStore.selectedInputs
|
||||
|
||||
return inputs
|
||||
.filter(
|
||||
([nodeId, widgetName]) =>
|
||||
!groupedItemKeys.value.has(`input:${nodeId}:${widgetName}`)
|
||||
)
|
||||
.flatMap(([nodeId, widgetName]) => {
|
||||
const entry = resolveInputEntry(nodeId, widgetName, nodeDataByNode)
|
||||
return entry ? [entry] : []
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
matchingWidget.slotMetadata = undefined
|
||||
matchingWidget.nodeId = String(node.id)
|
||||
|
||||
return [
|
||||
{
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
widgets: [matchingWidget]
|
||||
},
|
||||
action: { widget, node }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
function getDropIndicator(node: LGraphNode) {
|
||||
if (node.type !== 'LoadImage') return undefined
|
||||
|
||||
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
|
||||
|
||||
const { filename, subfolder, type } = stringValue
|
||||
? parseImageWidgetValue(stringValue)
|
||||
: { filename: '', subfolder: '', type: 'input' }
|
||||
|
||||
const buildImageUrl = () => {
|
||||
if (!filename) return undefined
|
||||
const params = new URLSearchParams({ filename, subfolder, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
}
|
||||
|
||||
const imageUrl = buildImageUrl()
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl,
|
||||
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined),
|
||||
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
|
||||
}
|
||||
return buildDropIndicator(node, {
|
||||
imageLabel: mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
videoLabel: mobile ? undefined : t('linearMode.dragAndDropVideo'),
|
||||
openMaskEditor: maskEditor.openMaskEditor
|
||||
})
|
||||
}
|
||||
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
@@ -139,21 +162,6 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragDrop(e: DragEvent) {
|
||||
for (const { nodeData } of mappedSelections.value) {
|
||||
if (!nodeData?.onDragOver?.(e)) continue
|
||||
|
||||
const rawResult = nodeData?.onDragDrop?.(e)
|
||||
if (rawResult === false) continue
|
||||
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if ((await rawResult) === true) return
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
@@ -174,12 +182,13 @@ defineExpose({ handleDragDrop })
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'mt-1.5 flex min-h-8 items-center gap-1 px-3',
|
||||
'flex min-h-8 items-center gap-1 px-3 pt-1.5',
|
||||
builderMode && 'drag-handle'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-tooltip.top="action.widget.label || action.widget.name"
|
||||
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
|
||||
>
|
||||
{{ action.widget.label || action.widget.name }}
|
||||
@@ -191,32 +200,6 @@ defineExpose({ handleDragDrop })
|
||||
{{ action.node.title }}
|
||||
</span>
|
||||
<div v-else class="flex-1" />
|
||||
<Popover
|
||||
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
|
||||
:entries="[
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'icon-[lucide--pencil]',
|
||||
command: () => promptRenameWidget(action.widget, action.node, t)
|
||||
},
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () =>
|
||||
appModeStore.removeSelectedInput(action.widget, action.node)
|
||||
}
|
||||
]"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
data-testid="widget-actions-menu"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div
|
||||
:class="builderMode && 'pointer-events-none'"
|
||||
@@ -239,5 +222,14 @@ defineExpose({ handleDragDrop })
|
||||
/>
|
||||
</DropZone>
|
||||
</div>
|
||||
<div
|
||||
v-if="!builderMode"
|
||||
:class="
|
||||
cn(
|
||||
'mx-3 border-b border-border-subtle/30',
|
||||
key === mappedSelections.at(-1)?.key && 'hidden'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
75
src/components/builder/BuilderConfirmDialog.vue
Normal file
75
src/components/builder/BuilderConfirmDialog.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
confirmVariant = 'secondary'
|
||||
} = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
confirmVariant?: 'secondary' | 'destructive'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-1800 bg-black/50" />
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 z-1800 w-80 -translate-1/2 rounded-xl border border-border-subtle bg-base-background p-5 shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle class="text-sm font-medium">
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
class="flex size-6 items-center justify-center rounded-sm border-0 bg-transparent text-muted-foreground outline-none hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 border-t border-border-subtle pt-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ description }}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-end gap-3">
|
||||
<DialogClose as-child>
|
||||
<Button variant="muted-textonly" size="sm">
|
||||
{{ t('g.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button :variant="confirmVariant" size="lg" @click="handleConfirm">
|
||||
{{ confirmLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -1,10 +1,21 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
|
||||
ref="toolbarEl"
|
||||
:class="
|
||||
cn(
|
||||
'fixed z-1000 origin-top-left select-none',
|
||||
isDragging && 'cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
transform: `scale(${toolbarScale})`
|
||||
}"
|
||||
:aria-label="t('builderToolbar.label')"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
class="group inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<template v-for="(step, index) in steps" :key="step.id">
|
||||
<button
|
||||
@@ -23,21 +34,65 @@
|
||||
<StepLabel :step />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="mx-1 h-px w-4 bg-border-default"
|
||||
role="separator"
|
||||
/>
|
||||
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
|
||||
</template>
|
||||
|
||||
<!-- Default view -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'bg-transparent opacity-30')">
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</ConnectOutputPopover>
|
||||
<button
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === 'builder:arrange'
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'bg-transparent hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="navigateToStep('builder:arrange')"
|
||||
>
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
class="ml-1 flex cursor-se-resize items-center opacity-0 transition-opacity group-hover:opacity-40"
|
||||
@pointerdown.stop="startResize"
|
||||
>
|
||||
<i class="icon-[lucide--grip] size-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDraggable } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
@@ -45,8 +100,49 @@ import type { BuilderStepId } from './useBuilderSteps'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { activeStep, navigateToStep } = useBuilderSteps()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
|
||||
|
||||
// ── Draggable positioning ──────────────────────────────────────────
|
||||
const toolbarEl = ref<HTMLElement | null>(null)
|
||||
const toolbarScale = ref(1)
|
||||
|
||||
const { position, isDragging } = useDraggable(toolbarEl, {
|
||||
initialValue: { x: 0, y: 50 },
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (toolbarEl.value) {
|
||||
const rect = toolbarEl.value.getBoundingClientRect()
|
||||
position.value = {
|
||||
x: Math.round((window.innerWidth - rect.width) / 2),
|
||||
y: 50
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Corner resize (scale) ──────────────────────────────────────────
|
||||
function startResize(e: PointerEvent) {
|
||||
const startX = e.clientX
|
||||
const startScale = toolbarScale.value
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
|
||||
function onMove(ev: PointerEvent) {
|
||||
const delta = ev.clientX - startX
|
||||
toolbarScale.value = Math.max(0.5, Math.min(1.2, startScale + delta / 400))
|
||||
}
|
||||
function onUp() {
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
// ── Step definitions ───────────────────────────────────────────────
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
|
||||
@@ -71,5 +167,11 @@ const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
icon: 'icon-[lucide--layout-panel-left]'
|
||||
}
|
||||
|
||||
const defaultViewStep: BuilderToolbarStep<string> = {
|
||||
id: 'setDefaultView',
|
||||
title: t('builderToolbar.defaultView'),
|
||||
subtitle: t('builderToolbar.defaultViewDescription'),
|
||||
icon: 'icon-[lucide--eye]'
|
||||
}
|
||||
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
|
||||
</script>
|
||||
|
||||
366
src/components/builder/InputGroupAccordion.vue
Normal file
366
src/components/builder/InputGroupAccordion.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle
|
||||
} from 'reka-ui'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
|
||||
import {
|
||||
vGroupDropTarget,
|
||||
vGroupItemDraggable,
|
||||
vGroupItemReorderTarget
|
||||
} from '@/components/builder/useGroupDrop'
|
||||
import {
|
||||
autoGroupName,
|
||||
groupedByPair,
|
||||
resolveGroupItems
|
||||
} from '@/components/builder/useInputGroups'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
group,
|
||||
zoneId,
|
||||
builderMode = false,
|
||||
position = 'middle'
|
||||
} = defineProps<{
|
||||
group: InputGroup
|
||||
zoneId: string
|
||||
builderMode?: boolean
|
||||
position?: 'first' | 'middle' | 'last' | 'only'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const isOpen = ref(builderMode)
|
||||
const isRenaming = ref(false)
|
||||
const showUngroupDialog = ref(false)
|
||||
const renameValue = ref('')
|
||||
let renameStartedAt = 0
|
||||
|
||||
const displayName = computed(() => group.name ?? autoGroupName(group))
|
||||
const resolvedItems = computed(() => resolveGroupItems(group))
|
||||
const rows = computed(() => groupedByPair(resolvedItems.value))
|
||||
|
||||
function startRename() {
|
||||
if (!builderMode) return
|
||||
renameValue.value = displayName.value
|
||||
renameStartedAt = Date.now()
|
||||
isRenaming.value = true
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (Date.now() - renameStartedAt < 150) return
|
||||
const trimmed = renameValue.value.trim()
|
||||
appModeStore.renameGroup(group.id, trimmed || null)
|
||||
isRenaming.value = false
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
isRenaming.value = false
|
||||
}
|
||||
|
||||
function startRenameDeferred() {
|
||||
setTimeout(startRename, 50)
|
||||
}
|
||||
|
||||
function handleDissolve() {
|
||||
appModeStore.dissolveGroup(group.id, zoneId)
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, value: WidgetValue) {
|
||||
if (value === undefined) return
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot
|
||||
v-model:open="isOpen"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col',
|
||||
builderMode &&
|
||||
'rounded-lg border border-dashed border-primary-background/40',
|
||||
!builderMode && 'border-border-subtle/40',
|
||||
!builderMode &&
|
||||
position !== 'first' &&
|
||||
position !== 'only' &&
|
||||
'border-t',
|
||||
!builderMode &&
|
||||
(position === 'last' || position === 'only') &&
|
||||
'border-b'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Header row — draggable in builder mode -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1',
|
||||
builderMode ? 'drag-handle cursor-grab py-1 pr-1.5 pl-1' : 'px-4 py-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Rename input (outside CollapsibleTrigger to avoid focus conflicts) -->
|
||||
<div v-if="isRenaming" class="flex flex-1 items-center gap-1.5 px-3 py-2">
|
||||
<input
|
||||
v-model="renameValue"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none"
|
||||
@click.stop
|
||||
@keydown.enter.stop="confirmRename"
|
||||
@keydown.escape.stop="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@vue:mounted="
|
||||
($event: any) => {
|
||||
$event.el?.focus()
|
||||
$event.el?.select()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- Name + chevron -->
|
||||
<CollapsibleTrigger v-else as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5 border border-transparent bg-transparent px-3 py-2 text-left outline-none"
|
||||
>
|
||||
<Tooltip :text="displayName" side="left" :side-offset="20">
|
||||
<span
|
||||
class="flex-1 truncate text-sm font-bold text-base-foreground"
|
||||
@dblclick.stop="startRename"
|
||||
>
|
||||
{{ displayName }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
isOpen && 'rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<!-- Builder actions on the right -->
|
||||
<Popover v-if="builderMode" class="-mr-2 shrink-0">
|
||||
<template #button>
|
||||
<Button variant="textonly" size="icon">
|
||||
<i class="icon-[lucide--ellipsis-vertical]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
startRenameDeferred()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--pencil]" />
|
||||
{{ t('g.rename') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
showUngroupDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--ungroup]" />
|
||||
{{ t('linearMode.layout.ungroup') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<!-- Ungroup confirmation dialog -->
|
||||
<DialogRoot v-model:open="showUngroupDialog">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-1800 bg-black/50" />
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 z-1800 w-80 -translate-1/2 rounded-xl border border-border-subtle bg-base-background p-5 shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle class="text-sm font-medium">
|
||||
{{ t('linearMode.groups.confirmUngroup') }}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
class="flex size-6 items-center justify-center rounded-sm border-0 bg-transparent text-muted-foreground outline-none hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 border-t border-border-subtle pt-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('linearMode.groups.ungroupDescription') }}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-end gap-3">
|
||||
<DialogClose as-child>
|
||||
<Button variant="muted-textonly" size="sm">
|
||||
{{ t('g.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="
|
||||
() => {
|
||||
handleDissolve()
|
||||
showUngroupDialog = false
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ t('linearMode.layout.ungroup') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<!-- Builder mode: drop zone -->
|
||||
<div
|
||||
v-if="builderMode"
|
||||
v-group-drop-target="{ groupId: group.id, zoneId }"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-10 flex-col gap-3 px-2 pb-2',
|
||||
'[&.group-drag-over]:bg-primary-background/5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="row.type === 'single' ? row.item.key : row.items[0].key"
|
||||
>
|
||||
<div
|
||||
v-if="row.type === 'single'"
|
||||
v-group-item-draggable="{
|
||||
itemKey: row.item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
v-group-item-reorder-target="{
|
||||
itemKey: row.item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
class="cursor-grab overflow-hidden rounded-lg p-1.5 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="row.item.widget"
|
||||
:node="row.item.node"
|
||||
hidden-label
|
||||
hidden-widget-actions
|
||||
hidden-favorite-indicator
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-stretch gap-2">
|
||||
<div
|
||||
v-for="item in row.items"
|
||||
:key="item.key"
|
||||
v-group-item-draggable="{
|
||||
itemKey: item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
v-group-item-reorder-target="{
|
||||
itemKey: item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
class="min-w-0 flex-1 cursor-grab overflow-hidden rounded-lg p-0.5 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="item.widget"
|
||||
:node="item.node"
|
||||
hidden-label
|
||||
hidden-widget-actions
|
||||
hidden-favorite-indicator
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="group.items.length === 0"
|
||||
class="flex items-center justify-center py-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('linearMode.arrange.dropHere') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App mode: clean read-only -->
|
||||
<div v-else class="flex flex-col gap-4 px-4 pt-2 pb-4">
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="row.type === 'single' ? row.item.key : row.items[0].key"
|
||||
>
|
||||
<div v-if="row.type === 'single'">
|
||||
<WidgetItem
|
||||
:widget="row.item.widget"
|
||||
:node="row.item.node"
|
||||
hidden-label
|
||||
hidden-widget-actions
|
||||
@update:widget-value="
|
||||
handleWidgetValueUpdate(row.item.widget, $event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-stretch gap-2">
|
||||
<div
|
||||
v-for="item in row.items"
|
||||
:key="item.key"
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<WidgetItem
|
||||
:widget="item.widget"
|
||||
:node="item.node"
|
||||
hidden-label
|
||||
hidden-widget-actions
|
||||
class="w-full"
|
||||
@update:widget-value="
|
||||
handleWidgetValueUpdate(item.widget, $event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
35
src/components/builder/LayoutTemplateSelector.vue
Normal file
35
src/components/builder/LayoutTemplateSelector.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LayoutTemplateId } from '@/components/builder/layoutTemplates'
|
||||
import { LAYOUT_TEMPLATES } from '@/components/builder/layoutTemplates'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const selected = defineModel<LayoutTemplateId>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed top-1/2 left-4 z-1000 flex -translate-y-1/2 flex-col gap-1 rounded-2xl border border-border-default bg-base-background p-1.5 shadow-interface"
|
||||
>
|
||||
<button
|
||||
v-for="template in LAYOUT_TEMPLATES"
|
||||
:key="template.id"
|
||||
v-tooltip.right="t(template.description)"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer items-center justify-center rounded-lg border-2 p-2 transition-colors',
|
||||
selected === template.id
|
||||
? 'border-primary-background bg-primary-background/10'
|
||||
: 'border-transparent bg-transparent hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:aria-label="t(template.label)"
|
||||
:aria-pressed="selected === template.id"
|
||||
@click="selected = template.id"
|
||||
>
|
||||
<i :class="cn(template.icon, 'size-5')" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
83
src/components/builder/LayoutZoneGrid.vue
Normal file
83
src/components/builder/LayoutZoneGrid.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
GridOverride,
|
||||
LayoutTemplate,
|
||||
LayoutZone
|
||||
} from '@/components/builder/layoutTemplates'
|
||||
import { buildGridTemplate } from '@/components/builder/layoutTemplates'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
const {
|
||||
template,
|
||||
highlightedZone,
|
||||
dashed = true,
|
||||
gridOverrides
|
||||
} = defineProps<{
|
||||
template: LayoutTemplate
|
||||
highlightedZone?: string
|
||||
dashed?: boolean
|
||||
gridOverrides?: GridOverride
|
||||
/** Extra CSS classes per zone ID, applied to the grid cell div. */
|
||||
zoneClasses?: Record<string, string>
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
zone(props: { zone: LayoutZone }): unknown
|
||||
}>()
|
||||
|
||||
const gridStyle = computed(() => {
|
||||
if (isMobile.value) {
|
||||
// Stack all zones vertically on mobile
|
||||
const areas = template.zones.map((z) => `"${z.gridArea}"`).join(' ')
|
||||
return {
|
||||
gridTemplate: `${areas} / 1fr`,
|
||||
gridAutoRows: 'minmax(200px, auto)'
|
||||
}
|
||||
}
|
||||
return { gridTemplate: buildGridTemplate(template, gridOverrides) }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Wrapper so handles overlay above zone content (overflow-y-auto creates stacking contexts) -->
|
||||
<div class="relative size-full overflow-hidden">
|
||||
<!-- Grid with zones -->
|
||||
<div class="grid size-full gap-3 overflow-hidden p-3" :style="gridStyle">
|
||||
<div
|
||||
v-for="zone in template.zones"
|
||||
:key="zone.id"
|
||||
:style="{ gridArea: zone.gridArea }"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col overflow-y-auto rounded-xl transition-colors',
|
||||
dashed
|
||||
? 'border border-dashed border-border-subtle/40'
|
||||
: 'border border-border-subtle/40',
|
||||
highlightedZone === zone.id &&
|
||||
'border-primary-background bg-primary-background/10',
|
||||
zoneClasses?.[zone.id]
|
||||
)
|
||||
"
|
||||
:data-zone-id="zone.id"
|
||||
:aria-label="t(zone.label)"
|
||||
>
|
||||
<slot name="zone" :zone="zone">
|
||||
<div
|
||||
class="flex size-full flex-col items-center justify-center gap-2 p-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-5" />
|
||||
<span>{{ t('linearMode.arrange.dropHere') }}</span>
|
||||
<span class="text-xs opacity-60">{{ t(zone.label) }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
78
src/components/builder/PresetMenu.stories.ts
Normal file
78
src/components/builder/PresetMenu.stories.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import PresetMenu from './PresetMenu.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Builder/PresetMenu',
|
||||
component: PresetMenu,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#1a1a1b' },
|
||||
{ name: 'light', value: '#ffffff' },
|
||||
{ name: 'sidebar', value: '#232326' }
|
||||
]
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof PresetMenu>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/** Default rendering — click to see built-in quick presets (Min/Mid/Max) and saved presets. */
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
template: `
|
||||
<div class="p-8">
|
||||
<PresetMenu />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** In a toolbar context alongside a workflow title. */
|
||||
export const InToolbar: Story = {
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
template: `
|
||||
<div class="flex h-12 items-center gap-2 rounded-lg border border-border-subtle bg-comfy-menu-bg px-4 py-2 min-w-80">
|
||||
<span class="truncate font-bold">my_workflow.json</span>
|
||||
<div class="flex-1" />
|
||||
<PresetMenu />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** On sidebar background — verify contrast against dark sidebar. */
|
||||
export const OnSidebarBackground: Story = {
|
||||
parameters: {
|
||||
backgrounds: { default: 'sidebar' }
|
||||
},
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
template: `
|
||||
<div class="p-8">
|
||||
<PresetMenu />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Narrow container — verify truncation of long preset names. */
|
||||
export const Compact: Story = {
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
template: `
|
||||
<div class="flex h-10 w-48 items-center rounded-lg border border-border-subtle bg-comfy-menu-bg px-2">
|
||||
<span class="truncate text-sm font-bold">long_workflow_name.json</span>
|
||||
<div class="flex-1" />
|
||||
<PresetMenu />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
176
src/components/builder/PresetMenu.vue
Normal file
176
src/components/builder/PresetMenu.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { BUILTIN_PRESET_IDS, useAppPresets } from '@/composables/useAppPresets'
|
||||
import type { PresetDisplayMode } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { presets, savePreset, deletePreset, applyPreset } = useAppPresets()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { presetDisplayMode } = storeToRefs(appModeStore)
|
||||
|
||||
const builtinPresets = [
|
||||
{
|
||||
id: BUILTIN_PRESET_IDS.min,
|
||||
label: () => t('linearMode.presets.builtinMin'),
|
||||
icon: 'icon-[lucide--arrow-down-to-line]'
|
||||
},
|
||||
{
|
||||
id: BUILTIN_PRESET_IDS.mid,
|
||||
label: () => t('linearMode.presets.builtinMid'),
|
||||
icon: 'icon-[lucide--minus]'
|
||||
},
|
||||
{
|
||||
id: BUILTIN_PRESET_IDS.max,
|
||||
label: () => t('linearMode.presets.builtinMax'),
|
||||
icon: 'icon-[lucide--arrow-up-to-line]'
|
||||
}
|
||||
]
|
||||
|
||||
const displayModes: { value: PresetDisplayMode; label: () => string }[] = [
|
||||
{ value: 'tabs', label: () => t('linearMode.presets.displayTabs') },
|
||||
{ value: 'buttons', label: () => t('linearMode.presets.displayButtons') },
|
||||
{ value: 'menu', label: () => t('linearMode.presets.displayMenu') }
|
||||
]
|
||||
|
||||
function setDisplayMode(mode: PresetDisplayMode) {
|
||||
presetDisplayMode.value = mode
|
||||
appModeStore.persistLinearData()
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const name = await useDialogService().prompt({
|
||||
title: t('linearMode.presets.saveTitle'),
|
||||
message: t('linearMode.presets.saveMessage'),
|
||||
placeholder: t('linearMode.presets.namePlaceholder')
|
||||
})
|
||||
if (name?.trim()) savePreset(name.trim())
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
:aria-label="t('linearMode.presets.label')"
|
||||
:class="
|
||||
cn(
|
||||
'gap-1 text-xs text-muted-foreground hover:text-base-foreground',
|
||||
presets.length > 0 && 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--bookmark]" />
|
||||
{{ t('linearMode.presets.label') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex min-w-48 flex-col">
|
||||
<!-- Built-in quick presets -->
|
||||
<div
|
||||
class="px-3 py-1 text-xs font-medium text-muted-foreground"
|
||||
v-text="t('linearMode.presets.builtinSection')"
|
||||
/>
|
||||
<div class="flex gap-1 px-2 pb-2">
|
||||
<button
|
||||
v-for="bp in builtinPresets"
|
||||
:key="bp.id"
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-1 rounded-md px-2 py-1.5 text-xs hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
applyPreset(bp.id)
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i :class="cn(bp.icon, 'size-3')" />
|
||||
{{ bp.label() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Saved presets -->
|
||||
<div class="border-t border-border-subtle">
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground"
|
||||
v-text="t('linearMode.presets.savedSection')"
|
||||
/>
|
||||
<div
|
||||
v-if="presets.length === 0"
|
||||
class="px-3 py-1.5 text-xs text-muted-foreground"
|
||||
v-text="t('linearMode.presets.empty')"
|
||||
/>
|
||||
<div
|
||||
v-for="preset in presets"
|
||||
:key="preset.id"
|
||||
class="group flex items-center gap-2 rounded-sm px-3 py-1.5 hover:bg-secondary-background-hover"
|
||||
>
|
||||
<button
|
||||
class="flex-1 cursor-pointer truncate text-left text-sm"
|
||||
@click="
|
||||
() => {
|
||||
applyPreset(preset.id)
|
||||
close()
|
||||
}
|
||||
"
|
||||
v-text="preset.name"
|
||||
/>
|
||||
<button
|
||||
class="hover:text-danger invisible shrink-0 cursor-pointer text-muted-foreground group-hover:visible"
|
||||
:aria-label="t('g.remove')"
|
||||
@click="deletePreset(preset.id)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save action -->
|
||||
<div class="border-t border-border-subtle pt-1">
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
handleSave()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-3.5" />
|
||||
{{ t('linearMode.presets.save') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Display mode -->
|
||||
<div class="border-t border-border-subtle pt-1">
|
||||
<div
|
||||
class="px-3 py-1 text-xs font-medium text-muted-foreground"
|
||||
v-text="t('linearMode.presets.displayAs')"
|
||||
/>
|
||||
<div class="flex gap-1 px-2 pb-1">
|
||||
<button
|
||||
v-for="dm in displayModes"
|
||||
:key="dm.value"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 cursor-pointer rounded-md px-2 py-1 text-xs',
|
||||
presetDisplayMode === dm.value
|
||||
? 'bg-secondary-background-hover font-medium'
|
||||
: 'hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="setDisplayMode(dm.value)"
|
||||
v-text="dm.label()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
407
src/components/builder/SidebarAppLayout.vue
Normal file
407
src/components/builder/SidebarAppLayout.vue
Normal file
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import BuilderConfirmDialog from '@/components/builder/BuilderConfirmDialog.vue'
|
||||
import InputGroupAccordion from '@/components/builder/InputGroupAccordion.vue'
|
||||
import {
|
||||
inputItemKey,
|
||||
parseGroupItemKey
|
||||
} from '@/components/builder/itemKeyHelper'
|
||||
import LayoutZoneGrid from '@/components/builder/LayoutZoneGrid.vue'
|
||||
import { getTemplate } from '@/components/builder/layoutTemplates'
|
||||
import { useBuilderRename } from '@/components/builder/useBuilderRename'
|
||||
import { vGroupDraggable } from '@/components/builder/useGroupDrop'
|
||||
import { useLinearRunPrompt } from '@/components/builder/useLinearRunPrompt'
|
||||
import {
|
||||
vWidgetDraggable,
|
||||
vZoneDropTarget
|
||||
} from '@/components/builder/useZoneDrop'
|
||||
import { vZoneItemReorderTarget } from '@/components/builder/useWidgetReorder'
|
||||
import type { ResolvedArrangeWidget } from '@/components/builder/useZoneWidgets'
|
||||
import { useArrangeZoneWidgets } from '@/components/builder/useZoneWidgets'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { runPrompt } = useLinearRunPrompt()
|
||||
const settingStore = useSettingStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const { isBuilderMode } = useAppMode()
|
||||
|
||||
const activeTemplate = computed(
|
||||
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('single')!
|
||||
)
|
||||
|
||||
/** The zone where run controls should render (last zone = right column in dual). */
|
||||
const runZoneId = computed(() => {
|
||||
const zones = activeTemplate.value.zones
|
||||
return zones.at(-1)?.id ?? zones[0]?.id ?? ''
|
||||
})
|
||||
|
||||
// Builder mode: draggable zone widgets
|
||||
const zoneWidgets = useArrangeZoneWidgets()
|
||||
|
||||
onMounted(() => {
|
||||
if (isBuilderMode.value) appModeStore.autoAssignInputs()
|
||||
})
|
||||
|
||||
const widgetsByKey = computed(() => {
|
||||
const map = new Map<string, ResolvedArrangeWidget>()
|
||||
for (const [, widgets] of zoneWidgets.value) {
|
||||
for (const w of widgets) map.set(inputItemKey(w.nodeId, w.widgetName), w)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getOrderedItems(zoneId: string) {
|
||||
const widgets = zoneWidgets.value.get(zoneId) ?? []
|
||||
const hasRun = zoneId === appModeStore.runControlsZoneId
|
||||
return appModeStore.getZoneItems(zoneId, [], widgets, hasRun, false)
|
||||
}
|
||||
|
||||
const {
|
||||
renamingKey,
|
||||
renameValue,
|
||||
startRename: startRenameInput,
|
||||
confirmRename: confirmRenameInput,
|
||||
cancelRename: cancelRenameInput,
|
||||
startRenameDeferred: startRenameInputDeferred
|
||||
} = useBuilderRename((key) => widgetsByKey.value.get(key))
|
||||
|
||||
const showRemoveDialog = ref(false)
|
||||
const pendingRemove = ref<{ nodeId: NodeId; widgetName: string } | null>(null)
|
||||
|
||||
function confirmRemoveInput(nodeId: NodeId, widgetName: string) {
|
||||
pendingRemove.value = { nodeId, widgetName }
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
function removeInput() {
|
||||
if (!pendingRemove.value) return
|
||||
const { nodeId, widgetName } = pendingRemove.value
|
||||
const idx = appModeStore.selectedInputs.findIndex(
|
||||
([nId, wName]) => nId === nodeId && wName === widgetName
|
||||
)
|
||||
if (idx !== -1) appModeStore.selectedInputs.splice(idx, 1)
|
||||
showRemoveDialog.value = false
|
||||
pendingRemove.value = null
|
||||
}
|
||||
|
||||
function findGroupById(itemKey: string) {
|
||||
const groupId = parseGroupItemKey(itemKey)
|
||||
if (!groupId) return undefined
|
||||
return appModeStore.inputGroups.find((g) => g.id === groupId)
|
||||
}
|
||||
|
||||
type ZoneSegment =
|
||||
| { type: 'inputs'; keys: string[] }
|
||||
| { type: 'group'; group: InputGroup }
|
||||
|
||||
function getZoneSegments(zoneId: string): ZoneSegment[] {
|
||||
const items = getOrderedItems(zoneId)
|
||||
const segments: ZoneSegment[] = []
|
||||
let currentInputKeys: string[] = []
|
||||
|
||||
function flushInputs() {
|
||||
if (currentInputKeys.length > 0) {
|
||||
segments.push({ type: 'inputs', keys: [...currentInputKeys] })
|
||||
currentInputKeys = []
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of items) {
|
||||
if (key.startsWith('input:')) {
|
||||
currentInputKeys.push(key)
|
||||
} else if (key.startsWith('group:')) {
|
||||
const group = findGroupById(key)
|
||||
if (group && (isBuilderMode.value || group.items.length >= 1)) {
|
||||
flushInputs()
|
||||
segments.push({ type: 'group', group })
|
||||
}
|
||||
}
|
||||
}
|
||||
flushInputs()
|
||||
return segments
|
||||
}
|
||||
|
||||
function groupPosition(
|
||||
group: InputGroup,
|
||||
segments: ZoneSegment[]
|
||||
): 'first' | 'middle' | 'last' | 'only' {
|
||||
const groupSegments = segments.filter(
|
||||
(s): s is ZoneSegment & { type: 'group' } => s.type === 'group'
|
||||
)
|
||||
const idx = groupSegments.findIndex((s) => s.group.id === group.id)
|
||||
const total = groupSegments.length
|
||||
const isFirst = idx === 0 && !segments.some((s) => s.type === 'inputs')
|
||||
if (total === 1) return isFirst ? 'only' : 'last'
|
||||
if (isFirst) return 'first'
|
||||
if (idx === total - 1) return 'last'
|
||||
return 'middle'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="linear-widgets" class="flex h-full flex-col">
|
||||
<!-- Inputs area -->
|
||||
<div class="flex min-h-0 flex-1 flex-col bg-comfy-menu-bg px-2">
|
||||
<!-- === ZONE GRID (always — single or dual) === -->
|
||||
<LayoutZoneGrid
|
||||
:template="activeTemplate"
|
||||
:grid-overrides="appModeStore.gridOverrides"
|
||||
:dashed="isBuilderMode"
|
||||
class="min-h-0 flex-1"
|
||||
>
|
||||
<template #zone="{ zone }">
|
||||
<div class="flex size-full flex-col" :data-zone-id="zone.id">
|
||||
<!-- Inputs (scrollable, order matches builder mode) -->
|
||||
<div
|
||||
v-if="!isBuilderMode"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
||||
>
|
||||
<div>
|
||||
<template
|
||||
v-for="(segment, sIdx) in getZoneSegments(zone.id)"
|
||||
:key="
|
||||
segment.type === 'inputs'
|
||||
? `inputs-${sIdx}`
|
||||
: `group-${segment.group.id}`
|
||||
"
|
||||
>
|
||||
<AppModeWidgetList
|
||||
v-if="segment.type === 'inputs'"
|
||||
:item-keys="segment.keys"
|
||||
/>
|
||||
<InputGroupAccordion
|
||||
v-else
|
||||
:group="segment.group"
|
||||
:zone-id="zone.id"
|
||||
:position="
|
||||
groupPosition(segment.group, getZoneSegments(zone.id))
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Builder mode: draggable zone content (scrollable, short content hugs bottom) -->
|
||||
<div
|
||||
v-else
|
||||
v-zone-drop-target="zone.id"
|
||||
class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2 [&.zone-drag-over]:bg-primary-background/10 [&.zone-drag-over]:ring-2 [&.zone-drag-over]:ring-primary-background [&.zone-drag-over]:ring-inset"
|
||||
>
|
||||
<template
|
||||
v-for="itemKey in getOrderedItems(zone.id)"
|
||||
:key="itemKey"
|
||||
>
|
||||
<!-- Input widget -->
|
||||
<div
|
||||
v-if="
|
||||
itemKey.startsWith('input:') && widgetsByKey.get(itemKey)
|
||||
"
|
||||
v-widget-draggable="{
|
||||
nodeId: widgetsByKey.get(itemKey)!.nodeId,
|
||||
widgetName: widgetsByKey.get(itemKey)!.widgetName,
|
||||
zone: zone.id
|
||||
}"
|
||||
v-zone-item-reorder-target="{
|
||||
itemKey,
|
||||
zone: zone.id
|
||||
}"
|
||||
class="shrink-0 cursor-grab overflow-hidden rounded-lg border border-dashed border-border-subtle p-2 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<!-- Builder menu -->
|
||||
<div class="mb-1 flex items-center gap-1">
|
||||
<div
|
||||
v-if="renamingKey === itemKey"
|
||||
class="flex flex-1 items-center"
|
||||
>
|
||||
<input
|
||||
v-model="renameValue"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none"
|
||||
@click.stop
|
||||
@keydown.enter.stop="confirmRenameInput"
|
||||
@keydown.escape.stop="cancelRenameInput"
|
||||
@blur="confirmRenameInput"
|
||||
@vue:mounted="
|
||||
($event: any) => {
|
||||
$event.el?.focus()
|
||||
$event.el?.select()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="flex-1 truncate text-sm text-muted-foreground"
|
||||
@dblclick.stop="startRenameInput(itemKey)"
|
||||
>
|
||||
{{
|
||||
widgetsByKey.get(itemKey)!.widget.label ||
|
||||
widgetsByKey.get(itemKey)!.widget.name
|
||||
}}
|
||||
—
|
||||
{{ widgetsByKey.get(itemKey)!.node.title }}
|
||||
</span>
|
||||
<Popover class="pointer-events-auto shrink-0">
|
||||
<template #button>
|
||||
<Button variant="textonly" size="icon">
|
||||
<i class="icon-[lucide--ellipsis-vertical]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
startRenameInputDeferred(itemKey)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--pencil]" />
|
||||
{{ t('g.rename') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
confirmRemoveInput(
|
||||
widgetsByKey.get(itemKey)!.nodeId,
|
||||
widgetsByKey.get(itemKey)!.widgetName
|
||||
)
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
{{ t('g.remove') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widgetsByKey.get(itemKey)!.widget"
|
||||
:node="widgetsByKey.get(itemKey)!.node"
|
||||
hidden-label
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Group accordion -->
|
||||
<div
|
||||
v-else-if="
|
||||
itemKey.startsWith('group:') && findGroupById(itemKey)
|
||||
"
|
||||
v-group-draggable="{
|
||||
groupId: findGroupById(itemKey)!.id,
|
||||
zone: zone.id
|
||||
}"
|
||||
v-zone-item-reorder-target="{
|
||||
itemKey,
|
||||
zone: zone.id
|
||||
}"
|
||||
class="shrink-0 [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<InputGroupAccordion
|
||||
:group="findGroupById(itemKey)!"
|
||||
:zone-id="zone.id"
|
||||
builder-mode
|
||||
/>
|
||||
</div>
|
||||
<!-- Run controls handled below, pinned to zone bottom -->
|
||||
</template>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="getOrderedItems(zone.id).length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<i class="mr-2 icon-[lucide--plus] size-4" />
|
||||
{{ t('linearMode.arrange.dropHere') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create group (pinned below scroll, builder only) -->
|
||||
<button
|
||||
v-if="isBuilderMode"
|
||||
type="button"
|
||||
class="group/cg flex w-full shrink-0 items-center justify-between border-0 border-t border-border-subtle/40 bg-transparent py-4 pr-5 pl-4 text-sm text-base-foreground outline-none"
|
||||
@click="appModeStore.createGroup(zone.id)"
|
||||
>
|
||||
{{ t('linearMode.groups.createGroup') }}
|
||||
<i
|
||||
class="icon-[lucide--plus] size-5 text-muted-foreground group-hover/cg:text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Run controls (pinned to bottom of last zone, both modes) -->
|
||||
<section
|
||||
v-if="zone.id === runZoneId"
|
||||
data-testid="linear-run-controls"
|
||||
:class="[
|
||||
'mt-auto shrink-0 border-t p-4 pb-6',
|
||||
isBuilderMode
|
||||
? 'border-border-subtle/40'
|
||||
: 'mx-3 border-border-subtle'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span
|
||||
class="shrink-0 text-sm text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 max-w-[35%] min-w-fit flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
data-testid="linear-run-button"
|
||||
@click="runPrompt"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutZoneGrid>
|
||||
|
||||
<PartnerNodesList />
|
||||
</div>
|
||||
|
||||
<BuilderConfirmDialog
|
||||
v-model="showRemoveDialog"
|
||||
:title="t('linearMode.groups.confirmRemove')"
|
||||
:description="t('linearMode.groups.removeDescription')"
|
||||
:confirm-label="t('g.remove')"
|
||||
confirm-variant="destructive"
|
||||
@confirm="removeInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
119
src/components/builder/dropIndicatorUtil.test.ts
Normal file
119
src/components/builder/dropIndicatorUtil.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import { buildDropIndicator } from './dropIndicatorUtil'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { apiURL: (path: string) => `http://localhost:8188${path}` }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { getPreviewFormatParam: () => '&format=webp' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: vi.fn()
|
||||
}))
|
||||
|
||||
function makeNode(type: string, widgetValue?: unknown): LGraphNode {
|
||||
return {
|
||||
type,
|
||||
widgets:
|
||||
widgetValue !== undefined
|
||||
? [{ value: widgetValue }, { callback: vi.fn() }]
|
||||
: undefined
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('buildDropIndicator', () => {
|
||||
it('returns undefined for unsupported node types', () => {
|
||||
expect(buildDropIndicator(makeNode('KSampler'), {})).toBeUndefined()
|
||||
expect(buildDropIndicator(makeNode('CLIPTextEncode'), {})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns image indicator for LoadImage node with filename', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadImage', 'photo.png'), {
|
||||
imageLabel: 'Upload'
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.iconClass).toBe('icon-[lucide--image]')
|
||||
expect(result!.imageUrl).toContain('/view?')
|
||||
expect(result!.imageUrl).toContain('filename=photo.png')
|
||||
expect(result!.label).toBe('Upload')
|
||||
})
|
||||
|
||||
it('returns image indicator with no imageUrl when widget has no value', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadImage', ''), {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.imageUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns image indicator with no imageUrl when widgets are missing', () => {
|
||||
const node = { type: 'LoadImage' } as unknown as LGraphNode
|
||||
const result = buildDropIndicator(node, {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.imageUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes onMaskEdit when imageUrl exists and openMaskEditor is provided', () => {
|
||||
const openMaskEditor = vi.fn()
|
||||
const node = makeNode('LoadImage', 'photo.png')
|
||||
const result = buildDropIndicator(node, { openMaskEditor })
|
||||
|
||||
expect(result!.onMaskEdit).toBeDefined()
|
||||
result!.onMaskEdit!()
|
||||
expect(openMaskEditor).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('omits onMaskEdit when no imageUrl', () => {
|
||||
const openMaskEditor = vi.fn()
|
||||
const result = buildDropIndicator(makeNode('LoadImage', ''), {
|
||||
openMaskEditor
|
||||
})
|
||||
|
||||
expect(result!.onMaskEdit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns video indicator for LoadVideo node with filename', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadVideo', 'clip.mp4'), {
|
||||
videoLabel: 'Upload Video'
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.iconClass).toBe('icon-[lucide--video]')
|
||||
expect(result!.videoUrl).toContain('/view?')
|
||||
expect(result!.videoUrl).toContain('filename=clip.mp4')
|
||||
expect(result!.label).toBe('Upload Video')
|
||||
expect(result!.onMaskEdit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns video indicator with no videoUrl when widget has no value', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadVideo', ''), {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.videoUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses subfolder and type from widget value', () => {
|
||||
const result = buildDropIndicator(
|
||||
makeNode('LoadImage', 'sub/folder/image.png [output]'),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result!.imageUrl).toContain('filename=image.png')
|
||||
expect(result!.imageUrl).toContain('subfolder=sub%2Ffolder')
|
||||
expect(result!.imageUrl).toContain('type=output')
|
||||
})
|
||||
|
||||
it('invokes widget callback on onClick', () => {
|
||||
const node = makeNode('LoadImage', 'photo.png')
|
||||
const result = buildDropIndicator(node, {})
|
||||
|
||||
result!.onClick!({} as MouseEvent)
|
||||
expect(node.widgets![1].callback).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
106
src/components/builder/dropIndicatorUtil.ts
Normal file
106
src/components/builder/dropIndicatorUtil.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
|
||||
interface DropIndicatorData {
|
||||
iconClass: string
|
||||
imageUrl?: string
|
||||
videoUrl?: string
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
onMaskEdit?: () => void
|
||||
onDownload?: () => void
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DropZone indicator for LoadImage or LoadVideo nodes.
|
||||
* Returns undefined for other node types.
|
||||
*/
|
||||
export function buildDropIndicator(
|
||||
node: LGraphNode,
|
||||
options: {
|
||||
imageLabel?: string
|
||||
videoLabel?: string
|
||||
openMaskEditor?: (node: LGraphNode) => void
|
||||
}
|
||||
): DropIndicatorData | undefined {
|
||||
if (node.type === 'LoadImage') {
|
||||
return buildImageDropIndicator(node, options)
|
||||
}
|
||||
|
||||
if (node.type === 'LoadVideo') {
|
||||
return buildVideoDropIndicator(node, options)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildImageDropIndicator(
|
||||
node: LGraphNode,
|
||||
options: {
|
||||
imageLabel?: string
|
||||
openMaskEditor?: (node: LGraphNode) => void
|
||||
}
|
||||
): DropIndicatorData {
|
||||
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
|
||||
|
||||
const { filename, subfolder, type } = stringValue
|
||||
? parseImageWidgetValue(stringValue)
|
||||
: { filename: '', subfolder: '', type: 'input' }
|
||||
|
||||
const imageUrl = filename
|
||||
? (() => {
|
||||
const params = new URLSearchParams({ filename, subfolder, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
})()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl,
|
||||
label: options.imageLabel,
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined),
|
||||
onMaskEdit:
|
||||
imageUrl && options.openMaskEditor
|
||||
? () => options.openMaskEditor!(node)
|
||||
: undefined,
|
||||
onDownload: imageUrl ? () => downloadFile(imageUrl) : undefined,
|
||||
onRemove: imageUrl
|
||||
? () => {
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
imageWidget.value = ''
|
||||
imageWidget.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function buildVideoDropIndicator(
|
||||
node: LGraphNode,
|
||||
options: { videoLabel?: string }
|
||||
): DropIndicatorData {
|
||||
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
|
||||
|
||||
const { filename, subfolder, type } = stringValue
|
||||
? parseImageWidgetValue(stringValue)
|
||||
: { filename: '', subfolder: '', type: 'input' }
|
||||
|
||||
const videoUrl = filename
|
||||
? api.apiURL(`/view?${new URLSearchParams({ filename, subfolder, type })}`)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--video]',
|
||||
videoUrl,
|
||||
label: options.videoLabel,
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
61
src/components/builder/itemKeyHelper.test.ts
Normal file
61
src/components/builder/itemKeyHelper.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
groupItemKey,
|
||||
inputItemKey,
|
||||
parseGroupItemKey,
|
||||
parseInputItemKey
|
||||
} from './itemKeyHelper'
|
||||
|
||||
describe('inputItemKey', () => {
|
||||
it('builds key from nodeId and widgetName', () => {
|
||||
expect(inputItemKey('5', 'steps')).toBe('input:5:steps')
|
||||
})
|
||||
|
||||
it('handles numeric nodeId', () => {
|
||||
expect(inputItemKey(42, 'cfg')).toBe('input:42:cfg')
|
||||
})
|
||||
|
||||
it('preserves colons in widgetName', () => {
|
||||
expect(inputItemKey('1', 'a:b:c')).toBe('input:1:a:b:c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupItemKey', () => {
|
||||
it('builds key from groupId', () => {
|
||||
expect(groupItemKey('abc-123')).toBe('group:abc-123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInputItemKey', () => {
|
||||
it('parses a valid input key', () => {
|
||||
expect(parseInputItemKey('input:5:steps')).toEqual({
|
||||
nodeId: '5',
|
||||
widgetName: 'steps'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles widgetName containing colons', () => {
|
||||
expect(parseInputItemKey('input:1:a:b:c')).toEqual({
|
||||
nodeId: '1',
|
||||
widgetName: 'a:b:c'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-input keys', () => {
|
||||
expect(parseInputItemKey('group:abc')).toBeNull()
|
||||
expect(parseInputItemKey('output:5')).toBeNull()
|
||||
expect(parseInputItemKey('run-controls')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGroupItemKey', () => {
|
||||
it('parses a valid group key', () => {
|
||||
expect(parseGroupItemKey('group:abc-123')).toBe('abc-123')
|
||||
})
|
||||
|
||||
it('returns null for non-group keys', () => {
|
||||
expect(parseGroupItemKey('input:5:steps')).toBeNull()
|
||||
expect(parseGroupItemKey('run-controls')).toBeNull()
|
||||
})
|
||||
})
|
||||
27
src/components/builder/itemKeyHelper.ts
Normal file
27
src/components/builder/itemKeyHelper.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Build an input item key from nodeId and widgetName. */
|
||||
export function inputItemKey(
|
||||
nodeId: string | number,
|
||||
widgetName: string
|
||||
): string {
|
||||
return `input:${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
/** Build a group item key from groupId. */
|
||||
export function groupItemKey(groupId: string): string {
|
||||
return `group:${groupId}`
|
||||
}
|
||||
|
||||
/** Parse an input item key into its nodeId and widgetName parts. Returns null if not an input key. */
|
||||
export function parseInputItemKey(
|
||||
key: string
|
||||
): { nodeId: string; widgetName: string } | null {
|
||||
if (!key.startsWith('input:')) return null
|
||||
const parts = key.split(':')
|
||||
return { nodeId: parts[1], widgetName: parts.slice(2).join(':') }
|
||||
}
|
||||
|
||||
/** Parse a group item key into its groupId. Returns null if not a group key. */
|
||||
export function parseGroupItemKey(key: string): string | null {
|
||||
if (!key.startsWith('group:')) return null
|
||||
return key.slice('group:'.length)
|
||||
}
|
||||
163
src/components/builder/layoutTemplates.test.ts
Normal file
163
src/components/builder/layoutTemplates.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LayoutTemplateId } from './layoutTemplates'
|
||||
import {
|
||||
buildGridTemplate,
|
||||
getTemplate,
|
||||
LAYOUT_TEMPLATES
|
||||
} from './layoutTemplates'
|
||||
|
||||
/** Extract area rows from a grid template string. */
|
||||
function parseAreaRows(gridStr: string) {
|
||||
return gridStr
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith('"'))
|
||||
.map((l) => {
|
||||
const match = l.match(/"([^"]+)"\s*(.*)/)
|
||||
return {
|
||||
areas: match?.[1].split(/\s+/) ?? [],
|
||||
fraction: match?.[2]?.trim() || '1fr'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildGridTemplate', () => {
|
||||
const dualTemplate = getTemplate('dual')!
|
||||
|
||||
it('returns original gridTemplate when no overrides', () => {
|
||||
const result = buildGridTemplate(dualTemplate)
|
||||
expect(result).toBe(dualTemplate.gridTemplate)
|
||||
})
|
||||
|
||||
it('applies column fraction overrides', () => {
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
const colCount = originalRows[0].areas.length
|
||||
|
||||
const fractions = Array.from({ length: colCount }, (_, i) => i + 1)
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
columnFractions: fractions
|
||||
})
|
||||
|
||||
const colLine = result
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
expect(colLine).toBe(`/ ${fractions.map((f) => `${f}fr`).join(' ')}`)
|
||||
})
|
||||
|
||||
it('applies row fraction overrides in correct positions', () => {
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
rowFractions: [2]
|
||||
})
|
||||
const rows = parseAreaRows(result)
|
||||
expect(rows[0].fraction).toBe('2fr')
|
||||
})
|
||||
|
||||
it('reorders zone areas in output', () => {
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
const uniqueAreas = [...new Set(originalRows.flatMap((r) => r.areas))]
|
||||
const swapped = [uniqueAreas[1], uniqueAreas[0]]
|
||||
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
zoneOrder: swapped
|
||||
})
|
||||
const resultRows = parseAreaRows(result)
|
||||
|
||||
expect(resultRows[0].areas[0]).toBe(originalRows[0].areas[1])
|
||||
expect(resultRows[0].areas[1]).toBe(originalRows[0].areas[0])
|
||||
})
|
||||
|
||||
it('preserves row count when applying overrides', () => {
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
rowFractions: [1]
|
||||
})
|
||||
const resultRows = parseAreaRows(result)
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
expect(resultRows).toHaveLength(originalRows.length)
|
||||
})
|
||||
|
||||
it('falls back to original columns when fractions length mismatches', () => {
|
||||
const originalColLine = dualTemplate.gridTemplate
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
columnFractions: [1] // wrong count — should be ignored
|
||||
})
|
||||
const resultColLine = result
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
|
||||
expect(resultColLine).toBe(originalColLine)
|
||||
})
|
||||
|
||||
it('applies combined overrides together', () => {
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
const uniqueAreas = [...new Set(originalRows.flatMap((r) => r.areas))]
|
||||
const swapped = [uniqueAreas[1], uniqueAreas[0]]
|
||||
const colCount = originalRows[0].areas.length
|
||||
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
zoneOrder: swapped,
|
||||
rowFractions: [5],
|
||||
columnFractions: Array.from({ length: colCount }, () => 2)
|
||||
})
|
||||
|
||||
const resultRows = parseAreaRows(result)
|
||||
expect(resultRows[0].areas[0]).toBe(originalRows[0].areas[1])
|
||||
expect(resultRows[0].fraction).toBe('5fr')
|
||||
const colLine = result
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
expect(colLine).toContain('2fr')
|
||||
})
|
||||
|
||||
it('empty overrides produce same structure as original', () => {
|
||||
const result = buildGridTemplate(dualTemplate, {})
|
||||
const resultRows = parseAreaRows(result)
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
expect(resultRows.map((r) => r.areas)).toEqual(
|
||||
originalRows.map((r) => r.areas)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplate', () => {
|
||||
it('returns undefined for invalid ID', () => {
|
||||
expect(
|
||||
getTemplate('nonexistent' as unknown as LayoutTemplateId)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns matching template for each known ID', () => {
|
||||
for (const template of LAYOUT_TEMPLATES) {
|
||||
expect(getTemplate(template.id)).toBe(template)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('LAYOUT_TEMPLATES', () => {
|
||||
it('has unique IDs', () => {
|
||||
const ids = LAYOUT_TEMPLATES.map((t) => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('every template has at least one zone', () => {
|
||||
for (const template of LAYOUT_TEMPLATES) {
|
||||
expect(template.zones.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('every template has valid default zone references', () => {
|
||||
for (const template of LAYOUT_TEMPLATES) {
|
||||
const zoneIds = template.zones.map((z) => z.id)
|
||||
expect(zoneIds).toContain(template.defaultRunControlsZone)
|
||||
expect(zoneIds).toContain(template.defaultPresetStripZone)
|
||||
}
|
||||
})
|
||||
})
|
||||
159
src/components/builder/layoutTemplates.ts
Normal file
159
src/components/builder/layoutTemplates.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
export type LayoutTemplateId = 'single' | 'dual'
|
||||
|
||||
export interface LayoutZone {
|
||||
id: string
|
||||
/** i18n key for the zone label */
|
||||
label: string
|
||||
gridArea: string
|
||||
}
|
||||
|
||||
export interface LayoutTemplate {
|
||||
id: LayoutTemplateId
|
||||
/** i18n key for the template label */
|
||||
label: string
|
||||
/** i18n key for the template description */
|
||||
description: string
|
||||
icon: string
|
||||
gridTemplate: string
|
||||
zones: LayoutZone[]
|
||||
/** Zone ID where run controls go by default */
|
||||
defaultRunControlsZone: string
|
||||
/** Zone ID where preset strip goes by default */
|
||||
defaultPresetStripZone: string
|
||||
}
|
||||
|
||||
export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
|
||||
{
|
||||
id: 'single',
|
||||
label: 'linearMode.layout.templates.single',
|
||||
description: 'linearMode.layout.templates.singleDesc',
|
||||
icon: 'icon-[lucide--panel-right]',
|
||||
gridTemplate: `
|
||||
"main" 1fr
|
||||
/ 1fr
|
||||
`,
|
||||
zones: [
|
||||
{
|
||||
id: 'main',
|
||||
label: 'linearMode.layout.zones.main',
|
||||
gridArea: 'main'
|
||||
}
|
||||
],
|
||||
defaultRunControlsZone: 'main',
|
||||
defaultPresetStripZone: 'main'
|
||||
},
|
||||
{
|
||||
id: 'dual',
|
||||
label: 'linearMode.layout.templates.dual',
|
||||
description: 'linearMode.layout.templates.dualDesc',
|
||||
icon: 'icon-[lucide--columns-2]',
|
||||
gridTemplate: `
|
||||
"left right" 1fr
|
||||
/ 1fr 1fr
|
||||
`,
|
||||
zones: [
|
||||
{
|
||||
id: 'left',
|
||||
label: 'linearMode.layout.zones.left',
|
||||
gridArea: 'left'
|
||||
},
|
||||
{
|
||||
id: 'right',
|
||||
label: 'linearMode.layout.zones.right',
|
||||
gridArea: 'right'
|
||||
}
|
||||
],
|
||||
defaultRunControlsZone: 'right',
|
||||
defaultPresetStripZone: 'left'
|
||||
}
|
||||
]
|
||||
|
||||
export function getTemplate(id: LayoutTemplateId): LayoutTemplate | undefined {
|
||||
return LAYOUT_TEMPLATES.find((t) => t.id === id)
|
||||
}
|
||||
|
||||
export interface GridOverride {
|
||||
zoneOrder?: string[]
|
||||
columnFractions?: number[]
|
||||
rowFractions?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CSS grid-template string from a template and optional overrides.
|
||||
* When overrides are provided, zone order and column/row fractions are adjusted.
|
||||
* Returns the original gridTemplate if no overrides apply.
|
||||
*/
|
||||
export function buildGridTemplate(
|
||||
template: LayoutTemplate,
|
||||
overrides?: GridOverride
|
||||
): string {
|
||||
if (!overrides) return template.gridTemplate
|
||||
|
||||
const { zoneOrder, columnFractions, rowFractions } = overrides
|
||||
|
||||
// Parse the template's grid areas to determine row/column structure
|
||||
const areaLines = template.gridTemplate
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith('"'))
|
||||
|
||||
if (areaLines.length === 0) return template.gridTemplate
|
||||
|
||||
// Extract area names per row and row fractions
|
||||
const rows = areaLines.map((line) => {
|
||||
const match = line.match(/"([^"]+)"\s*(.*)/)
|
||||
if (!match) return { areas: [] as string[], fraction: '1fr' }
|
||||
const areas = match[1].split(/\s+/)
|
||||
const fraction = match[2].trim() || '1fr'
|
||||
return { areas, fraction }
|
||||
})
|
||||
|
||||
// Determine unique column count from first row
|
||||
const colCount = rows[0]?.areas.length ?? 0
|
||||
// Apply zone order reordering if provided
|
||||
let reorderedRows = rows
|
||||
if (zoneOrder && zoneOrder.length > 0) {
|
||||
// Build a mapping from old position to new position
|
||||
const allAreas = rows.flatMap((r) => r.areas)
|
||||
const uniqueAreas = [...new Set(allAreas)]
|
||||
const reorderMap = new Map<string, string>()
|
||||
for (let i = 0; i < Math.min(zoneOrder.length, uniqueAreas.length); i++) {
|
||||
reorderMap.set(uniqueAreas[i], zoneOrder[i])
|
||||
}
|
||||
|
||||
reorderedRows = rows.map((row) => ({
|
||||
...row,
|
||||
areas: row.areas.map((a) => reorderMap.get(a) ?? a)
|
||||
}))
|
||||
}
|
||||
|
||||
// Build row fraction strings
|
||||
const rowFrStrs = reorderedRows.map((row, i) => {
|
||||
if (rowFractions && i < rowFractions.length) {
|
||||
return `${rowFractions[i]}fr`
|
||||
}
|
||||
return row.fraction
|
||||
})
|
||||
|
||||
// Build column fraction string
|
||||
let colStr: string
|
||||
if (columnFractions && columnFractions.length === colCount) {
|
||||
colStr = columnFractions.map((f) => `${f}fr`).join(' ')
|
||||
} else {
|
||||
// Extract original column definitions from the "/" line
|
||||
const slashLine = template.gridTemplate
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
colStr = slashLine ? slashLine.substring(1).trim() : '1fr '.repeat(colCount)
|
||||
}
|
||||
|
||||
// Assemble
|
||||
const areaStrs = reorderedRows.map(
|
||||
(row, i) => `"${row.areas.join(' ')}" ${rowFrStrs[i]}`
|
||||
)
|
||||
|
||||
return `\n ${areaStrs.join('\n ')}\n / ${colStr}\n `
|
||||
}
|
||||
50
src/components/builder/useBuilderRename.ts
Normal file
50
src/components/builder/useBuilderRename.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ResolvedArrangeWidget } from '@/components/builder/useZoneWidgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
export function useBuilderRename(
|
||||
getWidget: (key: string) => ResolvedArrangeWidget | undefined
|
||||
) {
|
||||
const renamingKey = ref<string | null>(null)
|
||||
const renameValue = ref('')
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function startRename(itemKey: string) {
|
||||
const w = getWidget(itemKey)
|
||||
if (!w) return
|
||||
renameValue.value = w.widget.label || w.widget.name
|
||||
renamingKey.value = itemKey
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (!renamingKey.value) return
|
||||
const w = getWidget(renamingKey.value)
|
||||
if (w) {
|
||||
const trimmed = renameValue.value.trim()
|
||||
if (trimmed) {
|
||||
renameWidget(w.widget, w.node, trimmed)
|
||||
canvasStore.canvas?.setDirty(true)
|
||||
}
|
||||
}
|
||||
renamingKey.value = null
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
renamingKey.value = null
|
||||
}
|
||||
|
||||
function startRenameDeferred(itemKey: string) {
|
||||
setTimeout(() => startRename(itemKey), 50)
|
||||
}
|
||||
|
||||
return {
|
||||
renamingKey,
|
||||
renameValue,
|
||||
startRename,
|
||||
confirmRename,
|
||||
cancelRename,
|
||||
startRenameDeferred
|
||||
}
|
||||
}
|
||||
234
src/components/builder/useGroupDrop.ts
Normal file
234
src/components/builder/useGroupDrop.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
import {
|
||||
inputItemKey,
|
||||
parseInputItemKey
|
||||
} from '@/components/builder/itemKeyHelper'
|
||||
import { getEdgeTriZone } from '@/components/builder/useWidgetReorder'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
function getDragItemKey(data: Record<string | symbol, unknown>): string | null {
|
||||
if (data.type === 'zone-widget')
|
||||
return inputItemKey(data.nodeId as string, data.widgetName as string)
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Group body drop target ---
|
||||
|
||||
interface GroupDropBinding {
|
||||
groupId: string
|
||||
zoneId: string
|
||||
}
|
||||
|
||||
type GroupDropEl = HTMLElement & {
|
||||
__groupDropCleanup?: () => void
|
||||
__groupDropValue?: GroupDropBinding
|
||||
}
|
||||
|
||||
/** Drop zone for the group body — accepts zone-widget drags. */
|
||||
export const vGroupDropTarget: Directive<HTMLElement, GroupDropBinding> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupDropEl
|
||||
typedEl.__groupDropValue = value
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
typedEl.__groupDropCleanup = dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) => {
|
||||
const itemKey = getDragItemKey(source.data)
|
||||
if (!itemKey) return false
|
||||
const group = appModeStore.inputGroups.find(
|
||||
(g) => g.id === typedEl.__groupDropValue!.groupId
|
||||
)
|
||||
return !group?.items.some((i) => i.key === itemKey)
|
||||
},
|
||||
onDragEnter: () => el.classList.add('group-drag-over'),
|
||||
onDragLeave: () => el.classList.remove('group-drag-over'),
|
||||
onDrop: ({ source, location }) => {
|
||||
el.classList.remove('group-drag-over')
|
||||
// Skip if the innermost drop target is a child (item reorder handled it)
|
||||
if (location.current.dropTargets[0]?.element !== el) return
|
||||
const itemKey = getDragItemKey(source.data)
|
||||
if (!itemKey) return
|
||||
const { groupId, zoneId } = typedEl.__groupDropValue!
|
||||
appModeStore.moveWidgetItem(itemKey, {
|
||||
kind: 'group',
|
||||
zoneId,
|
||||
groupId
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupDropEl).__groupDropValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupDropEl).__groupDropCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group item reorder (with center detection for pairing) ---
|
||||
|
||||
interface GroupItemReorderBinding {
|
||||
itemKey: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
type GroupItemReorderEl = HTMLElement & {
|
||||
__groupReorderCleanup?: () => void
|
||||
__groupReorderValue?: GroupItemReorderBinding
|
||||
}
|
||||
|
||||
function clearGroupIndicator(el: HTMLElement) {
|
||||
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
|
||||
}
|
||||
|
||||
function setGroupIndicator(
|
||||
el: HTMLElement,
|
||||
edge: 'before' | 'center' | 'after'
|
||||
) {
|
||||
clearGroupIndicator(el)
|
||||
if (edge === 'center') {
|
||||
el.classList.add('pair-indicator')
|
||||
} else {
|
||||
el.classList.add(`reorder-${edge}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorder within a group with three-zone detection for side-by-side pairing. */
|
||||
export const vGroupItemReorderTarget: Directive<
|
||||
HTMLElement,
|
||||
GroupItemReorderBinding
|
||||
> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupItemReorderEl
|
||||
typedEl.__groupReorderValue = value
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
typedEl.__groupReorderCleanup = dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) => {
|
||||
const dragKey = getDragItemKey(source.data)
|
||||
return !!dragKey && dragKey !== typedEl.__groupReorderValue!.itemKey
|
||||
},
|
||||
onDrag: ({ location }) => {
|
||||
setGroupIndicator(
|
||||
el,
|
||||
getEdgeTriZone(el, location.current.input.clientY)
|
||||
)
|
||||
},
|
||||
onDragEnter: ({ location }) => {
|
||||
setGroupIndicator(
|
||||
el,
|
||||
getEdgeTriZone(el, location.current.input.clientY)
|
||||
)
|
||||
},
|
||||
onDragLeave: () => clearGroupIndicator(el),
|
||||
onDrop: ({ source, location }) => {
|
||||
clearGroupIndicator(el)
|
||||
const dragKey = getDragItemKey(source.data)
|
||||
if (!dragKey) return
|
||||
|
||||
const { groupId, itemKey } = typedEl.__groupReorderValue!
|
||||
const edge = getEdgeTriZone(el, location.current.input.clientY)
|
||||
|
||||
appModeStore.moveWidgetItem(dragKey, {
|
||||
kind: 'group-relative',
|
||||
zoneId: '',
|
||||
groupId,
|
||||
targetKey: itemKey,
|
||||
edge
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupItemReorderEl).__groupReorderValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupItemReorderEl).__groupReorderCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Draggable for items inside a group ---
|
||||
|
||||
interface GroupItemDragBinding {
|
||||
itemKey: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
type GroupItemDragEl = HTMLElement & {
|
||||
__groupItemDragCleanup?: () => void
|
||||
__groupItemDragValue?: GroupItemDragBinding
|
||||
}
|
||||
|
||||
/** Makes an item inside a group draggable. */
|
||||
export const vGroupItemDraggable: Directive<HTMLElement, GroupItemDragBinding> =
|
||||
{
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupItemDragEl
|
||||
typedEl.__groupItemDragValue = value
|
||||
|
||||
typedEl.__groupItemDragCleanup = draggable({
|
||||
element: el,
|
||||
getInitialData: () => {
|
||||
const parsed = parseInputItemKey(
|
||||
typedEl.__groupItemDragValue!.itemKey
|
||||
)
|
||||
return {
|
||||
type: 'zone-widget',
|
||||
nodeId: parsed?.nodeId ?? '',
|
||||
widgetName: parsed?.widgetName ?? '',
|
||||
sourceZone: '__group__',
|
||||
sourceGroupId: typedEl.__groupItemDragValue!.groupId
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupItemDragEl).__groupItemDragValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupItemDragEl).__groupItemDragCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Draggable for entire group (reorder within zone) ---
|
||||
|
||||
interface GroupDragBinding {
|
||||
groupId: string
|
||||
zone: string
|
||||
}
|
||||
|
||||
type GroupDragEl = HTMLElement & {
|
||||
__groupDragCleanup?: () => void
|
||||
__groupDragValue?: GroupDragBinding
|
||||
}
|
||||
|
||||
/** Makes a group draggable within the zone order. Uses drag-handle class. */
|
||||
export const vGroupDraggable: Directive<HTMLElement, GroupDragBinding> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupDragEl
|
||||
typedEl.__groupDragValue = value
|
||||
|
||||
typedEl.__groupDragCleanup = draggable({
|
||||
element: el,
|
||||
dragHandle: el.querySelector('.drag-handle') ?? undefined,
|
||||
getInitialData: () => ({
|
||||
type: 'zone-group',
|
||||
groupId: typedEl.__groupDragValue!.groupId,
|
||||
sourceZone: typedEl.__groupDragValue!.zone
|
||||
})
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupDragEl).__groupDragValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupDragEl).__groupDragCleanup?.()
|
||||
}
|
||||
}
|
||||
201
src/components/builder/useInputGroups.test.ts
Normal file
201
src/components/builder/useInputGroups.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
const mockResolveNodeWidget =
|
||||
vi.fn<(...args: unknown[]) => [LGraphNode, IBaseWidget] | [LGraphNode] | []>()
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNodeWidget: (...args: unknown[]) => mockResolveNodeWidget(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import {
|
||||
autoGroupName,
|
||||
groupedByPair,
|
||||
resolveGroupItems
|
||||
} from './useInputGroups'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function makeNode(id: string): LGraphNode {
|
||||
return { id } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeWidget(name: string, label?: string): IBaseWidget {
|
||||
return { name, label } as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
function makeGroup(items: { key: string; pairId?: string }[]): InputGroup {
|
||||
return { id: 'g1', name: null, items }
|
||||
}
|
||||
|
||||
function makeResolvedItem(key: string, opts: { pairId?: string } = {}) {
|
||||
return {
|
||||
key,
|
||||
pairId: opts.pairId,
|
||||
node: makeNode('1'),
|
||||
widget: makeWidget('w'),
|
||||
nodeId: '1',
|
||||
widgetName: 'w'
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupedByPair', () => {
|
||||
it('returns empty for empty input', () => {
|
||||
expect(groupedByPair([])).toEqual([])
|
||||
})
|
||||
|
||||
it('treats all items without pairId as singles', () => {
|
||||
const items = [makeResolvedItem('a'), makeResolvedItem('b')]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toMatchObject({ type: 'single' })
|
||||
expect(rows[1]).toMatchObject({ type: 'single' })
|
||||
})
|
||||
|
||||
it('pairs two items with matching pairId', () => {
|
||||
const items = [
|
||||
makeResolvedItem('a', { pairId: 'p1' }),
|
||||
makeResolvedItem('b', { pairId: 'p1' })
|
||||
]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].type).toBe('pair')
|
||||
if (rows[0].type === 'pair') {
|
||||
expect(rows[0].items[0].key).toBe('a')
|
||||
expect(rows[0].items[1].key).toBe('b')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders orphaned pairId (no partner) as single', () => {
|
||||
const items = [makeResolvedItem('a', { pairId: 'lonely' })]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ type: 'single' })
|
||||
})
|
||||
|
||||
it('handles mixed singles and pairs', () => {
|
||||
const items = [
|
||||
makeResolvedItem('a'),
|
||||
makeResolvedItem('b', { pairId: 'p1' }),
|
||||
makeResolvedItem('c', { pairId: 'p1' }),
|
||||
makeResolvedItem('d')
|
||||
]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0]).toMatchObject({ type: 'single' })
|
||||
expect(rows[1]).toMatchObject({ type: 'pair' })
|
||||
expect(rows[2]).toMatchObject({ type: 'single' })
|
||||
})
|
||||
|
||||
it('pairs first two of three items with same pairId, third becomes single', () => {
|
||||
const items = [
|
||||
makeResolvedItem('a', { pairId: 'p1' }),
|
||||
makeResolvedItem('b', { pairId: 'p1' }),
|
||||
makeResolvedItem('c', { pairId: 'p1' })
|
||||
]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].type).toBe('pair')
|
||||
expect(rows[1]).toMatchObject({ type: 'single' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoGroupName', () => {
|
||||
it('joins widget labels with comma', () => {
|
||||
mockResolveNodeWidget
|
||||
.mockReturnValueOnce([makeNode('1'), makeWidget('w1', 'Width')])
|
||||
.mockReturnValueOnce([makeNode('2'), makeWidget('w2', 'Height')])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:w1' }, { key: 'input:2:w2' }])
|
||||
|
||||
expect(autoGroupName(group)).toBe('Width, Height')
|
||||
})
|
||||
|
||||
it('falls back to widget name when label is absent', () => {
|
||||
mockResolveNodeWidget.mockReturnValueOnce([
|
||||
makeNode('1'),
|
||||
makeWidget('steps')
|
||||
])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:steps' }])
|
||||
expect(autoGroupName(group)).toBe('steps')
|
||||
})
|
||||
|
||||
it('returns untitled key when no widgets resolve', () => {
|
||||
mockResolveNodeWidget.mockReturnValue([])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:w' }])
|
||||
expect(autoGroupName(group)).toBe('linearMode.groups.untitled')
|
||||
})
|
||||
|
||||
it('skips non-input keys', () => {
|
||||
mockResolveNodeWidget.mockReturnValueOnce([
|
||||
makeNode('1'),
|
||||
makeWidget('w', 'OK')
|
||||
])
|
||||
|
||||
const group = makeGroup([{ key: 'output:1:w' }, { key: 'input:1:w' }])
|
||||
|
||||
expect(autoGroupName(group)).toBe('OK')
|
||||
expect(mockResolveNodeWidget).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveGroupItems', () => {
|
||||
it('filters out items where resolveNodeWidget returns empty', () => {
|
||||
mockResolveNodeWidget
|
||||
.mockReturnValueOnce([makeNode('1'), makeWidget('w1')])
|
||||
.mockReturnValueOnce([])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:w1' }, { key: 'input:2:missing' }])
|
||||
const resolved = resolveGroupItems(group)
|
||||
|
||||
expect(resolved).toHaveLength(1)
|
||||
expect(resolved[0].widgetName).toBe('w1')
|
||||
})
|
||||
|
||||
it('handles widget names containing colons', () => {
|
||||
mockResolveNodeWidget.mockReturnValueOnce([
|
||||
makeNode('5'),
|
||||
makeWidget('a:b:c')
|
||||
])
|
||||
|
||||
const group = makeGroup([{ key: 'input:5:a:b:c' }])
|
||||
const resolved = resolveGroupItems(group)
|
||||
|
||||
expect(resolved).toHaveLength(1)
|
||||
expect(resolved[0].nodeId).toBe('5')
|
||||
expect(resolved[0].widgetName).toBe('a:b:c')
|
||||
})
|
||||
|
||||
it('skips non-input keys', () => {
|
||||
const group = makeGroup([{ key: 'other:1:w' }])
|
||||
const resolved = resolveGroupItems(group)
|
||||
|
||||
expect(resolved).toHaveLength(0)
|
||||
expect(mockResolveNodeWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves pairId on resolved items', () => {
|
||||
mockResolveNodeWidget.mockReturnValueOnce([makeNode('1'), makeWidget('w')])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:w', pairId: 'p1' }])
|
||||
const resolved = resolveGroupItems(group)
|
||||
|
||||
expect(resolved[0].pairId).toBe('p1')
|
||||
})
|
||||
})
|
||||
88
src/components/builder/useInputGroups.ts
Normal file
88
src/components/builder/useInputGroups.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { parseInputItemKey } from '@/components/builder/itemKeyHelper'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
|
||||
interface ResolvedGroupItem {
|
||||
key: string
|
||||
pairId?: string
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
/** Row of items to render — single or side-by-side pair. */
|
||||
type GroupRow =
|
||||
| { type: 'single'; item: ResolvedGroupItem }
|
||||
| { type: 'pair'; items: [ResolvedGroupItem, ResolvedGroupItem] }
|
||||
|
||||
/** Derive a group name from the labels of its contained widgets. */
|
||||
export function autoGroupName(group: InputGroup): string {
|
||||
const labels: string[] = []
|
||||
for (const item of group.items) {
|
||||
const parsed = parseInputItemKey(item.key)
|
||||
if (!parsed) continue
|
||||
const [, widget] = resolveNodeWidget(parsed.nodeId, parsed.widgetName)
|
||||
if (widget) labels.push(widget.label || widget.name)
|
||||
}
|
||||
return labels.join(', ') || t('linearMode.groups.untitled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve item keys to widget/node data.
|
||||
* Items whose node or widget cannot be resolved are silently omitted
|
||||
* from the result — callers should not rely on a 1:1 mapping with group.items.
|
||||
*/
|
||||
export function resolveGroupItems(group: InputGroup): ResolvedGroupItem[] {
|
||||
const resolved: ResolvedGroupItem[] = []
|
||||
for (const item of group.items) {
|
||||
const parsed = parseInputItemKey(item.key)
|
||||
if (!parsed) continue
|
||||
const { nodeId, widgetName } = parsed
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (node && widget) {
|
||||
resolved.push({
|
||||
key: item.key,
|
||||
pairId: item.pairId,
|
||||
node,
|
||||
widget,
|
||||
nodeId,
|
||||
widgetName
|
||||
})
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/** Group resolved items into rows, pairing items with matching pairId. */
|
||||
export function groupedByPair(items: ResolvedGroupItem[]): GroupRow[] {
|
||||
const rows: GroupRow[] = []
|
||||
const paired = new Set<string>()
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (paired.has(item.key)) continue
|
||||
|
||||
if (item.pairId) {
|
||||
const partner = items.find(
|
||||
(other) =>
|
||||
other.key !== item.key &&
|
||||
other.pairId === item.pairId &&
|
||||
!paired.has(other.key)
|
||||
)
|
||||
if (partner) {
|
||||
paired.add(item.key)
|
||||
paired.add(partner.key)
|
||||
rows.push({ type: 'pair', items: [item, partner] })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({ type: 'single', item })
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
17
src/components/builder/useLinearRunPrompt.ts
Normal file
17
src/components/builder/useLinearRunPrompt.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
export function useLinearRunPrompt() {
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
async function runPrompt(e: Event) {
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: { subscribe_to_run: false, trigger_source: 'linear' }
|
||||
})
|
||||
}
|
||||
|
||||
return { runPrompt }
|
||||
}
|
||||
153
src/components/builder/useWidgetReorder.ts
Normal file
153
src/components/builder/useWidgetReorder.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
import { groupItemKey, inputItemKey } from '@/components/builder/itemKeyHelper'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
/** Determine if cursor is in the top or bottom half of the element. */
|
||||
function getEdge(el: HTMLElement, clientY: number): 'before' | 'after' {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
/** Three-zone detection: top third = before, center = pair, bottom third = after. */
|
||||
export function getEdgeTriZone(
|
||||
el: HTMLElement,
|
||||
clientY: number
|
||||
): 'before' | 'center' | 'after' {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const third = rect.height / 3
|
||||
if (clientY < rect.top + third) return 'before'
|
||||
if (clientY > rect.top + third * 2) return 'after'
|
||||
return 'center'
|
||||
}
|
||||
|
||||
function clearIndicator(el: HTMLElement) {
|
||||
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
|
||||
}
|
||||
|
||||
function setIndicator(el: HTMLElement, edge: 'before' | 'after' | 'center') {
|
||||
clearIndicator(el)
|
||||
if (edge === 'center') el.classList.add('pair-indicator')
|
||||
else el.classList.add(`reorder-${edge}`)
|
||||
}
|
||||
|
||||
/** Extract item key from drag data. */
|
||||
function getDragKey(data: Record<string | symbol, unknown>): string | null {
|
||||
if (data.type === 'zone-widget')
|
||||
return inputItemKey(data.nodeId as string, data.widgetName as string)
|
||||
if (data.type === 'zone-output') return `output:${data.nodeId}`
|
||||
if (data.type === 'zone-run-controls') return 'run-controls'
|
||||
if (data.type === 'zone-preset-strip') return 'preset-strip'
|
||||
if (data.type === 'zone-group') return groupItemKey(data.groupId as string)
|
||||
return null
|
||||
}
|
||||
|
||||
function getDragZone(data: Record<string | symbol, unknown>): string | null {
|
||||
return (data.sourceZone as string) ?? null
|
||||
}
|
||||
|
||||
/** Both keys are input widgets — eligible for center-drop pairing. */
|
||||
function canPairKeys(a: string, b: string): boolean {
|
||||
return a.startsWith('input:') && b.startsWith('input:')
|
||||
}
|
||||
|
||||
// --- Unified reorder drop target ---
|
||||
|
||||
interface ZoneItemReorderBinding {
|
||||
/** The item key for this drop target (e.g. "input:5:steps", "output:7", "run-controls"). */
|
||||
itemKey: string
|
||||
/** The zone this item belongs to. */
|
||||
zone: string
|
||||
}
|
||||
|
||||
type ReorderEl = HTMLElement & {
|
||||
__reorderCleanup?: () => void
|
||||
__reorderValue?: ZoneItemReorderBinding
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified reorder directive — any zone item (input, output, run controls)
|
||||
* can be reordered relative to any other item in the same zone.
|
||||
* When two input widgets are involved, center-drop creates a paired group.
|
||||
*/
|
||||
export const vZoneItemReorderTarget: Directive<
|
||||
HTMLElement,
|
||||
ZoneItemReorderBinding
|
||||
> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as ReorderEl
|
||||
typedEl.__reorderValue = value
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
typedEl.__reorderCleanup = dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) => {
|
||||
const dragKey = getDragKey(source.data)
|
||||
const dragZone = getDragZone(source.data)
|
||||
if (!dragKey || !dragZone) return false
|
||||
// Same zone or from a group, different item
|
||||
return (
|
||||
(dragZone === typedEl.__reorderValue!.zone ||
|
||||
dragZone === '__group__') &&
|
||||
dragKey !== typedEl.__reorderValue!.itemKey
|
||||
)
|
||||
},
|
||||
onDrag: ({ location, source }) => {
|
||||
const dragKey = getDragKey(source.data)
|
||||
const targetKey = typedEl.__reorderValue!.itemKey
|
||||
const pairingAllowed = dragKey && canPairKeys(dragKey, targetKey)
|
||||
const edge = pairingAllowed
|
||||
? getEdgeTriZone(el, location.current.input.clientY)
|
||||
: getEdge(el, location.current.input.clientY)
|
||||
setIndicator(el, edge)
|
||||
},
|
||||
onDragEnter: ({ location, source }) => {
|
||||
const dragKey = getDragKey(source.data)
|
||||
const targetKey = typedEl.__reorderValue!.itemKey
|
||||
const pairingAllowed = dragKey && canPairKeys(dragKey, targetKey)
|
||||
const edge = pairingAllowed
|
||||
? getEdgeTriZone(el, location.current.input.clientY)
|
||||
: getEdge(el, location.current.input.clientY)
|
||||
setIndicator(el, edge)
|
||||
},
|
||||
onDragLeave: () => clearIndicator(el),
|
||||
onDrop: ({ source, location, self }) => {
|
||||
clearIndicator(el)
|
||||
// Skip if a nested drop target (e.g. group body) is the innermost target
|
||||
const innermost = location.current.dropTargets[0]
|
||||
if (innermost && innermost.element !== self.element) return
|
||||
|
||||
const dragKey = getDragKey(source.data)
|
||||
if (!dragKey) return
|
||||
|
||||
const { zone, itemKey } = typedEl.__reorderValue!
|
||||
const pairingAllowed = canPairKeys(dragKey, itemKey)
|
||||
const edge = pairingAllowed
|
||||
? getEdgeTriZone(el, location.current.input.clientY)
|
||||
: getEdge(el, location.current.input.clientY)
|
||||
|
||||
if (edge === 'center') {
|
||||
appModeStore.moveWidgetItem(dragKey, {
|
||||
kind: 'zone-pair',
|
||||
zoneId: zone,
|
||||
targetKey: itemKey
|
||||
})
|
||||
} else {
|
||||
appModeStore.moveWidgetItem(dragKey, {
|
||||
kind: 'zone-relative',
|
||||
zoneId: zone,
|
||||
targetKey: itemKey,
|
||||
edge
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as ReorderEl).__reorderValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as ReorderEl).__reorderCleanup?.()
|
||||
}
|
||||
}
|
||||
145
src/components/builder/useZoneDrop.ts
Normal file
145
src/components/builder/useZoneDrop.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
import { inputItemKey } from '@/components/builder/itemKeyHelper'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
interface WidgetDragData {
|
||||
type: 'zone-widget'
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
sourceZone: string
|
||||
}
|
||||
|
||||
interface RunControlsDragData {
|
||||
type: 'zone-run-controls'
|
||||
sourceZone: string
|
||||
}
|
||||
|
||||
interface PresetStripDragData {
|
||||
type: 'zone-preset-strip'
|
||||
sourceZone: string
|
||||
}
|
||||
|
||||
function isWidgetDragData(
|
||||
data: Record<string | symbol, unknown>
|
||||
): data is Record<string | symbol, unknown> & WidgetDragData {
|
||||
return data.type === 'zone-widget'
|
||||
}
|
||||
|
||||
function isRunControlsDragData(
|
||||
data: Record<string | symbol, unknown>
|
||||
): data is Record<string | symbol, unknown> & RunControlsDragData {
|
||||
return data.type === 'zone-run-controls'
|
||||
}
|
||||
|
||||
function isPresetStripDragData(
|
||||
data: Record<string | symbol, unknown>
|
||||
): data is Record<string | symbol, unknown> & PresetStripDragData {
|
||||
return data.type === 'zone-preset-strip'
|
||||
}
|
||||
|
||||
interface GroupDragData {
|
||||
type: 'zone-group'
|
||||
groupId: string
|
||||
sourceZone: string
|
||||
}
|
||||
|
||||
function isGroupDragData(
|
||||
data: Record<string | symbol, unknown>
|
||||
): data is Record<string | symbol, unknown> & GroupDragData {
|
||||
return data.type === 'zone-group'
|
||||
}
|
||||
|
||||
interface DragBindingValue {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
zone: string
|
||||
}
|
||||
|
||||
type DragEl = HTMLElement & {
|
||||
__dragCleanup?: () => void
|
||||
__dragValue?: DragBindingValue
|
||||
__zoneId?: string
|
||||
}
|
||||
|
||||
export const vWidgetDraggable: Directive<HTMLElement, DragBindingValue> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as DragEl
|
||||
typedEl.__dragValue = value
|
||||
typedEl.__dragCleanup = draggable({
|
||||
element: el,
|
||||
getInitialData: () => ({
|
||||
type: 'zone-widget',
|
||||
nodeId: typedEl.__dragValue!.nodeId,
|
||||
widgetName: typedEl.__dragValue!.widgetName,
|
||||
sourceZone: typedEl.__dragValue!.zone
|
||||
})
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as DragEl).__dragValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as DragEl).__dragCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
export const vZoneDropTarget: Directive<HTMLElement, string> = {
|
||||
mounted(el, { value: zoneId }) {
|
||||
const typedEl = el as DragEl
|
||||
typedEl.__zoneId = zoneId
|
||||
const appModeStore = useAppModeStore()
|
||||
typedEl.__dragCleanup = dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) => {
|
||||
const data = source.data
|
||||
if (isWidgetDragData(data)) return data.sourceZone !== typedEl.__zoneId
|
||||
if (isRunControlsDragData(data))
|
||||
return data.sourceZone !== typedEl.__zoneId
|
||||
if (isPresetStripDragData(data))
|
||||
return data.sourceZone !== typedEl.__zoneId
|
||||
if (isGroupDragData(data)) return data.sourceZone !== typedEl.__zoneId
|
||||
return false
|
||||
},
|
||||
onDragEnter: () => el.classList.add('zone-drag-over'),
|
||||
onDragLeave: () => el.classList.remove('zone-drag-over'),
|
||||
onDrop: ({ source, location, self }) => {
|
||||
el.classList.remove('zone-drag-over')
|
||||
// Skip if a nested drop target (e.g. group body) is the innermost target
|
||||
const innermost = location.current.dropTargets[0]
|
||||
if (innermost && innermost.element !== self.element) return
|
||||
|
||||
const data = source.data
|
||||
if (isWidgetDragData(data)) {
|
||||
const itemKey = inputItemKey(data.nodeId, data.widgetName)
|
||||
appModeStore.moveWidgetItem(itemKey, {
|
||||
kind: 'zone',
|
||||
zoneId: typedEl.__zoneId!
|
||||
})
|
||||
appModeStore.setZone(data.nodeId, data.widgetName, typedEl.__zoneId!)
|
||||
} else if (isRunControlsDragData(data)) {
|
||||
appModeStore.setRunControlsZone(typedEl.__zoneId!)
|
||||
} else if (isPresetStripDragData(data)) {
|
||||
appModeStore.setPresetStripZone(typedEl.__zoneId!)
|
||||
} else if (isGroupDragData(data)) {
|
||||
appModeStore.moveGroupToZone(
|
||||
data.groupId,
|
||||
data.sourceZone,
|
||||
typedEl.__zoneId!
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value: zoneId }) {
|
||||
;(el as DragEl).__zoneId = zoneId
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as DragEl).__dragCleanup?.()
|
||||
}
|
||||
}
|
||||
118
src/components/builder/useZoneWidgets.test.ts
Normal file
118
src/components/builder/useZoneWidgets.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
extractVueNodeData: vi.fn()
|
||||
}))
|
||||
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
|
||||
isPromotedWidgetView: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/litegraph/src/types/globalEnums', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
LGraphEventMode: { ALWAYS: 0 }
|
||||
}))
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNodeWidget: vi.fn()
|
||||
}))
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: vi.fn()
|
||||
}))
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: vi.fn()
|
||||
}))
|
||||
|
||||
import { inputsForZone } from './useZoneWidgets'
|
||||
|
||||
describe('useZoneWidgets', () => {
|
||||
describe('inputsForZone', () => {
|
||||
const inputs: [NodeId, string][] = [
|
||||
[1, 'prompt'],
|
||||
[2, 'width'],
|
||||
[1, 'steps'],
|
||||
[3, 'seed']
|
||||
]
|
||||
|
||||
function makeGetZone(
|
||||
assignments: Record<string, string>
|
||||
): (nodeId: NodeId, widgetName: string) => string | undefined {
|
||||
return (nodeId, widgetName) => assignments[`${nodeId}:${widgetName}`]
|
||||
}
|
||||
|
||||
it('returns inputs matching the given zone', () => {
|
||||
const getZone = makeGetZone({
|
||||
'1:prompt': 'z1',
|
||||
'2:width': 'z2',
|
||||
'1:steps': 'z1',
|
||||
'3:seed': 'z2'
|
||||
})
|
||||
|
||||
const result = inputsForZone(inputs, getZone, 'z1')
|
||||
expect(result).toEqual([
|
||||
[1, 'prompt'],
|
||||
[1, 'steps']
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty array when no inputs match', () => {
|
||||
const getZone = makeGetZone({
|
||||
'1:prompt': 'z1',
|
||||
'2:width': 'z1'
|
||||
})
|
||||
|
||||
const result = inputsForZone(inputs, getZone, 'z2')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('handles empty inputs', () => {
|
||||
const getZone = makeGetZone({})
|
||||
expect(inputsForZone([], getZone, 'z1')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles unassigned inputs (getZone returns undefined)', () => {
|
||||
const getZone = makeGetZone({ '1:prompt': 'z1' })
|
||||
|
||||
// Only 1:prompt is assigned to z1; rest are undefined
|
||||
const result = inputsForZone(inputs, getZone, 'z1')
|
||||
expect(result).toEqual([[1, 'prompt']])
|
||||
})
|
||||
|
||||
it('routes unassigned inputs to defaultZoneId when provided', () => {
|
||||
const getZone = makeGetZone({ '1:prompt': 'z1' })
|
||||
|
||||
const z1 = inputsForZone(inputs, getZone, 'z1', 'z1')
|
||||
const z2 = inputsForZone(inputs, getZone, 'z2', 'z1')
|
||||
|
||||
// 1:prompt is explicitly z1; unassigned ones also go to z1 (default)
|
||||
expect(z1).toEqual([
|
||||
[1, 'prompt'],
|
||||
[2, 'width'],
|
||||
[1, 'steps'],
|
||||
[3, 'seed']
|
||||
])
|
||||
// z2 gets nothing since unassigned defaults to z1
|
||||
expect(z2).toEqual([])
|
||||
})
|
||||
|
||||
it('filters non-contiguous inputs for the same node across zones', () => {
|
||||
const getZone = makeGetZone({
|
||||
'1:prompt': 'z1',
|
||||
'2:width': 'z2',
|
||||
'1:steps': 'z2', // same node 1, different zone
|
||||
'3:seed': 'z1'
|
||||
})
|
||||
|
||||
const z1 = inputsForZone(inputs, getZone, 'z1')
|
||||
const z2 = inputsForZone(inputs, getZone, 'z2')
|
||||
|
||||
expect(z1).toEqual([
|
||||
[1, 'prompt'],
|
||||
[3, 'seed']
|
||||
])
|
||||
expect(z2).toEqual([
|
||||
[2, 'width'],
|
||||
[1, 'steps']
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
62
src/components/builder/useZoneWidgets.ts
Normal file
62
src/components/builder/useZoneWidgets.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getTemplate } from '@/components/builder/layoutTemplates'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
|
||||
export interface ResolvedArrangeWidget {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export function inputsForZone(
|
||||
selectedInputs: [NodeId, string][],
|
||||
getZone: (nodeId: NodeId, widgetName: string) => string | undefined,
|
||||
zoneId: string,
|
||||
defaultZoneId?: string
|
||||
): [NodeId, string][] {
|
||||
return selectedInputs.filter(([nodeId, widgetName]) => {
|
||||
const assigned = getZone(nodeId, widgetName)
|
||||
if (assigned) return assigned === zoneId
|
||||
return defaultZoneId ? zoneId === defaultZoneId : false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for builder arrange mode.
|
||||
* Returns a computed Map<zoneId, resolved widget items[]>.
|
||||
*/
|
||||
export function useArrangeZoneWidgets() {
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const template = computed(
|
||||
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('single')!
|
||||
)
|
||||
|
||||
return computed(() => {
|
||||
const map = new Map<string, ResolvedArrangeWidget[]>()
|
||||
const defaultZoneId = template.value.zones[0]?.id
|
||||
|
||||
for (const zone of template.value.zones) {
|
||||
const inputs = inputsForZone(
|
||||
appModeStore.selectedInputs,
|
||||
appModeStore.getZone,
|
||||
zone.id,
|
||||
defaultZoneId
|
||||
)
|
||||
const resolved = inputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
return node && widget ? { nodeId, widgetName, node, widget } : null
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
map.set(zone.id, resolved)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
}
|
||||
136
src/components/common/Dialogue.stories.ts
Normal file
136
src/components/common/Dialogue.stories.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import Dialogue from './Dialogue.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Dialog',
|
||||
component: Dialogue,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
} satisfies Meta<typeof Dialogue>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const WithTitle: Story = {
|
||||
render: (args) => ({
|
||||
components: { Dialogue, Button },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<Dialogue v-bind="args">
|
||||
<template #button>
|
||||
<Button>Open dialog</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
A more descriptive lorem ipsum text...
|
||||
</p>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<Button variant="muted-textonly" size="sm" @click="close">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="close">
|
||||
Ok
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialogue>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
title: 'Modal Title'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithoutTitle: Story = {
|
||||
render: () => ({
|
||||
components: { Dialogue, Button },
|
||||
template: `
|
||||
<Dialogue>
|
||||
<template #button>
|
||||
<Button>Open dialog</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
This dialog has no title header.
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<Button variant="secondary" size="lg" @click="close">
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialogue>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Confirmation: Story = {
|
||||
render: () => ({
|
||||
components: { Dialogue, Button },
|
||||
template: `
|
||||
<Dialogue title="Delete this item?">
|
||||
<template #button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
This action cannot be undone. The item will be permanently removed.
|
||||
</p>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<Button variant="muted-textonly" size="sm" @click="close">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" @click="close">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialogue>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithLink: Story = {
|
||||
render: () => ({
|
||||
components: { Dialogue, Button },
|
||||
template: `
|
||||
<Dialogue title="Modal Title">
|
||||
<template #button>
|
||||
<Button>Open dialog</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
A more descriptive lorem ipsum text...
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="flex items-center gap-2 text-sm text-muted-foreground hover:text-base-foreground">
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
See what's new
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="muted-textonly" size="sm" @click="close">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="close">
|
||||
Ok
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialogue>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
VisuallyHidden
|
||||
} from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -20,6 +21,16 @@ const { src, alt = '' } = defineProps<{
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const isVideo = computed(() => {
|
||||
const videoExt = /\.(mp4|webm|mov)/i
|
||||
return (
|
||||
videoExt.test(src) ||
|
||||
videoExt.test(
|
||||
new URL(src, location.href).searchParams.get('filename') ?? ''
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
@@ -46,7 +57,15 @@ const { t } = useI18n()
|
||||
<i class="icon-[lucide--x] size-5" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<video
|
||||
v-if="isVideo"
|
||||
:src
|
||||
controls
|
||||
autoplay
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src
|
||||
:alt
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
class="flex h-8 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="aspect-square h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@@ -16,7 +16,7 @@
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
</Button>
|
||||
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
|
||||
<div class="relative my-0.25 min-w-[2ch] flex-1 py-1.5">
|
||||
<input
|
||||
ref="inputField"
|
||||
v-bind="inputAttrs"
|
||||
@@ -54,7 +54,7 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="aspect-square h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@@ -142,8 +142,12 @@ const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
|
||||
|
||||
whenever(distanceX, () => {
|
||||
if (disabled) return
|
||||
const delta = ((distanceX.value - dragDelta) / 10) | 0
|
||||
dragDelta += delta * 10
|
||||
// Scale sensitivity: small steps (floats) need less drag distance.
|
||||
// For step >= 1, use 10px per increment. For step < 1, scale proportionally
|
||||
// so 0.01 step requires ~2px per increment instead of 10px.
|
||||
const pxPerStep = step >= 1 ? 10 : Math.max(2, Math.round(step * 100))
|
||||
const delta = ((distanceX.value - dragDelta) / pxPerStep) | 0
|
||||
dragDelta += delta * pxPerStep
|
||||
modelValue.value = clamp(modelValue.value - delta * step)
|
||||
})
|
||||
|
||||
|
||||
@@ -192,15 +192,3 @@ export function curvesToLUT(
|
||||
|
||||
return lut
|
||||
}
|
||||
|
||||
export function curveDataToFloatLUT(
|
||||
curve: CurveData,
|
||||
size: number = 256
|
||||
): Float32Array {
|
||||
const lut = new Float32Array(size)
|
||||
const interpolate = createInterpolator(curve.points, curve.interpolation)
|
||||
for (let i = 0; i < size; i++) {
|
||||
lut[i] = interpolate(i / (size - 1))
|
||||
}
|
||||
return lut
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
node,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
hiddenLabel = false,
|
||||
hiddenWidgetActions = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
@@ -43,6 +44,7 @@ const {
|
||||
node: LGraphNode
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
hiddenLabel?: boolean
|
||||
hiddenWidgetActions?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
@@ -148,6 +150,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
>
|
||||
<!-- widget header -->
|
||||
<div
|
||||
v-if="!hiddenLabel"
|
||||
:class="
|
||||
cn(
|
||||
'mb-1.5 flex min-h-8 min-w-0 items-center justify-between gap-1',
|
||||
|
||||
@@ -143,16 +143,11 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<Button size="icon" @click="handleDownloadSelected">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -161,17 +156,12 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<Button variant="secondary" @click="handleDownloadSelected">
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
|
||||
214
src/components/ui/Popover.stories.ts
Normal file
214
src/components/ui/Popover.stories.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import Popover from './Popover.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Popover',
|
||||
component: Popover,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#1a1a1b' },
|
||||
{ name: 'light', value: '#ffffff' },
|
||||
{ name: 'sidebar', value: '#232326' }
|
||||
]
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof Popover>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/** Default: menu-style popover with action entries. */
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { Popover },
|
||||
template: `
|
||||
<Popover
|
||||
:entries="[
|
||||
{ label: 'Rename', icon: 'icon-[lucide--pencil]', command: () => {} },
|
||||
{ label: 'Duplicate', icon: 'icon-[lucide--copy]', command: () => {} },
|
||||
{ separator: true },
|
||||
{ label: 'Delete', icon: 'icon-[lucide--trash-2]', command: () => {} }
|
||||
]"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Custom trigger button. */
|
||||
export const CustomTrigger: Story = {
|
||||
render: () => ({
|
||||
components: { Popover, Button },
|
||||
template: `
|
||||
<Popover
|
||||
:entries="[
|
||||
{ label: 'Option A', command: () => {} },
|
||||
{ label: 'Option B', command: () => {} }
|
||||
]"
|
||||
>
|
||||
<template #button>
|
||||
<Button variant="outline">Click me</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Action prompt: small inline confirmation bubble.
|
||||
* Use this pattern for contextual Yes/No prompts like
|
||||
* "Group these?", "Align to bottom?", etc. */
|
||||
export const ActionPrompt: Story = {
|
||||
render: () => ({
|
||||
components: { Popover, Button },
|
||||
template: `
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button variant="outline" size="sm">
|
||||
<i class="icon-[lucide--layout-grid] mr-1 size-3.5" />
|
||||
Group
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<p class="text-sm text-muted-foreground">Group into a row?</p>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
class="flex-1"
|
||||
@click="close()"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="flex-1"
|
||||
@click="close()"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Alignment prompt: contextual bubble for zone actions. */
|
||||
export const AlignPrompt: Story = {
|
||||
render: () => ({
|
||||
components: { Popover, Button },
|
||||
template: `
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<i class="icon-[lucide--align-vertical-justify-end] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-1.5 p-1">
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-secondary-background"
|
||||
@click="close()"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-down-to-line] size-4" />
|
||||
Align to bottom
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-secondary-background"
|
||||
@click="close()"
|
||||
>
|
||||
<i class="icon-[lucide--columns-2] size-4" />
|
||||
Group into row
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** On light background — verify popover visibility. */
|
||||
export const OnLightBackground: Story = {
|
||||
parameters: {
|
||||
backgrounds: { default: 'light' }
|
||||
},
|
||||
render: () => ({
|
||||
components: { Popover, Button },
|
||||
template: `
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button>Open popover</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="p-2">
|
||||
<p class="text-sm">Popover on light background</p>
|
||||
<Button size="sm" class="mt-2" @click="close()">Close</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** On sidebar background — verify contrast against dark sidebar. */
|
||||
export const OnSidebarBackground: Story = {
|
||||
parameters: {
|
||||
backgrounds: { default: 'sidebar' }
|
||||
},
|
||||
render: () => ({
|
||||
components: { Popover, Button },
|
||||
template: `
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button>Open popover</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="p-2">
|
||||
<p class="text-sm">Popover on sidebar background</p>
|
||||
<Button size="sm" class="mt-2" @click="close()">Close</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** No arrow variant. */
|
||||
export const NoArrow: Story = {
|
||||
render: () => ({
|
||||
components: { Popover },
|
||||
template: `
|
||||
<Popover
|
||||
:show-arrow="false"
|
||||
:entries="[
|
||||
{ label: 'Settings', icon: 'icon-[lucide--settings]', command: () => {} },
|
||||
{ label: 'Help', icon: 'icon-[lucide--circle-help]', command: () => {} }
|
||||
]"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Disabled entry. */
|
||||
export const WithDisabled: Story = {
|
||||
render: () => ({
|
||||
components: { Popover },
|
||||
template: `
|
||||
<Popover
|
||||
:entries="[
|
||||
{ label: 'Available', command: () => {} },
|
||||
{ label: 'Coming soon', disabled: true }
|
||||
]"
|
||||
/>
|
||||
`
|
||||
})
|
||||
}
|
||||
31
src/components/ui/TypeformPopoverButton.stories.ts
Normal file
31
src/components/ui/TypeformPopoverButton.stories.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import TypeformPopoverButton from './TypeformPopoverButton.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'UI/TypeformPopoverButton',
|
||||
component: TypeformPopoverButton,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
} satisfies Meta<typeof TypeformPopoverButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/** Default: help button that opens an embedded Typeform survey. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
dataTfWidget: 'example123',
|
||||
active: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Inactive: popover content is hidden. */
|
||||
export const Inactive: Story = {
|
||||
args: {
|
||||
dataTfWidget: 'example123',
|
||||
active: false
|
||||
}
|
||||
}
|
||||
161
src/components/ui/tooltip/Tooltip.stories.ts
Normal file
161
src/components/ui/tooltip/Tooltip.stories.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import Tooltip from './Tooltip.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Tooltip',
|
||||
component: Tooltip,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
side: {
|
||||
control: 'select',
|
||||
options: ['top', 'bottom', 'left', 'right']
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'lg']
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof Tooltip>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { Tooltip, Button },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<Tooltip v-bind="args">
|
||||
<Button>Hover me</Button>
|
||||
</Tooltip>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
text: 'This is a tooltip',
|
||||
side: 'top',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
render: () => ({
|
||||
components: { Tooltip, Button },
|
||||
template: `
|
||||
<div class="flex gap-12 p-20">
|
||||
<Tooltip text="Tool tip left aligned" side="top" size="sm">
|
||||
<Button>Top</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Tool tip center aligned" side="bottom" size="sm">
|
||||
<Button>Bottom</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Tool tip right aligned" side="left" size="sm">
|
||||
<Button>Left</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Tool tip pointing left" side="right" size="sm">
|
||||
<Button>Right</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
render: () => ({
|
||||
components: { Tooltip, Button },
|
||||
template: `
|
||||
<div class="flex gap-12 p-20">
|
||||
<Tooltip text="Lorem ipsum dolor sit amet, consectetur dolor si adipiscing elit. Proin maximus nisl nec posuere mattis." side="top" size="lg">
|
||||
<Button>Top</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Lorem ipsum dolor sit amet, consectetur dolor si adipiscing elit. Proin maximus nisl nec posuere mattis." side="bottom" size="lg">
|
||||
<Button>Bottom</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Lorem ipsum dolor sit amet, consectetur dolor si adipiscing elit. Proin maximus nisl nec posuere mattis." side="left" size="lg">
|
||||
<Button>Left</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Lorem ipsum dolor sit amet, consectetur dolor si adipiscing elit. Proin maximus nisl nec posuere mattis." side="right" size="lg">
|
||||
<Button>Right</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithKeybind: Story = {
|
||||
render: () => ({
|
||||
components: { Tooltip, Button },
|
||||
template: `
|
||||
<div class="flex gap-12 p-20">
|
||||
<Tooltip text="Select all" keybind="Ctrl+A" side="top" size="sm">
|
||||
<Button>With keybind</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Save" keybind="Ctrl+S" side="bottom" size="sm">
|
||||
<Button>Save</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Undo" keybind="Ctrl+Z" side="right" size="sm">
|
||||
<Button>Undo</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSides: Story = {
|
||||
render: () => ({
|
||||
components: { Tooltip, Button },
|
||||
template: `
|
||||
<div class="flex flex-col items-center gap-12 p-20">
|
||||
<Tooltip text="Top tooltip" side="top">
|
||||
<Button>Top</Button>
|
||||
</Tooltip>
|
||||
<div class="flex gap-12">
|
||||
<Tooltip text="Left tooltip" side="left">
|
||||
<Button>Left</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Right tooltip" side="right">
|
||||
<Button>Right</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip text="Bottom tooltip" side="bottom">
|
||||
<Button>Bottom</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithOffset: Story = {
|
||||
render: () => ({
|
||||
components: { Tooltip, Button },
|
||||
template: `
|
||||
<div class="flex gap-12 p-20">
|
||||
<Tooltip text="20px offset" side="left" :side-offset="20" size="sm">
|
||||
<Button>Left 20px</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="20px offset" side="top" :side-offset="20" size="sm">
|
||||
<Button>Top 20px</Button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Default offset" side="left" size="sm">
|
||||
<Button>Left default</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { Tooltip, Button },
|
||||
template: `
|
||||
<Tooltip text="You won't see this" :disabled="true">
|
||||
<Button>No tooltip</Button>
|
||||
</Tooltip>
|
||||
`
|
||||
})
|
||||
}
|
||||
70
src/components/ui/tooltip/Tooltip.vue
Normal file
70
src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
text,
|
||||
side = 'top',
|
||||
sideOffset = 5,
|
||||
delayDuration = 400,
|
||||
disabled = false,
|
||||
size = 'sm',
|
||||
keybind
|
||||
} = defineProps<{
|
||||
text?: string
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
sideOffset?: number
|
||||
delayDuration?: number
|
||||
disabled?: boolean
|
||||
size?: 'sm' | 'lg'
|
||||
keybind?: string
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<TooltipProvider
|
||||
:delay-duration="delayDuration"
|
||||
:disable-hoverable-content="true"
|
||||
>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal v-if="text && !disabled">
|
||||
<TooltipContent
|
||||
:side
|
||||
:side-offset="sideOffset"
|
||||
:collision-padding="10"
|
||||
:class="
|
||||
cn(
|
||||
'z-1700 border border-border-default bg-base-background font-normal text-base-foreground shadow-[1px_1px_8px_rgba(0,0,0,0.4)]',
|
||||
size === 'sm' &&
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-xs',
|
||||
size === 'lg' && 'max-w-75 rounded-md px-4 py-2 text-sm'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ text }}
|
||||
<span
|
||||
v-if="keybind && size === 'sm'"
|
||||
class="rounded-sm bg-secondary-background px-1 text-xs/4"
|
||||
>
|
||||
{{ keybind }}
|
||||
</span>
|
||||
<TooltipArrow
|
||||
:width="8"
|
||||
:height="5"
|
||||
class="fill-base-background stroke-border-default"
|
||||
/>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
307
src/composables/useAppPresets.test.ts
Normal file
307
src/composables/useAppPresets.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
const mockWidgets = vi.hoisted(() => new Map<string, IBaseWidget>())
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNodeWidget: (nodeId: NodeId, widgetName: string) => {
|
||||
const widget = mockWidgets.get(`${nodeId}:${widgetName}`)
|
||||
return widget ? [{}, widget] : []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: { extra: {}, nodes: [{ id: 1 }] }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () => ({ read_only: false })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
|
||||
useEmptyWorkflowDialog: () => ({ show: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/changeTracker', () => ({
|
||||
ChangeTracker: { isLoadingGraph: false }
|
||||
}))
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppPresets } from './useAppPresets'
|
||||
|
||||
function createWidget(
|
||||
name: string,
|
||||
value: unknown,
|
||||
options?: Record<string, unknown>
|
||||
): IBaseWidget {
|
||||
return { name, value, type: 'number', options } as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
describe('useAppPresets', () => {
|
||||
let appModeStore: ReturnType<typeof useAppModeStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
appModeStore = useAppModeStore()
|
||||
mockWidgets.clear()
|
||||
})
|
||||
|
||||
describe('savePreset', () => {
|
||||
it('snapshots current widget values and saves with a name', () => {
|
||||
const widget = createWidget('steps', 20)
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { savePreset, presets } = useAppPresets()
|
||||
const preset = savePreset('My Preset')
|
||||
|
||||
expect(preset.name).toBe('My Preset')
|
||||
expect(preset.values['1:steps']).toBe(20)
|
||||
expect(presets.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('saves multiple widget values', () => {
|
||||
mockWidgets.set('1:steps', createWidget('steps', 20))
|
||||
mockWidgets.set('2:cfg', createWidget('cfg', 7.5))
|
||||
appModeStore.selectedInputs.push(
|
||||
['1' as NodeId, 'steps'],
|
||||
['2' as NodeId, 'cfg']
|
||||
)
|
||||
|
||||
const { savePreset } = useAppPresets()
|
||||
const preset = savePreset('Dual')
|
||||
|
||||
expect(preset.values['1:steps']).toBe(20)
|
||||
expect(preset.values['2:cfg']).toBe(7.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPreset', () => {
|
||||
it('sets widget values from the preset', () => {
|
||||
const widget = createWidget('steps', 20)
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { savePreset, applyPreset } = useAppPresets()
|
||||
const preset = savePreset('Saved')
|
||||
|
||||
widget.value = 50
|
||||
|
||||
applyPreset(preset.id)
|
||||
expect(widget.value).toBe(20)
|
||||
})
|
||||
|
||||
it('clamps numeric values to widget overrides', () => {
|
||||
const widget = createWidget('steps', 25)
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
appModeStore.widgetOverrides['1:steps'] = { min: 10, max: 30 }
|
||||
|
||||
const { savePreset, applyPreset } = useAppPresets()
|
||||
const preset = savePreset('High Steps')
|
||||
|
||||
// Manually override the preset value to be out of range
|
||||
preset.values['1:steps'] = 50
|
||||
|
||||
applyPreset(preset.id)
|
||||
expect(widget.value).toBe(30)
|
||||
})
|
||||
|
||||
it('clamps to min override', () => {
|
||||
const widget = createWidget('steps', 5)
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
appModeStore.widgetOverrides['1:steps'] = { min: 10 }
|
||||
|
||||
const { savePreset, applyPreset } = useAppPresets()
|
||||
const preset = savePreset('Low Steps')
|
||||
preset.values['1:steps'] = 2
|
||||
|
||||
applyPreset(preset.id)
|
||||
expect(widget.value).toBe(10)
|
||||
})
|
||||
|
||||
it('does not clamp non-numeric values', () => {
|
||||
const widget = createWidget('sampler', 'euler')
|
||||
mockWidgets.set('1:sampler', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'sampler'])
|
||||
appModeStore.widgetOverrides['1:sampler'] = { min: 0, max: 10 }
|
||||
|
||||
const { savePreset, applyPreset } = useAppPresets()
|
||||
const preset = savePreset('Sampler Preset')
|
||||
|
||||
applyPreset(preset.id)
|
||||
expect(widget.value).toBe('euler')
|
||||
})
|
||||
|
||||
it('ignores unknown preset id', () => {
|
||||
const widget = createWidget('steps', 20)
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { applyPreset } = useAppPresets()
|
||||
applyPreset('nonexistent')
|
||||
expect(widget.value).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePreset', () => {
|
||||
it('removes the preset by id', () => {
|
||||
mockWidgets.set('1:steps', createWidget('steps', 20))
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { savePreset, deletePreset, presets } = useAppPresets()
|
||||
const preset = savePreset('To Delete')
|
||||
|
||||
deletePreset(preset.id)
|
||||
expect(presets.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores unknown id', () => {
|
||||
mockWidgets.set('1:steps', createWidget('steps', 20))
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { savePreset, deletePreset, presets } = useAppPresets()
|
||||
savePreset('Keep')
|
||||
|
||||
deletePreset('nonexistent')
|
||||
expect(presets.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renamePreset', () => {
|
||||
it('updates the preset name', () => {
|
||||
mockWidgets.set('1:steps', createWidget('steps', 20))
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { savePreset, renamePreset, presets } = useAppPresets()
|
||||
const preset = savePreset('Old Name')
|
||||
|
||||
renamePreset(preset.id, 'New Name')
|
||||
expect(presets.value[0].name).toBe('New Name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePreset', () => {
|
||||
it('replaces preset values with current widget values', () => {
|
||||
const widget = createWidget('steps', 20)
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { savePreset, updatePreset, presets } = useAppPresets()
|
||||
const preset = savePreset('Updatable')
|
||||
|
||||
widget.value = 42
|
||||
updatePreset(preset.id)
|
||||
|
||||
expect(presets.value[0].values['1:steps']).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBuiltin', () => {
|
||||
it('sets numeric widgets to min when t=0', () => {
|
||||
const widget = createWidget('steps', 20, { min: 1, max: 100 })
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { applyBuiltin } = useAppPresets()
|
||||
applyBuiltin(0)
|
||||
expect(widget.value).toBe(1)
|
||||
})
|
||||
|
||||
it('sets numeric widgets to max when t=1', () => {
|
||||
const widget = createWidget('steps', 20, { min: 1, max: 100 })
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { applyBuiltin } = useAppPresets()
|
||||
applyBuiltin(1)
|
||||
expect(widget.value).toBe(100)
|
||||
})
|
||||
|
||||
it('sets numeric widgets to midpoint when t=0.5', () => {
|
||||
const widget = createWidget('steps', 20, { min: 0, max: 50 })
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { applyBuiltin } = useAppPresets()
|
||||
applyBuiltin(0.5)
|
||||
expect(widget.value).toBe(25)
|
||||
})
|
||||
|
||||
it('respects widget overrides over widget options', () => {
|
||||
const widget = createWidget('steps', 20, { min: 1, max: 100 })
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
appModeStore.widgetOverrides['1:steps'] = { min: 10, max: 30 }
|
||||
|
||||
const { applyBuiltin } = useAppPresets()
|
||||
applyBuiltin(1)
|
||||
expect(widget.value).toBe(30)
|
||||
})
|
||||
|
||||
it('skips numeric widgets without min/max', () => {
|
||||
const widget = createWidget('steps', 20)
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { applyBuiltin } = useAppPresets()
|
||||
applyBuiltin(0)
|
||||
expect(widget.value).toBe(20)
|
||||
})
|
||||
|
||||
it('picks first combo option at t=0', () => {
|
||||
const widget = createWidget('sampler', 'euler', {
|
||||
values: ['euler', 'dpmpp_2m', 'ddim']
|
||||
})
|
||||
mockWidgets.set('1:sampler', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'sampler'])
|
||||
|
||||
const { applyBuiltin } = useAppPresets()
|
||||
applyBuiltin(0)
|
||||
expect(widget.value).toBe('euler')
|
||||
})
|
||||
|
||||
it('picks middle combo option at t=0.5', () => {
|
||||
const widget = createWidget('ratio', '4:3', {
|
||||
values: ['1:1', '4:3', '16:9']
|
||||
})
|
||||
mockWidgets.set('1:ratio', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'ratio'])
|
||||
|
||||
const { applyBuiltin } = useAppPresets()
|
||||
applyBuiltin(0.5)
|
||||
expect(widget.value).toBe('4:3')
|
||||
})
|
||||
|
||||
it('picks last combo option at t=1', () => {
|
||||
const widget = createWidget('ratio', '4:3', {
|
||||
values: ['1:1', '4:3', '16:9']
|
||||
})
|
||||
mockWidgets.set('1:ratio', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'ratio'])
|
||||
|
||||
const { applyBuiltin } = useAppPresets()
|
||||
applyBuiltin(1)
|
||||
expect(widget.value).toBe('16:9')
|
||||
})
|
||||
|
||||
it('applies via applyPreset with builtin IDs', () => {
|
||||
const widget = createWidget('steps', 20, { min: 1, max: 100 })
|
||||
mockWidgets.set('1:steps', widget)
|
||||
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
|
||||
|
||||
const { applyPreset } = useAppPresets()
|
||||
applyPreset('__builtin:max')
|
||||
expect(widget.value).toBe(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
193
src/composables/useAppPresets.ts
Normal file
193
src/composables/useAppPresets.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type {
|
||||
AppModePreset,
|
||||
WidgetOverride
|
||||
} from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
|
||||
type WidgetKey = `${string}:${string}`
|
||||
|
||||
/** Well-known IDs for built-in presets. */
|
||||
export const BUILTIN_PRESET_IDS = {
|
||||
min: '__builtin:min',
|
||||
mid: '__builtin:mid',
|
||||
max: '__builtin:max'
|
||||
} as const
|
||||
|
||||
function makeKey(nodeId: string, widgetName: string): WidgetKey {
|
||||
return `${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
/** Clamp a numeric value to widget override bounds if set. */
|
||||
function clampToOverride(
|
||||
value: unknown,
|
||||
override: WidgetOverride | undefined
|
||||
): unknown {
|
||||
if (override === undefined || typeof value !== 'number') return value
|
||||
let clamped = value
|
||||
if (override.min != null && clamped < override.min) clamped = override.min
|
||||
if (override.max != null && clamped > override.max) clamped = override.max
|
||||
return clamped
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective min/max for a widget: user override > widget options > undefined.
|
||||
*/
|
||||
function getEffectiveBounds(
|
||||
widgetOptions: { min?: number; max?: number } | undefined,
|
||||
override: WidgetOverride | undefined
|
||||
): { min: number | undefined; max: number | undefined } {
|
||||
return {
|
||||
min: override?.min ?? widgetOptions?.min,
|
||||
max: override?.max ?? widgetOptions?.max
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(
|
||||
min: number | undefined,
|
||||
max: number | undefined,
|
||||
t: number
|
||||
): number | undefined {
|
||||
if (min == null || max == null) return undefined
|
||||
return min + (max - min) * t
|
||||
}
|
||||
|
||||
export function useAppPresets() {
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const presets = computed(() => appModeStore.presets)
|
||||
|
||||
/** Snapshot current widget values for all selected inputs. */
|
||||
function snapshotValues(): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const [nodeId, widgetName] of appModeStore.selectedInputs) {
|
||||
const [, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (widget) {
|
||||
values[makeKey(String(nodeId), widgetName)] = widget.value
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an item from a list at interpolation factor t (0=first, 0.5=mid, 1=last).
|
||||
*/
|
||||
function pickFromList(list: unknown[], t: number): unknown {
|
||||
if (list.length === 0) return undefined
|
||||
const idx = Math.round(t * (list.length - 1))
|
||||
return list[idx]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a built-in preset (min/mid/max) from widget bounds.
|
||||
* Numeric widgets use min/max interpolation.
|
||||
* Combo/list widgets pick from available options by position.
|
||||
*/
|
||||
function computeBuiltinValues(t: number): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const [nodeId, widgetName] of appModeStore.selectedInputs) {
|
||||
const key = makeKey(String(nodeId), widgetName)
|
||||
const [, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget) continue
|
||||
|
||||
// Numeric widgets: interpolate between min and max
|
||||
if (typeof widget.value === 'number') {
|
||||
const override = appModeStore.widgetOverrides[key]
|
||||
const bounds = getEffectiveBounds(widget.options, override)
|
||||
const val = lerp(bounds.min, bounds.max, t)
|
||||
if (val != null) values[key] = val
|
||||
continue
|
||||
}
|
||||
|
||||
// Combo/list widgets: pick from options by position
|
||||
const opts = widget.options?.values
|
||||
if (Array.isArray(opts) && opts.length > 0) {
|
||||
values[key] = pickFromList(opts, t)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/** Apply a built-in preset by interpolation factor (0=min, 0.5=mid, 1=max). */
|
||||
function applyBuiltin(t: number) {
|
||||
const values = computeBuiltinValues(t)
|
||||
for (const [nodeId, widgetName] of appModeStore.selectedInputs) {
|
||||
const key = makeKey(String(nodeId), widgetName)
|
||||
if (!(key in values)) continue
|
||||
|
||||
const [, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (widget) widget.value = values[key] as typeof widget.value
|
||||
}
|
||||
}
|
||||
|
||||
function savePreset(name: string): AppModePreset {
|
||||
const preset: AppModePreset = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
values: snapshotValues()
|
||||
}
|
||||
appModeStore.presets.push(preset)
|
||||
appModeStore.persistLinearData()
|
||||
return preset
|
||||
}
|
||||
|
||||
function deletePreset(id: string) {
|
||||
const idx = appModeStore.presets.findIndex((p) => p.id === id)
|
||||
if (idx !== -1) {
|
||||
appModeStore.presets.splice(idx, 1)
|
||||
appModeStore.persistLinearData()
|
||||
}
|
||||
}
|
||||
|
||||
function renamePreset(id: string, name: string) {
|
||||
const preset = appModeStore.presets.find((p) => p.id === id)
|
||||
if (preset) {
|
||||
preset.name = name
|
||||
appModeStore.persistLinearData()
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a preset — sets widget values, clamping to any overrides. */
|
||||
function applyPreset(id: string) {
|
||||
// Handle built-in presets
|
||||
if (id === BUILTIN_PRESET_IDS.min) return applyBuiltin(0)
|
||||
if (id === BUILTIN_PRESET_IDS.mid) return applyBuiltin(0.5)
|
||||
if (id === BUILTIN_PRESET_IDS.max) return applyBuiltin(1)
|
||||
|
||||
const preset = appModeStore.presets.find((p) => p.id === id)
|
||||
if (!preset) return
|
||||
|
||||
for (const [nodeId, widgetName] of appModeStore.selectedInputs) {
|
||||
const key = makeKey(String(nodeId), widgetName)
|
||||
if (!(key in preset.values)) continue
|
||||
|
||||
const [, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget) continue
|
||||
|
||||
const override = appModeStore.widgetOverrides[key]
|
||||
const value = clampToOverride(preset.values[key], override)
|
||||
widget.value = value as typeof widget.value
|
||||
}
|
||||
}
|
||||
|
||||
/** Update an existing preset with current widget values. */
|
||||
function updatePreset(id: string) {
|
||||
const preset = appModeStore.presets.find((p) => p.id === id)
|
||||
if (!preset) return
|
||||
|
||||
preset.values = snapshotValues()
|
||||
appModeStore.persistLinearData()
|
||||
}
|
||||
|
||||
return {
|
||||
presets,
|
||||
savePreset,
|
||||
deletePreset,
|
||||
renamePreset,
|
||||
applyPreset,
|
||||
applyBuiltin,
|
||||
updatePreset
|
||||
}
|
||||
}
|
||||
@@ -15680,10 +15680,6 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "صورة UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"save": "Save",
|
||||
"saveAnyway": "Save Anyway",
|
||||
"saving": "Saving",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
@@ -1193,6 +1194,7 @@
|
||||
"maskEditor": {
|
||||
"title": "Mask Editor",
|
||||
"openMaskEditor": "Open in Mask Editor",
|
||||
"editMask": "Edit Mask",
|
||||
"invert": "Invert",
|
||||
"clear": "Clear",
|
||||
"undo": "Undo",
|
||||
@@ -3282,6 +3284,7 @@
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"dragAndDropVideo": "Click to browse or drag a video",
|
||||
"mobileControls": "Edit & Run",
|
||||
"runCount": "Number of runs",
|
||||
"rerun": "Rerun",
|
||||
@@ -3321,7 +3324,62 @@
|
||||
"outputExamples": "Examples: 'Save Image' or 'Save Video'",
|
||||
"switchToOutputsButton": "Switch to Outputs",
|
||||
"outputs": "Outputs",
|
||||
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
|
||||
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app",
|
||||
"layout": "Layout",
|
||||
"dropHere": "Drop inputs here",
|
||||
"outputZone": "Output",
|
||||
"shiftClickPriority": "Shift+click to prioritize",
|
||||
"queueFailed": "Failed to queue prompt"
|
||||
},
|
||||
"groups": {
|
||||
"createGroup": "Create group",
|
||||
"untitled": "Unnamed Group",
|
||||
"confirmUngroup": "Ungroup these inputs?",
|
||||
"ungroupDescription": "These inputs will no longer be grouped together.",
|
||||
"confirmRemove": "Remove this input?",
|
||||
"removeDescription": "This will remove the input from the app. You will need to re-add it in the inputs step."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Presets",
|
||||
"empty": "No saved presets yet.",
|
||||
"save": "Save current as preset",
|
||||
"saveTitle": "Save preset",
|
||||
"saveMessage": "Enter a name for this preset.",
|
||||
"namePlaceholder": "Preset name",
|
||||
"builtinMin": "Min",
|
||||
"builtinMid": "Mid",
|
||||
"builtinMax": "Max",
|
||||
"builtinMinTip": "Set all inputs to minimum values",
|
||||
"builtinMidTip": "Set all inputs to midpoint values",
|
||||
"builtinMaxTip": "Set all inputs to maximum values",
|
||||
"builtinSection": "Quick presets",
|
||||
"savedSection": "Saved",
|
||||
"displayAs": "Display as",
|
||||
"displayTabs": "Tabs",
|
||||
"displayButtons": "Buttons",
|
||||
"displayMenu": "Menu",
|
||||
"overwrite": "Save current values to this preset",
|
||||
"presetCount": "{count} saved preset | {count} saved presets"
|
||||
},
|
||||
"layout": {
|
||||
"templates": {
|
||||
"single": "Single",
|
||||
"singleDesc": "Single column sidebar",
|
||||
"dual": "Dual",
|
||||
"dualDesc": "Two-column sidebar with resize"
|
||||
},
|
||||
"zones": {
|
||||
"main": "Main",
|
||||
"left": "Left",
|
||||
"right": "Right"
|
||||
},
|
||||
"group": "Group selected",
|
||||
"ungroup": "Ungroup",
|
||||
"moveToGroup": "Move to group",
|
||||
"removeFromGroup": "Remove from group",
|
||||
"newGroup": "New group...",
|
||||
"groupName": "Group name",
|
||||
"ungrouped": "Ungrouped"
|
||||
},
|
||||
"builder": {
|
||||
"title": "App builder mode",
|
||||
|
||||
@@ -15680,10 +15680,6 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,10 +15680,6 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "imagen UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,10 +15680,6 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "تصویر UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,10 +15680,6 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,10 +15680,6 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv画像",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,10 +15680,6 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user