mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-18 12:30:07 +00:00
Compare commits
6 Commits
fix/pricin
...
cloud/1.46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e6ed8653f | ||
|
|
384b29d72d | ||
|
|
3a4f2d1440 | ||
|
|
16169def51 | ||
|
|
af771a45d0 | ||
|
|
cb4c3b833b |
55
.github/workflows/pr-cursor-review.yaml
vendored
55
.github/workflows/pr-cursor-review.yaml
vendored
@@ -1,55 +0,0 @@
|
||||
# Description: Team-gated multi-model Cursor review — a thin caller for the
|
||||
# reusable workflow in Comfy-Org/github-workflows, which is the single source of
|
||||
# truth for the panel, judge, prompts, and scripts. Triggered by the
|
||||
# 'cursor-review' label.
|
||||
#
|
||||
# Access control (team-only, two layers):
|
||||
# 1. Only users with triage permission or higher can apply a label in a public
|
||||
# repo, so the public cannot trigger this.
|
||||
# 2. The reusable workflow's secret-bearing jobs do not run on fork PRs (forks
|
||||
# get no secrets), so CURSOR_API_KEY is reachable only on internal branches.
|
||||
name: 'PR: Cursor Review'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
# Re-labeling cancels an in-flight run for the same PR + label.
|
||||
group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cursor-review:
|
||||
if: github.event.action == 'labeled' && github.event.label.name == 'cursor-review'
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
# paths (Playwright snapshots, generated manager types).
|
||||
diff_excludes: >-
|
||||
:!**/package-lock.json
|
||||
:!**/yarn.lock
|
||||
:!**/pnpm-lock.yaml
|
||||
:!**/node_modules/**
|
||||
:!**/.claude/**
|
||||
:!**/dist/**
|
||||
:!**/vendor/**
|
||||
:!**/*.generated.*
|
||||
:!**/*.min.js
|
||||
:!**/*.min.css
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
@@ -15,7 +15,7 @@ import { t } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import PlayPauseButton from './PlayPauseButton.vue'
|
||||
|
||||
export type VideoTrack = {
|
||||
type VideoTrack = {
|
||||
src: string
|
||||
kind: 'subtitles' | 'captions' | 'descriptions'
|
||||
srclang: string
|
||||
@@ -35,7 +35,7 @@ const {
|
||||
locale?: Locale
|
||||
src?: string
|
||||
poster?: string
|
||||
tracks?: readonly VideoTrack[]
|
||||
tracks?: VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
|
||||
@@ -64,7 +64,6 @@ onUnmounted(() => {
|
||||
:locale
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
:tracks="tutorial.caption"
|
||||
autoplay
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
@@ -68,8 +68,7 @@ const plans: PricingPlan[] = [
|
||||
: undefined,
|
||||
features: [
|
||||
{ text: 'pricing.plan.standard.feature1' },
|
||||
{ text: 'pricing.plan.standard.feature2' },
|
||||
{ text: 'pricing.plan.standard.feature3' }
|
||||
{ text: 'pricing.plan.standard.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -123,11 +122,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Header -->
|
||||
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
|
||||
<h1
|
||||
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
class="text-primary-comfy-canvas font-formula text-4xl font-light lg:text-5xl"
|
||||
>
|
||||
{{ t('pricing.title', locale) }}
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-3 text-base">
|
||||
{{ t('pricing.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -157,7 +156,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
|
||||
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink flex items-center px-2 text-sm font-bold tracking-wider"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.badge.popular', locale) }}
|
||||
@@ -173,18 +172,18 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="px-6 text-sm text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas px-6 text-sm">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Price -->
|
||||
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
|
||||
<span
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas font-formula text-5xl font-light"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -193,7 +192,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Credits -->
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="px-6 text-sm text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas px-6 text-sm"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
@@ -202,7 +201,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Estimate -->
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="px-6 text-xs text-primary-comfy-canvas/80"
|
||||
class="text-primary-comfy-canvas/80 px-6 text-xs"
|
||||
>
|
||||
{{ t(plan.estimateKey, locale) }}
|
||||
</p>
|
||||
@@ -212,13 +211,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<p
|
||||
v-if="plan.featureIntroKey"
|
||||
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
>
|
||||
{{ t(plan.featureIntroKey, locale) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
@@ -230,7 +229,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="text-primary-comfy-yellow mt-0.5 text-sm">✓</span>
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -270,7 +269,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center px-2 text-[10px] font-bold tracking-wider"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.badge.popular', locale) }}
|
||||
@@ -288,13 +287,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Enterprise heading -->
|
||||
<h2
|
||||
v-if="plan.isEnterprise"
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-3 text-2xl font-light"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="mt-2 text-sm text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-2 text-sm">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
@@ -302,25 +301,25 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<template v-if="plan.priceKey">
|
||||
<div class="mt-6 flex items-baseline gap-1">
|
||||
<span
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas font-formula text-5xl font-light"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-sm text-primary-comfy-canvas/55">
|
||||
<span class="text-primary-comfy-canvas/55 text-sm">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-4 text-xs font-medium"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="mt-2 text-xs text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-2 text-xs"
|
||||
>
|
||||
{{ t(plan.estimateKey, locale) }}
|
||||
</p>
|
||||
@@ -369,7 +368,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
>
|
||||
<!-- Left side -->
|
||||
<div
|
||||
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 p-8"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
@@ -378,11 +377,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
{{ t(enterprisePlan.labelKey, locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
|
||||
class="text-primary-comfy-canvas mt-3 text-2xl font-light lg:text-3xl"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mt-3 text-sm text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-3 text-sm">
|
||||
{{ t(enterprisePlan.summaryKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -393,7 +392,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</div>
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
|
||||
<p class="text-primary-comfy-canvas/70 mt-12 text-xs">
|
||||
{{ t('pricing.footnote', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -54,11 +54,7 @@ const features: IncludedFeature[] = [
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature11.title',
|
||||
descriptionKey: 'pricing.included.feature11.description'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature12.title',
|
||||
descriptionKey: 'pricing.included.feature12.description',
|
||||
descriptionKey: 'pricing.included.feature11.description',
|
||||
isComingSoon: true
|
||||
}
|
||||
]
|
||||
@@ -69,10 +65,10 @@ const features: IncludedFeature[] = [
|
||||
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
|
||||
<!-- Heading -->
|
||||
<div
|
||||
class="sticky top-20 mb-10 bg-primary-comfy-ink py-2 lg:top-28 lg:mb-0 lg:self-start"
|
||||
class="bg-primary-comfy-ink sticky top-20 mb-10 py-2 lg:top-28 lg:mb-0 lg:self-start"
|
||||
>
|
||||
<h2
|
||||
class="text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-light whitespace-pre-line"
|
||||
>
|
||||
{{ t('pricing.included.heading', locale) }}
|
||||
</h2>
|
||||
@@ -85,7 +81,7 @@ const features: IncludedFeature[] = [
|
||||
:key="feature.titleKey"
|
||||
:class="
|
||||
index < features.length - 1
|
||||
? 'border-b border-solid border-primary-comfy-canvas/15'
|
||||
? 'border-primary-comfy-canvas/15 border-b border-solid'
|
||||
: ''
|
||||
"
|
||||
class="py-8 first:pt-0 lg:grid lg:grid-cols-[200px_1fr] lg:gap-x-10"
|
||||
@@ -103,14 +99,14 @@ const features: IncludedFeature[] = [
|
||||
v-else
|
||||
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<p class="text-sm font-medium text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
class="mt-3 text-sm/relaxed text-primary-comfy-canvas/55 lg:mt-0"
|
||||
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
|
||||
v-html="t(feature.descriptionKey, locale)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
|
||||
import type { LocalizedText, TranslationKey } from '../i18n/translations'
|
||||
|
||||
export interface LearningTutorial {
|
||||
@@ -8,7 +7,6 @@ export interface LearningTutorial {
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
caption?: readonly VideoTrack[]
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
@@ -30,14 +28,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
// href: '#',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
@@ -48,15 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/deaging_workflow_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -67,14 +49,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -85,14 +59,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=be0889296f65',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/mattes_and_utilities_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -103,14 +69,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -121,14 +79,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
}
|
||||
] as const
|
||||
|
||||
@@ -1244,10 +1244,6 @@ const translations = {
|
||||
en: 'Add more credits anytime',
|
||||
'zh-CN': '可随时增加积分'
|
||||
},
|
||||
'pricing.plan.standard.feature3': {
|
||||
en: 'Run 1 workflow concurrently (via API)',
|
||||
'zh-CN': '通过 API 并发运行 1 个工作流'
|
||||
},
|
||||
|
||||
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
|
||||
'pricing.plan.creator.summary': {
|
||||
@@ -1276,8 +1272,8 @@ const translations = {
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: 'Run up to 3 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 3 个工作流'
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
@@ -1304,8 +1300,8 @@ const translations = {
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: 'Run up to 5 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 5 个工作流'
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
@@ -1389,9 +1385,9 @@ const translations = {
|
||||
'zh-CN': '随时加购积分'
|
||||
},
|
||||
'pricing.included.feature5.description': {
|
||||
en: 'Purchase additional credits at any time. Top-up credits are valid for 1 year from the date of purchase and do not roll over with your monthly plan.',
|
||||
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
|
||||
'zh-CN':
|
||||
'可随时购买额外积分。充值积分自购买之日起 1 年内有效,且不会随月度计划结转。'
|
||||
'可随时购买额外积分。未使用的充值积分自动结转至下月,最长保留 1 年。'
|
||||
},
|
||||
'pricing.included.feature6.title': {
|
||||
en: 'Pre-installed models',
|
||||
@@ -1437,19 +1433,10 @@ const translations = {
|
||||
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA,打造专属风格。'
|
||||
},
|
||||
'pricing.included.feature11.title': {
|
||||
en: 'Run Workflows via API',
|
||||
'zh-CN': '通过 API 运行工作流'
|
||||
},
|
||||
'pricing.included.feature11.description': {
|
||||
en: 'Run Comfy workflows programmatically via API, with concurrency limits based on your plan. Perfect for integrating ComfyUI into your applications, automating batch processing, or building production pipelines. For higher rate limits, reach out to <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'通过 API 以编程方式运行 Comfy 工作流,并发上限由您的计划决定。非常适合将 ComfyUI 集成到您的应用、自动化批量处理或构建生产级流水线。如需更高的速率限制,请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
|
||||
},
|
||||
'pricing.included.feature12.title': {
|
||||
en: 'Parallel job execution',
|
||||
'zh-CN': '并行任务执行'
|
||||
},
|
||||
'pricing.included.feature12.description': {
|
||||
'pricing.included.feature11.description': {
|
||||
en: 'Run multiple workflows in parallel to speed up your pipeline.',
|
||||
'zh-CN': '并行运行多个工作流,加速你的流程。'
|
||||
},
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "seed"]]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["3", "seed"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 14,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 120]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [1, "increment", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [498.26665242513025, 471.46666463216144],
|
||||
"size": [510.328125, 252.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500.66667683919275, 227.8666280110677],
|
||||
"size": [507.40625, 233.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [570.266591389974, 735.4665120442708],
|
||||
"size": [378, 216],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1453.466512044271, 230.26666768391925],
|
||||
"size": [252, 138],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.866658528646, 231.46666463216144],
|
||||
"size": [252, 148],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.866689046223996, 573.4666951497395],
|
||||
"size": [378, 196],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.9444173177085, 343.52284749348956],
|
||||
"size": [225, 220],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["-1", "seed"],
|
||||
["3", "control_after_generate"]
|
||||
]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.35.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
function flagAttributeFor(testId: string) {
|
||||
const encoded = Array.from(testId, (ch) =>
|
||||
ch.charCodeAt(0).toString(16)
|
||||
).join('')
|
||||
return `data-flashed-${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the first time an element matching `[data-testid="<testId>"]` is
|
||||
* present and rendered, sampled every frame via `requestAnimationFrame` from
|
||||
* page load. Catches a dialog that mounts and unmounts within a few frames,
|
||||
* which `toBeHidden()` (final state only) cannot.
|
||||
*
|
||||
* Must be called before navigation (e.g. before `comfyPage.setup()`).
|
||||
*/
|
||||
export async function trackElementFlash(
|
||||
page: Page,
|
||||
testId: string
|
||||
): Promise<{ hasFlashed: () => Promise<boolean> }> {
|
||||
const flagAttribute = flagAttributeFor(testId)
|
||||
|
||||
await page.addInitScript(
|
||||
({ id, attribute }: { id: string; attribute: string }) => {
|
||||
const sample = () => {
|
||||
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
|
||||
if (el instanceof HTMLElement) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
document.documentElement.setAttribute(attribute, 'true')
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ id: testId, attribute: flagAttribute }
|
||||
)
|
||||
|
||||
return {
|
||||
hasFlashed: async () =>
|
||||
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
@@ -7,13 +8,8 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
interface ResolvedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
function widgetSourceToEntry(
|
||||
source: ResolvedWidgetSource
|
||||
source: PromotedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
@@ -24,22 +20,23 @@ function previewExposureToEntry(
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the promoted widgets of a subgraph host node from the live graph.
|
||||
*
|
||||
* Promoted widgets are now store-backed: a host input is promoted iff it
|
||||
* carries a `widgetId`, and its interior source identity is resolved on demand
|
||||
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
|
||||
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
|
||||
* denormalization, so the helper reflects the real projection rather than a
|
||||
* deleted widget-object contract.
|
||||
*/
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -47,49 +44,21 @@ export async function getPromotedWidgets(
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const previewExposures = node?.serialize()?.properties?.previewExposures
|
||||
if (!node?.isSubgraphNode?.())
|
||||
return { widgetSources: [], previewExposures }
|
||||
|
||||
const { subgraph } = node
|
||||
const resolveSource = (
|
||||
inputName: string
|
||||
): ResolvedWidgetSource | undefined => {
|
||||
const inputSlot = subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) return undefined
|
||||
for (const linkId of inputSlot.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
if (inputNode.isSubgraphNode?.()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const widgetSources = (node.inputs ?? []).flatMap((input) => {
|
||||
if (!input.widgetId) return []
|
||||
const source = resolveSource(input.name)
|
||||
return source ? [source] : []
|
||||
]
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
}
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
@@ -98,7 +67,7 @@ export async function getPromotedWidgets(
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.map(widgetSourceToEntry),
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 321 KiB |
@@ -1,103 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
interface UploadResponse {
|
||||
name: string
|
||||
subfolder: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
}
|
||||
|
||||
const IMAGE_CANVAS_INDEX = 0
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
|
||||
const successResponse = (name: string): UploadResponse => ({
|
||||
name,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
const fulfillJson = (body: UploadResponse) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
test('Save with drawn mask uploads non-empty mask data', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
expect(observedBodyLength).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const imageDimensions =
|
||||
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
|
||||
const maskDimensions =
|
||||
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
|
||||
|
||||
expect(imageDimensions).not.toBeNull()
|
||||
expect(maskDimensions).not.toBeNull()
|
||||
expect(imageDimensions?.totalPixels).toBe(64 * 64)
|
||||
expect(maskDimensions?.totalPixels).toBe(64 * 64)
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
})
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
@@ -15,10 +15,6 @@ import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
|
||||
// internal `setup()`, so the page first-loads with mocks already in place.
|
||||
// See cloud-asset-default.spec.ts for the same pattern.
|
||||
//
|
||||
// Use `waitForAssets()` not `waitForAssets(MIXED_JOBS.length)`: VirtualGrid can
|
||||
// virtualize the 3D card out of the initial render (#11635). Filtering reads the
|
||||
// full store, so the per-filter count assertions still cover the behavior.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
@@ -117,7 +113,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
@@ -140,7 +136,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -157,7 +153,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
@@ -171,7 +167,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('audio')
|
||||
@@ -183,7 +179,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('3d')
|
||||
@@ -197,7 +193,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -215,7 +211,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
@@ -217,14 +217,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Each promoted input must surface its own source value, so assert the
|
||||
// name->value mapping rather than the first textbox in DOM order.
|
||||
const EXPECTED_VALUE_BY_INPUT: Record<string, RegExp> = {
|
||||
value: /Inner 1/,
|
||||
value_1: /Inner 2/,
|
||||
value_1_1: /Inner 3/
|
||||
}
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -236,16 +228,11 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgets).toHaveCount(4)
|
||||
|
||||
for (const [inputName, expectedValue] of Object.entries(
|
||||
EXPECTED_VALUE_BY_INPUT
|
||||
)) {
|
||||
const valueWidget = outerNode.getByRole('textbox', {
|
||||
name: inputName,
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(expectedValue)
|
||||
}
|
||||
const valueWidget = outerNode
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
})
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
|
||||
@@ -284,16 +271,11 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
|
||||
|
||||
for (const [inputName, expectedValue] of Object.entries(
|
||||
EXPECTED_VALUE_BY_INPUT
|
||||
)) {
|
||||
const valueWidget = outerNodeAfter.getByRole('textbox', {
|
||||
name: inputName,
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(expectedValue)
|
||||
}
|
||||
const valueWidget = outerNodeAfter
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,22 +53,6 @@ test.describe(
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test('Promoted textarea materializes once when a node is converted to a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator(String(subgraphNode.id))
|
||||
.getByRole('textbox', { name: 'text', exact: true })
|
||||
await expect(promotedTextarea).toHaveCount(1)
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Promoted Text Widget Lifecycle',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
wstest(
|
||||
'Seed handling',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
async function verifySeedControl(initializeState = true) {
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('', 'seed')
|
||||
const { input, valueControl } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
|
||||
if (initializeState) {
|
||||
await input.fill('1')
|
||||
await valueControl.click()
|
||||
await comfyPage.page.getByRole('radio', { name: 'increment' }).click()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
await execution.run()
|
||||
await expect.soft(input).toHaveValue('2')
|
||||
}
|
||||
|
||||
await test.step('seed updates on generation', async () => {
|
||||
await verifySeedControl()
|
||||
})
|
||||
|
||||
await test.step('subgraph seed updates on generation', async () => {
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
await verifySeedControl()
|
||||
})
|
||||
|
||||
for (const w of ['link-seed', 'proxy-seed', 'zit-seed']) {
|
||||
await test.step(`seed updates for old workflow: ${w}`, async () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/' + w)
|
||||
await verifySeedControl(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -484,14 +484,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Assert against the visible textbox the user sees, not the internal
|
||||
// graph/widget projection.
|
||||
const promotedTextWidgets = comfyPage.page.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(promotedTextWidgets).toHaveCount(1)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
@@ -505,58 +497,31 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
|
||||
await comfyExpect(promotedTextWidgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test(
|
||||
'Cloning a subgraph node preserves edited promoted widget values on original and clone',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const editedValue = 'Edited prompt that must survive cloning'
|
||||
const originalTextbox = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(originalTextbox).toBeVisible()
|
||||
await expect(originalTextbox).toHaveValue('')
|
||||
await originalTextbox.fill(editedValue)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await originalNode.click('title')
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const textbox = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(
|
||||
textbox,
|
||||
`node ${nodeId} promoted text widget reset to default after clone`
|
||||
).toHaveValue(editedValue)
|
||||
}
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { WorkflowTemplates } from '@/platform/workflow/templates/types/temp
|
||||
import { getWav } from '@e2e/fixtures/components/AudioPreview'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { trackElementFlash } from '@e2e/fixtures/utils/flashDetector'
|
||||
|
||||
async function checkTemplateFileExists(
|
||||
page: Page,
|
||||
@@ -506,32 +505,3 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
expect(popup.url()).toEqual(tutorialUrl)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Templates deeplink (new user)',
|
||||
{ tag: ['@slow', '@workflow'] },
|
||||
() => {
|
||||
test('templates dialog never flashes when first-time user opens a template link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templatesFlash = await trackElementFlash(
|
||||
comfyPage.page,
|
||||
TestIds.templates.content
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
|
||||
await comfyPage.setup({
|
||||
clearStorage: true,
|
||||
url: '/?template=default'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
expect(await templatesFlash.hasFlashed()).toBe(false)
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -177,30 +177,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('does not drag contents when control is held', async ({ comfyPage }) => {
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const initialNodeBounds = await ksampler.boundingBox()
|
||||
expect(initialNodeBounds).toBeTruthy()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await expect
|
||||
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
|
||||
.not.toEqual(groupPos)
|
||||
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -140,46 +139,6 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'Displays previews inside subgraphs received while workflow inactive',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const previewLocator = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
const previewImage = new VueNodeFixture(previewLocator)
|
||||
const subgraphLocator = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
const subgraphNode = new VueNodeFixture(subgraphLocator)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
await expect(previewImage.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await previewImage.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(subgraphNode.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Inject Previews from different tab', async () => {
|
||||
const jobId = await execution.run()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await comfyPage.vueNodes.waitForNodes(7)
|
||||
|
||||
const images = [{ filename: 'example.png', type: 'input' }]
|
||||
execution.executed(jobId, '2:1', { images })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
await expect(subgraphNode.imagePreview.locator('img')).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function countColumns(locator: Locator) {
|
||||
|
||||
@@ -98,43 +98,4 @@ test.describe('Workspace switcher', { tag: '@cloud' }, () => {
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
|
||||
})
|
||||
|
||||
test('opens the switcher to the left of the profile menu without overlap', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByTestId('workspace-switcher-trigger').click()
|
||||
|
||||
const panel = page.getByTestId('workspace-switcher-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
const profileMenu = page.locator('.current-user-popover')
|
||||
const panelBox = await panel.boundingBox()
|
||||
const profileBox = await profileMenu.boundingBox()
|
||||
expect(panelBox).not.toBeNull()
|
||||
expect(profileBox).not.toBeNull()
|
||||
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(profileBox!.x)
|
||||
})
|
||||
|
||||
test('opens the create-workspace dialog with DES-246 copy', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByTestId('workspace-switcher-trigger').click()
|
||||
|
||||
await page.getByText('Create a workspace').click()
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Workspaces keep your projects and files organized. Subscribe to a Team plan to invite members.'
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Ex: Comfy Org')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
11
codecov.yml
11
codecov.yml
@@ -4,14 +4,3 @@ comment:
|
||||
require_changes: false
|
||||
require_base: false
|
||||
require_head: true
|
||||
|
||||
# Carry forward the last known coverage for a flag when its upload is missing or
|
||||
# late. The `e2e` flag is uploaded by a separate workflow_run job that can fail
|
||||
# or arrive after Codecov has already computed the patch status; without this,
|
||||
# E2E-only code paths show up as patch misses and the patch status fails. See
|
||||
# https://docs.codecov.com/docs/carryforward-flags
|
||||
flags:
|
||||
unit:
|
||||
carryforward: true
|
||||
e2e:
|
||||
carryforward: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.1",
|
||||
"version": "1.46.14",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -207,7 +207,7 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25 <26",
|
||||
"node": ">=25",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
|
||||
@@ -7,10 +7,7 @@ export type { ClassValue } from 'clsx'
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': ['text-xxs', 'text-xxxs'],
|
||||
// tailwind-merge does not know Tailwind's `max-h-none`, so it never
|
||||
// resolves conflicts like `max-h-[80vh] max-h-none` (both survive).
|
||||
'max-h': [{ 'max-h': ['none'] }]
|
||||
'font-size': ['text-xxs', 'text-xxxs']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
|
||||
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: mockIsActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue', () => ({
|
||||
default: {
|
||||
name: 'ComfyQueueButton',
|
||||
template: '<div data-testid="queue-button" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({
|
||||
default: {
|
||||
name: 'SubscribeToRun',
|
||||
template: '<div data-testid="subscribe-to-run-button" />'
|
||||
}
|
||||
}))
|
||||
|
||||
function renderWrapper() {
|
||||
return render(CloudRunButtonWrapper)
|
||||
}
|
||||
|
||||
describe('CloudRunButtonWrapper', () => {
|
||||
beforeEach(() => {
|
||||
mockIsActiveSubscription.value = true
|
||||
})
|
||||
|
||||
it('renders the runnable queue button when the subscription is active', () => {
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-to-run-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locks the run button when the subscription is inactive', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('unlocks the run button once the subscription becomes active again', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
|
||||
mockIsActiveSubscription.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-to-run-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedWidgetId(widgetId: WidgetId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const widgetId = widget.widgetId
|
||||
if (!widgetId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
const entityId = widget.entityId
|
||||
if (!entityId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index === -1)
|
||||
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
|
||||
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.widgetId, getWidgetBounding(entry)] as [
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="entry.displayName"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedWidgetId(entry.widgetId)"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
/>
|
||||
</template>
|
||||
</DraggableList>
|
||||
|
||||
@@ -60,7 +60,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { widgetId, node, widget, config } = entry
|
||||
const { entityId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
@@ -70,7 +70,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
return vueWidget.widgetId === widgetId
|
||||
return vueWidget.entityId === entityId
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
@@ -79,7 +79,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
key: widgetId,
|
||||
key: entityId,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
@@ -23,29 +22,18 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
inputs: [],
|
||||
isSubgraphNode: () => false,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function makeSubgraphNode(id: number, inputs: INodeInputSlot[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
inputs,
|
||||
isSubgraphNode: () => true,
|
||||
widgets: []
|
||||
})
|
||||
}
|
||||
|
||||
function setRootGraphNodes(nodes: LGraphNode[]) {
|
||||
vi.mocked(app.rootGraph).nodes = nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
@@ -100,27 +88,4 @@ describe('useResolvedSelectedInputs', () => {
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('resolves promoted subgraph inputs from their host input widgetId', () => {
|
||||
const node = makeSubgraphNode(1, [
|
||||
fromPartial<INodeInputSlot>({
|
||||
name: 'seed',
|
||||
label: 'renamed_seed',
|
||||
widgetId: entitySeed
|
||||
})
|
||||
])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
|
||||
expect(resolved.value[0]).toMatchObject({
|
||||
status: 'resolved',
|
||||
node,
|
||||
displayName: 'seed',
|
||||
widget: { name: 'seed', label: 'renamed_seed', widgetId: entitySeed }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
widgetId: WidgetId
|
||||
entityId: WidgetEntityId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
@@ -21,7 +20,7 @@ export type ResolvedSelection =
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
widgetId: WidgetId
|
||||
entityId: WidgetEntityId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
@@ -55,19 +54,16 @@ export function useResolvedSelectedInputs() {
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([widgetId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetId(widgetId)) return []
|
||||
const { nodeId, name } = parseWidgetId(widgetId)
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widgets = node?.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: node?.widgets
|
||||
const widget = widgets?.find((w) => w.name === name)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', widgetId, displayName, config }]
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', widgetId, node, widget, displayName, config }
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,9 +17,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { close: 'Close', maximizeDialog: 'Maximize' } }
|
||||
},
|
||||
messages: { en: { g: { close: 'Close' } } },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -195,68 +193,6 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies headerClass and bodyClass on the non-headless path', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-section-classes',
|
||||
title: 'Section classes',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headerClass: 'p-2',
|
||||
bodyClass: 'p-0'
|
||||
}
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const header = screen.getByText('Section classes').parentElement
|
||||
expect(header?.classList.contains('p-2')).toBe(true)
|
||||
// twMerge drops the default header padding in favor of headerClass
|
||||
expect(header?.classList.contains('px-4')).toBe(false)
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const body = screen.getByTestId('body').parentElement
|
||||
expect(body?.classList.contains('p-0')).toBe(true)
|
||||
expect(body?.classList.contains('px-4')).toBe(false)
|
||||
})
|
||||
|
||||
it('maximize overrides custom dimension classes from contentClass', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-maximize-wins',
|
||||
title: 'Maximize wins',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
maximizable: true,
|
||||
contentClass:
|
||||
'w-[80vw] max-w-[80vw] sm:max-w-[80vw] h-[80vh] max-h-[80vh]'
|
||||
}
|
||||
})
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog.classList.contains('w-[80vw]')).toBe(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Maximize' }))
|
||||
|
||||
// Maximized dimensions win over the caller's fixed dimensions,
|
||||
// mirroring PrimeVue's `.p-dialog-maximized` !important behavior.
|
||||
expect(dialog.classList.contains('size-auto')).toBe(true)
|
||||
expect(dialog.classList.contains('max-h-none')).toBe(true)
|
||||
expect(dialog.classList.contains('w-[80vw]')).toBe(false)
|
||||
expect(dialog.classList.contains('h-[80vh]')).toBe(false)
|
||||
expect(dialog.classList.contains('max-h-[80vh]')).toBe(false)
|
||||
expect(dialog.classList.contains('max-w-[80vw]')).toBe(false)
|
||||
expect(dialog.classList.contains('sm:max-w-[80vw]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
@@ -302,22 +238,6 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when the dialog is not the top-most (stacked)', () => {
|
||||
// A backgrounded dialog must never dismiss on an outside pointer — the
|
||||
// pointer belongs to the dialog stacked above it (e.g. Edit Keybinding
|
||||
// opening over Settings). Target is outside any overlay, so only the
|
||||
// is-active gate can prevent it.
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event, false)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('allows the top-most dialog to dismiss on a true outside pointer', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event, true)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: false }, event)
|
||||
|
||||
@@ -18,19 +18,13 @@
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@open-auto-focus="(e) => onRekaOpenAutoFocus(e, item.key)"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
item.dialogComponentProps.closeOnEscape === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
onRekaPointerDownOutside(
|
||||
item.dialogComponentProps,
|
||||
e,
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
@@ -43,7 +37,7 @@
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DialogHeader :class="item.dialogComponentProps.headerClass">
|
||||
<DialogHeader>
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
@@ -64,24 +58,14 @@
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 overflow-auto px-4 py-2',
|
||||
item.dialogComponentProps.bodyClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter
|
||||
v-if="item.footerComponent"
|
||||
:class="item.dialogComponentProps.footerClass"
|
||||
>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
@@ -125,8 +109,6 @@
|
||||
<script setup lang="ts">
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
@@ -154,22 +136,6 @@ function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
// Reka's FocusScope focuses the first tabbable element on open (often a header
|
||||
// or footer button). Dialog content that marks an input with `autofocus` (e.g.
|
||||
// the keybinding capture input, the prompt input) relied on PrimeVue honoring
|
||||
// that attribute, so honor it here: focus the autofocus target and cancel
|
||||
// Reka's default auto-focus when one is present.
|
||||
function onRekaOpenAutoFocus(event: Event, key: string) {
|
||||
const content = document.querySelector<HTMLElement>(
|
||||
`[aria-labelledby="${CSS.escape(key)}"]`
|
||||
)
|
||||
const autofocusEl = content?.querySelector<HTMLElement>('[autofocus]')
|
||||
if (autofocusEl) {
|
||||
event.preventDefault()
|
||||
autofocusEl.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMaximize(item: DialogInstance) {
|
||||
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
@@ -45,28 +44,23 @@ const mockSubscription = vi.hoisted(() => ({
|
||||
value: null as { endDate: string | null } | null
|
||||
}))
|
||||
|
||||
const mockCancelSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStatus = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
cancelSubscription: mockCancelSubscription,
|
||||
fetchStatus: mockFetchStatus,
|
||||
cancelSubscription: vi.fn(),
|
||||
fetchStatus: vi.fn(),
|
||||
subscription: mockSubscription
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: mockCloseDialog
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
add: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -92,54 +86,6 @@ function renderComponent(props: { cancelAt?: string } = {}) {
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('cancel flow', () => {
|
||||
it('shows an error toast and keeps the dialog open when cancellation fails', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockRejectedValueOnce(
|
||||
new Error('Subscription cancellation timed out')
|
||||
)
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Subscription cancellation timed out'
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the dialog and shows a success toast when cancellation succeeds', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
)
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formattedEndDate fallbacks', () => {
|
||||
it('uses the localized fallback when no cancel timestamp is available', () => {
|
||||
mockSubscription.value = { endDate: null }
|
||||
|
||||
@@ -27,19 +27,8 @@ function isInsideOverlay(target: EventTarget | null): boolean {
|
||||
|
||||
export function onRekaPointerDownOutside(
|
||||
options: { dismissableMask?: boolean },
|
||||
event: OutsideEvent,
|
||||
isActive = true
|
||||
event: OutsideEvent
|
||||
) {
|
||||
// Stacked dialogs each render an independent Reka `Dialog` root, so a lower
|
||||
// dialog's DismissableLayer sees a pointer-down that opened (or landed on)
|
||||
// the dialog above it as "outside" and would dismiss itself — including via
|
||||
// the upper dialog's overlay, whose element matches none of the portal
|
||||
// selectors below. Only the top-most dialog may dismiss on an outside
|
||||
// pointer, mirroring the escape-key handling in `GlobalDialog`.
|
||||
if (!isActive) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
|
||||
@@ -7,26 +7,12 @@ import { createI18n } from 'vue-i18n'
|
||||
import ErrorOverlay from './ErrorOverlay.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
const mockErrorGroups = vi.hoisted(() => ({
|
||||
allErrorGroups: { value: [] as ErrorGroup[] },
|
||||
missingPackGroups: { value: [] as MissingPackGroup[] },
|
||||
missingModelGroups: { value: [] as MissingModelGroup[] },
|
||||
missingMediaGroups: { value: [] as MissingMediaGroup[] },
|
||||
swapNodeGroups: { value: [] as SwapNodeGroup[] }
|
||||
}))
|
||||
|
||||
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => mockErrorGroups
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
@@ -76,6 +62,7 @@ function createTestI18n() {
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.',
|
||||
viewDetails: 'View details'
|
||||
@@ -121,10 +108,6 @@ function renderOverlay(props: { appMode?: boolean } = {}) {
|
||||
describe('ErrorOverlay', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockErrorGroups.missingPackGroups.value = []
|
||||
mockErrorGroups.missingModelGroups.value = []
|
||||
mockErrorGroups.missingMediaGroups.value = []
|
||||
mockErrorGroups.swapNodeGroups.value = []
|
||||
mockOpenPanel.mockClear()
|
||||
mockCanvasStore.linearMode = false
|
||||
mockCanvasStore.canvas = null
|
||||
@@ -133,12 +116,17 @@ describe('ErrorOverlay', () => {
|
||||
})
|
||||
|
||||
it('renders a single overlay message without list markup', async () => {
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -149,12 +137,6 @@ describe('ErrorOverlay', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -163,19 +145,21 @@ describe('ErrorOverlay', () => {
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'View details'
|
||||
)
|
||||
expect(screen.getByTestId('error-overlay-dismiss')).toHaveAccessibleName(
|
||||
'Close'
|
||||
)
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the app mode button label', async () => {
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -186,12 +170,6 @@ describe('ErrorOverlay', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,35 +7,47 @@
|
||||
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="error-overlay"
|
||||
class="pointer-events-auto relative flex w-fit max-w-120 min-w-80 flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div class="flex w-full items-start gap-2 pr-8">
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-5 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-start gap-2 pr-8"
|
||||
data-testid="error-overlay-messages"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3" data-testid="error-overlay-messages">
|
||||
<p
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-3">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
data-testid="error-overlay-dismiss"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
size="lg"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
>
|
||||
@@ -46,17 +58,6 @@
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-2 right-2 size-6 rounded-sm"
|
||||
data-testid="error-overlay-dismiss"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-4 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -8,26 +8,12 @@ import { useErrorOverlayState } from './useErrorOverlayState'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
const mockErrorGroups = vi.hoisted(() => ({
|
||||
allErrorGroups: { value: [] as ErrorGroup[] },
|
||||
missingPackGroups: { value: [] as MissingPackGroup[] },
|
||||
missingModelGroups: { value: [] as MissingModelGroup[] },
|
||||
missingMediaGroups: { value: [] as MissingMediaGroup[] },
|
||||
swapNodeGroups: { value: [] as SwapNodeGroup[] }
|
||||
}))
|
||||
|
||||
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => mockErrorGroups
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
@@ -58,6 +44,7 @@ function createTestI18n() {
|
||||
messages: {
|
||||
en: {
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.'
|
||||
}
|
||||
@@ -105,19 +92,20 @@ function mountOverlayState() {
|
||||
describe('useErrorOverlayState', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockErrorGroups.missingPackGroups.value = []
|
||||
mockErrorGroups.missingModelGroups.value = []
|
||||
mockErrorGroups.missingMediaGroups.value = []
|
||||
mockErrorGroups.swapNodeGroups.value = []
|
||||
})
|
||||
|
||||
it('uses the raw message for a single uncataloged execution error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -128,12 +116,6 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -143,12 +125,17 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses toast copy for a single validation error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Required input is missing',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -165,12 +152,6 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -183,12 +164,17 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses display copy before raw copy when toast copy is absent', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Friendly validation title',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -204,12 +190,6 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -222,12 +202,24 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses toast copy for a single runtime error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Generation failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -245,19 +237,6 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -268,44 +247,6 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses group toast copy for a single missing media error', async () => {
|
||||
mockErrorGroups.missingMediaGroups.value = [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'image.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'image.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
count: 1,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -320,6 +261,17 @@ describe('useErrorOverlayState', () => {
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -329,147 +281,6 @@ describe('useErrorOverlayState', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group copy for one missing model referenced by multiple nodes', async () => {
|
||||
mockErrorGroups.missingModelGroups.value = [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: [
|
||||
{
|
||||
name: 'missing.safetensors',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{ nodeId: '1', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '2', widgetName: 'ckpt_name' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_model',
|
||||
groupKey: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: 'Model missing',
|
||||
toastMessage: 'CheckpointLoaderSimple is missing missing.safetensors.',
|
||||
count: 1,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Missing Models')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Import a model, or open the node to replace it.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group copy for one execution group with multiple errors', async () => {
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:required_input_missing',
|
||||
displayTitle: 'Missing connection',
|
||||
displayMessage: 'Required input slots have no connection feeding them.',
|
||||
count: 2,
|
||||
priority: 1,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{ message: 'KSampler is missing model' },
|
||||
{ message: 'KSampler is missing positive' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Missing connection')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses aggregate copy for one missing model group with multiple rows', async () => {
|
||||
mockErrorGroups.missingModelGroups.value = [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: [
|
||||
{
|
||||
name: 'first.safetensors',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'first.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
|
||||
},
|
||||
{
|
||||
name: 'second.safetensors',
|
||||
representative: {
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'second.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '2', widgetName: 'ckpt_name' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_model',
|
||||
groupKey: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: 'Missing models',
|
||||
toastMessage: '2 model files are missing.',
|
||||
count: 2,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('2 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show when a raw error has no resolved overlay message', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
@@ -484,14 +295,26 @@ describe('useErrorOverlayState', () => {
|
||||
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('uses grouped error counts for aggregate copy', async () => {
|
||||
it('uses aggregate copy for multiple errors', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError([
|
||||
'First error',
|
||||
'Second error',
|
||||
'Third error',
|
||||
'Fourth error',
|
||||
'Fifth error',
|
||||
'Sixth error',
|
||||
'Seventh error'
|
||||
])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
displayMessage: 'First group message',
|
||||
count: 2,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -500,31 +323,13 @@ describe('useErrorOverlayState', () => {
|
||||
errors: [{ message: 'First error' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:CLIPTextEncode',
|
||||
displayTitle: 'Invalid CLIP input',
|
||||
displayMessage: 'Second group message',
|
||||
count: 3,
|
||||
priority: 1,
|
||||
cards: [
|
||||
{
|
||||
id: '2',
|
||||
title: 'CLIPTextEncode',
|
||||
errors: [{ message: 'Second error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('5 errors found')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
|
||||
@@ -4,17 +4,11 @@ import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
type OverlayCopy = { title?: string; message: string }
|
||||
|
||||
function resolveSingleOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
function resolveSingleOverlayCopy(
|
||||
group: ErrorGroup
|
||||
): { title?: string; message: string } | undefined {
|
||||
if (group.type === 'execution') {
|
||||
const [card] = group.cards
|
||||
const [error] = card?.errors ?? []
|
||||
@@ -43,119 +37,27 @@ function resolveSingleOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGroupOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
const message =
|
||||
group.displayMessage ?? group.toastMessage ?? group.displayTitle
|
||||
if (!message) return undefined
|
||||
|
||||
return {
|
||||
title: group.displayTitle,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
function countMissingNodeReferences(groups: MissingPackGroup[]): number {
|
||||
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
|
||||
}
|
||||
|
||||
function countSwapNodeReferences(groups: SwapNodeGroup[]): number {
|
||||
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
|
||||
}
|
||||
|
||||
function getMissingModelRows(groups: MissingModelGroup[]) {
|
||||
return groups.flatMap((group) => group.models)
|
||||
}
|
||||
|
||||
function getMissingMediaRows(groups: MissingMediaGroup[]) {
|
||||
return groups.flatMap((group) => group.items)
|
||||
}
|
||||
|
||||
function hasSingleRowWithAtMostOneReference(
|
||||
rows: Array<{ referencingNodes: readonly unknown[] }>
|
||||
): boolean {
|
||||
const row = rows[0]
|
||||
return (
|
||||
rows.length === 1 && row !== undefined && row.referencingNodes.length <= 1
|
||||
)
|
||||
}
|
||||
|
||||
interface OverlayGroupContext {
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
}
|
||||
|
||||
function isSingleLeafGroup(
|
||||
group: ErrorGroup,
|
||||
context: OverlayGroupContext
|
||||
): boolean {
|
||||
if (group.type === 'execution') {
|
||||
return group.cards.length === 1 && group.cards[0]?.errors.length === 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_node') {
|
||||
return (
|
||||
context.missingPackGroups.length === 1 &&
|
||||
countMissingNodeReferences(context.missingPackGroups) === 1
|
||||
)
|
||||
}
|
||||
|
||||
if (group.type === 'swap_nodes') {
|
||||
return (
|
||||
context.swapNodeGroups.length === 1 &&
|
||||
countSwapNodeReferences(context.swapNodeGroups) === 1
|
||||
)
|
||||
}
|
||||
|
||||
if (group.type === 'missing_model') {
|
||||
return hasSingleRowWithAtMostOneReference(
|
||||
getMissingModelRows(context.missingModelGroups)
|
||||
)
|
||||
}
|
||||
|
||||
return hasSingleRowWithAtMostOneReference(
|
||||
getMissingMediaRows(context.missingMediaGroups)
|
||||
)
|
||||
}
|
||||
|
||||
function shouldUseAggregateCopyForSingleGroup(
|
||||
group: ErrorGroup,
|
||||
context: OverlayGroupContext
|
||||
): boolean {
|
||||
if (group.type === 'missing_node') {
|
||||
return context.missingPackGroups.length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'swap_nodes') {
|
||||
return context.swapNodeGroups.length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_model') {
|
||||
return getMissingModelRows(context.missingModelGroups).length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_media') {
|
||||
return getMissingMediaRows(context.missingMediaGroups).length > 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function useErrorOverlayState() {
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||
const {
|
||||
allErrorGroups,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups('')
|
||||
const { totalErrorCount, isErrorOverlayOpen } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { allErrorGroups } = useErrorGroups('')
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
|
||||
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
|
||||
const singleErrorGroup = computed(() =>
|
||||
hasExactlyOneError.value && allErrorGroups.value.length === 1
|
||||
? allErrorGroups.value[0]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const multipleErrorCountLabel = computed(() =>
|
||||
@@ -166,38 +68,25 @@ export function useErrorOverlayState() {
|
||||
)
|
||||
)
|
||||
|
||||
const aggregateOverlayCopy = computed<OverlayCopy>(() => ({
|
||||
title: multipleErrorCountLabel.value,
|
||||
message: t('errorOverlay.multipleErrorsMessage')
|
||||
}))
|
||||
const singleOverlayCopy = computed(() =>
|
||||
singleErrorGroup.value
|
||||
? resolveSingleOverlayCopy(singleErrorGroup.value)
|
||||
: undefined
|
||||
)
|
||||
|
||||
const overlayCopy = computed<OverlayCopy | undefined>(() => {
|
||||
const groups = allErrorGroups.value
|
||||
if (groups.length === 0) return undefined
|
||||
if (groups.length > 1) return aggregateOverlayCopy.value
|
||||
|
||||
const [group] = groups
|
||||
const context = {
|
||||
missingPackGroups: missingPackGroups.value,
|
||||
missingModelGroups: missingModelGroups.value,
|
||||
missingMediaGroups: missingMediaGroups.value,
|
||||
swapNodeGroups: swapNodeGroups.value
|
||||
const overlayMessage = computed(() => {
|
||||
if (hasMultipleErrors.value) {
|
||||
return t('errorOverlay.multipleErrorsMessage')
|
||||
}
|
||||
|
||||
if (shouldUseAggregateCopyForSingleGroup(group, context)) {
|
||||
return aggregateOverlayCopy.value
|
||||
}
|
||||
|
||||
if (isSingleLeafGroup(group, context)) {
|
||||
return resolveSingleOverlayCopy(group) ?? resolveGroupOverlayCopy(group)
|
||||
}
|
||||
|
||||
return resolveGroupOverlayCopy(group)
|
||||
return singleOverlayCopy.value?.message ?? ''
|
||||
})
|
||||
|
||||
const overlayMessage = computed(() => overlayCopy.value?.message ?? '')
|
||||
|
||||
const overlayTitle = computed(() => overlayCopy.value?.title ?? '')
|
||||
const overlayTitle = computed(() =>
|
||||
hasMultipleErrors.value
|
||||
? multipleErrorCountLabel.value
|
||||
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() =>
|
||||
|
||||
@@ -57,85 +57,154 @@ function drawFrame(canvas: LGraphCanvas) {
|
||||
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
|
||||
}
|
||||
|
||||
describe('DomWidgets positioning', () => {
|
||||
describe('DomWidgets transition grace characterization', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('positions an active visible widget relative to its owning node', () => {
|
||||
it('applies transition grace for exactly one frame when override exists but is not active', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graph = new LGraph()
|
||||
const node = createNode(graph, 1, 'host', [100, 200])
|
||||
const widget = createWidget('widget-pos', node, 14)
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
|
||||
|
||||
const widget = createWidget('widget-transition', interiorNode, 14)
|
||||
const overrideWidget = createWidget('override-widget', overrideNode, 22)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
|
||||
const canvas = createCanvas(graph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([110, 224])
|
||||
})
|
||||
|
||||
it('hides a widget whose owning node is in a different graph', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const currentGraph = new LGraph()
|
||||
const otherGraph = new LGraph()
|
||||
const node = createNode(otherGraph, 1, 'host', [100, 200])
|
||||
const widget = createWidget('widget-other-graph', node, 14)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
|
||||
const canvas = createCanvas(currentGraph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('hides an inactive widget', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graph = new LGraph()
|
||||
const node = createNode(graph, 1, 'host', [0, 0])
|
||||
const widget = createWidget('widget-inactive', node, 10)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
widgetState.visible = true
|
||||
widgetState.pos = [321, 654]
|
||||
|
||||
const canvas = createCanvas(graph)
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([321, 654])
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
|
||||
|
||||
const widget = createWidget('widget-override-active', interiorNode, 8)
|
||||
const overrideWidget = createWidget(
|
||||
'override-position-source',
|
||||
overrideNode,
|
||||
18
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
|
||||
const canvas = createCanvas(graphB)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(widgetState.visible).toBe(false)
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([310, 428])
|
||||
})
|
||||
|
||||
it('cleans orphaned transition-grace ids after widget removal', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
const overrideWidget = createWidget(
|
||||
'shared-override-widget',
|
||||
overrideNode,
|
||||
14
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(oldWidget)
|
||||
domWidgetStore.setPositionOverride(oldWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
domWidgetStore.unregisterWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
domWidgetStore.registerWidget(replacementWidget)
|
||||
domWidgetStore.setPositionOverride(replacementWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(replacementWidget.id)
|
||||
|
||||
const replacementState = domWidgetStore.widgetStates.get(
|
||||
replacementWidget.id
|
||||
)
|
||||
if (!replacementState) throw new Error('Replacement widget missing state')
|
||||
replacementState.visible = true
|
||||
replacementState.pos = [999, 999]
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(replacementState.visible).toBe(true)
|
||||
expect(replacementState.pos).toEqual([999, 999])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const overrideTransitionGrace = new Set<string>()
|
||||
|
||||
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
|
||||
|
||||
@@ -30,16 +31,47 @@ const updateWidgets = () => {
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
const currentGraph = lgCanvas.graph
|
||||
const seenWidgetIds = new Set<string>()
|
||||
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
seenWidgetIds.add(widget.id)
|
||||
|
||||
if (!widget.isVisible() || !widgetState.active) {
|
||||
// Use position override only when the override node (SubgraphNode) is
|
||||
// in the current graph. When the user enters the subgraph, the override
|
||||
// node is no longer visible — fall back to the widget's own node.
|
||||
// Use graph reference equality (IDs are not unique across graphs).
|
||||
const override = widgetState.positionOverride
|
||||
const useOverride = !!override && currentGraph === override.node.graph
|
||||
const inOverrideTransitionGap =
|
||||
!!override && !useOverride && !widgetState.active
|
||||
const useTransitionGrace =
|
||||
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
|
||||
|
||||
if (useTransitionGrace) {
|
||||
overrideTransitionGrace.add(widget.id)
|
||||
} else if (!inOverrideTransitionGap) {
|
||||
overrideTransitionGrace.delete(widget.id)
|
||||
}
|
||||
|
||||
// Early exit for non-visible widgets.
|
||||
// When a position override is active (widget promoted to SubgraphNode),
|
||||
// the interior widget's `active` flag is false (its node is in the
|
||||
// subgraph, not the current graph) — bypass that check.
|
||||
if (
|
||||
!widget.isVisible() ||
|
||||
(!widgetState.active && !useOverride && !useTransitionGrace)
|
||||
) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
const posNode = widget.node
|
||||
// During graph transitions, hold the previous position for one frame
|
||||
// so promoted widgets don't briefly disappear before activation flips.
|
||||
if (useTransitionGrace) continue
|
||||
|
||||
const posNode = useOverride ? override.node : widget.node
|
||||
const posWidget = useOverride ? override.widget : widget
|
||||
|
||||
const isInCorrectGraph = posNode.graph === currentGraph
|
||||
const nodeVisible = lgCanvas.isNodeVisible(posNode)
|
||||
@@ -53,16 +85,22 @@ const updateWidgets = () => {
|
||||
const margin = widget.margin
|
||||
widgetState.pos = [
|
||||
posNode.pos[0] + margin,
|
||||
posNode.pos[1] + margin + widget.y
|
||||
posNode.pos[1] + margin + posWidget.y
|
||||
]
|
||||
widgetState.size = [
|
||||
(widget.width ?? posNode.width) - margin * 2,
|
||||
(widget.computedHeight ?? 50) - margin * 2
|
||||
(posWidget.width ?? posNode.width) - margin * 2,
|
||||
(posWidget.computedHeight ?? 50) - margin * 2
|
||||
]
|
||||
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
|
||||
widgetState.readonly = lgCanvas.read_only
|
||||
}
|
||||
}
|
||||
|
||||
for (const widgetId of overrideTransitionGrace) {
|
||||
if (!seenWidgetIds.has(widgetId)) {
|
||||
overrideTransitionGrace.delete(widgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -54,7 +54,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
function createWidgetState(disabled: boolean): DomWidgetState {
|
||||
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const node = createMockLGraphNode({
|
||||
id: 1,
|
||||
@@ -70,10 +70,14 @@ function createWidgetState(disabled: boolean): DomWidgetState {
|
||||
value: '',
|
||||
options: {},
|
||||
node,
|
||||
computedDisabled: disabled
|
||||
computedDisabled: false
|
||||
})
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: createMockLGraphNode({ id: 2 }),
|
||||
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
|
||||
})
|
||||
|
||||
const state = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!state) throw new Error('Expected registered DomWidgetState')
|
||||
@@ -94,7 +98,7 @@ describe('DomWidget disabled style', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses disabled style when widget is computedDisabled', async () => {
|
||||
it('uses disabled style when promoted override widget is computedDisabled', async () => {
|
||||
const widgetState = createWidgetState(true)
|
||||
const { container } = render(DomWidget, {
|
||||
props: {
|
||||
|
||||
@@ -69,7 +69,11 @@ const updateDomClipping = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = selectedNode === widgetState.widget.node
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
|
||||
const isSelected = selectedNode === ownerNode
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
@@ -100,7 +104,10 @@ const updateDomClipping = () => {
|
||||
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
|
||||
|
||||
function composeStyle() {
|
||||
const isDisabled = widget.computedDisabled
|
||||
const override = widgetState.positionOverride
|
||||
const isDisabled = override
|
||||
? (override.widget.computedDisabled ?? widget.computedDisabled)
|
||||
: widget.computedDisabled
|
||||
|
||||
style.value = {
|
||||
...positionStyle.value,
|
||||
@@ -160,7 +167,13 @@ onMounted(() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const ownerNode = widgetState.widget.node
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph
|
||||
? override.node
|
||||
: widgetState.widget.node
|
||||
|
||||
lgCanvas.selectNode(ownerNode)
|
||||
lgCanvas.bringToFront(ownerNode)
|
||||
}
|
||||
|
||||
@@ -41,9 +41,7 @@ const openIn3DViewer = () => {
|
||||
component: Load3DViewerContent,
|
||||
props: props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true,
|
||||
onClose: async () => {
|
||||
await useLoad3dService().handleViewerClose(props.node)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
:aria-label="
|
||||
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
|
||||
"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
|
||||
collapse && '-rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,31 +1,29 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
@@ -36,7 +34,7 @@
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
@@ -51,7 +49,7 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
@@ -61,29 +59,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
@@ -98,13 +96,13 @@
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
@@ -117,61 +115,60 @@
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-1 py-1">
|
||||
<span
|
||||
class="text-xs font-semibold text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto">
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-4" />
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-testid="missing-node-card" class="px-3">
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal"
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
@@ -80,7 +80,7 @@
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
@@ -89,7 +89,7 @@
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
@@ -122,10 +122,10 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-xs">
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
@@ -150,7 +150,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -163,7 +163,7 @@
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none p-0',
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -190,7 +190,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -199,7 +199,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
@@ -241,7 +241,7 @@ const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
@@ -78,10 +78,6 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
@@ -122,6 +118,9 @@ describe('TabErrors.vue', () => {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
@@ -212,13 +211,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Errors detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
@@ -333,9 +326,6 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
||||
1
|
||||
)
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('1')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -414,7 +404,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -424,40 +414,6 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts missing models per file when several share one directory', () => {
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-a.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-b.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
}
|
||||
] satisfies MissingModelCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-model')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
@@ -475,7 +431,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
@@ -497,7 +453,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
@@ -539,12 +495,6 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-media')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
@@ -553,73 +503,6 @@ describe('TabErrors.vue', () => {
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
|
||||
})
|
||||
|
||||
it('sums the summary hero count across error types', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'Node'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: model',
|
||||
extra_info: { input_name: 'model' }
|
||||
},
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: positive',
|
||||
extra_info: { input_name: 'positive' }
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: clip',
|
||||
extra_info: { input_name: 'clip' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'a.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '4',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'b.png',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
} satisfies { missingMediaCandidates: MissingMediaCandidate[] }
|
||||
})
|
||||
|
||||
// 3 validation items + 2 missing media references
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('5')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
@@ -643,7 +526,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -11,62 +11,49 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
v-if="filteredGroups.length === 0"
|
||||
key="empty"
|
||||
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span class="truncate text-destructive-background-hover">
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
@@ -75,7 +62,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
@@ -96,7 +83,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
@@ -109,7 +96,7 @@
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@@ -142,142 +129,140 @@
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div
|
||||
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
|
||||
>
|
||||
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
@@ -319,10 +304,10 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
@@ -338,7 +323,6 @@ import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { isExecutionItemListGroup } from './executionItemList'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
@@ -372,6 +356,31 @@ const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
group.cards.length > 0 &&
|
||||
group.cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
@@ -403,6 +412,14 @@ function compareExecutionItemListEntry(
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionGroupCount(group: ErrorGroup) {
|
||||
if (group.type !== 'execution') return 0
|
||||
if (isExecutionItemListGroup(group)) {
|
||||
return group.cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
return group.cards.length
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
@@ -435,10 +452,6 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
|
||||
export function shouldRenderExecutionItemList(cards: ErrorCardData[]): boolean {
|
||||
return (
|
||||
cards.length > 0 &&
|
||||
cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function isExecutionItemListGroup(group: ErrorGroup): boolean {
|
||||
return (
|
||||
group.type === 'execution' && shouldRenderExecutionItemList(group.cards)
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,6 @@ interface ErrorGroupBase extends Omit<ResolvedErrorMessage, 'displayTitle'> {
|
||||
groupKey: string
|
||||
/** Human-friendly title resolved for UI display. */
|
||||
displayTitle: string
|
||||
count: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
@@ -793,6 +793,53 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupedErrorMessages', () => {
|
||||
it('returns empty array when no errors', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.groupedErrorMessages.value).toEqual([])
|
||||
})
|
||||
|
||||
it('collects unique display messages from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'err_a', message: 'Error A', details: '' },
|
||||
{ type: 'err_b', message: 'Error B', details: '' }
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const messages = groups.groupedErrorMessages.value
|
||||
expect(messages).toEqual([unknownValidationMessage])
|
||||
})
|
||||
|
||||
it('includes missing node group display message', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(groups.groupedErrorMessages.value).toContain(
|
||||
missingGroup!.displayMessage
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missingModelGroups', () => {
|
||||
it('returns empty array when no missing models', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
@@ -935,7 +982,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
expect(modelGroup?.groupKey).toBe('missing_model')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1051,7 +1098,7 @@ describe('useErrorGroups', () => {
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -25,15 +25,14 @@ import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import { shouldRenderExecutionItemList } from './executionItemList'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import {
|
||||
countMissingModels,
|
||||
groupMissingModelCandidates
|
||||
} from '@/platform/missingModel/missingModelGrouping'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import {
|
||||
@@ -50,6 +49,9 @@ const PROMPT_CARD_ID = '__prompt__'
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
/** Sentinel key for grouping non-asset-supported missing models. */
|
||||
const UNSUPPORTED = Symbol('unsupported')
|
||||
|
||||
export interface MissingPackGroup {
|
||||
packId: string | null
|
||||
nodeTypes: MissingNodeType[]
|
||||
@@ -150,28 +152,16 @@ function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
|
||||
return compareExecutionId(a.nodeId, b.nodeId)
|
||||
}
|
||||
|
||||
function countExecutionCards(cards: ErrorCardData[]): number {
|
||||
if (shouldRenderExecutionItemList(cards)) {
|
||||
return cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
|
||||
return cards.length
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
return Array.from(groupsMap.entries())
|
||||
.map(([rawGroupKey, groupData]) => {
|
||||
const cards = Array.from(groupData.cards.values()).sort(compareNodeId)
|
||||
return {
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
count: countExecutionCards(cards),
|
||||
cards,
|
||||
priority: groupData.priority
|
||||
}
|
||||
})
|
||||
.map(([rawGroupKey, groupData]) => ({
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority
|
||||
return a.displayTitle.localeCompare(b.displayTitle)
|
||||
@@ -230,13 +220,11 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
return groups
|
||||
.map((group, gi) => {
|
||||
if (group.type !== 'execution') return group
|
||||
const cards = group.cards.filter((_: ErrorCardData, ci: number) =>
|
||||
matchedCardKeys.has(`${gi}:${ci}`)
|
||||
)
|
||||
return {
|
||||
...group,
|
||||
cards,
|
||||
count: countExecutionCards(cards)
|
||||
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
|
||||
matchedCardKeys.has(`${gi}:${ci}`)
|
||||
)
|
||||
}
|
||||
})
|
||||
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
|
||||
@@ -603,7 +591,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groups.push({
|
||||
type: 'swap_nodes' as const,
|
||||
groupKey: 'swap_nodes',
|
||||
count: swapNodeGroups.value.length,
|
||||
priority: 0,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
@@ -618,7 +605,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groups.push({
|
||||
type: 'missing_node' as const,
|
||||
groupKey: 'missing_node',
|
||||
count: missingPackGroups.value.length,
|
||||
priority: 1,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
@@ -632,21 +618,60 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return groups.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
|
||||
* Within each group, candidates with the same model name are merged into a single view model. */
|
||||
const missingModelGroups = computed<MissingModelGroup[]>(() => {
|
||||
return groupMissingModelCandidates(
|
||||
missingModelStore.missingModelCandidates,
|
||||
isCloud
|
||||
)
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
|
||||
type GroupKey = string | null | typeof UNSUPPORTED
|
||||
const map = new Map<
|
||||
GroupKey,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
|
||||
for (const c of candidates) {
|
||||
const groupKey: GroupKey =
|
||||
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
|
||||
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
// All candidates in the same directory share the same isAssetSupported
|
||||
// value in practice (a directory is either asset-supported or not).
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
})
|
||||
|
||||
function buildMissingModelGroups(): ErrorGroup[] {
|
||||
if (!missingModelGroups.value.length) return []
|
||||
const count = countMissingModels(missingModelGroups.value)
|
||||
const count = missingModelGroups.value.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
groupKey: 'missing_model',
|
||||
count,
|
||||
priority: 2,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
@@ -671,7 +696,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
groupKey: 'missing_media',
|
||||
count: totalRows,
|
||||
priority: 3,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
@@ -713,7 +737,37 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
|
||||
const map = new Map<
|
||||
string | null | typeof UNSUPPORTED,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
for (const c of filtered) {
|
||||
const groupKey =
|
||||
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredMissingMediaGroups = computed(() => {
|
||||
@@ -729,12 +783,14 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingModelGroups.value.length) return []
|
||||
const count = countMissingModels(filteredMissingModelGroups.value)
|
||||
const count = filteredMissingModelGroups.value.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
groupKey: 'missing_model',
|
||||
count,
|
||||
priority: 2,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
@@ -755,7 +811,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
groupKey: 'missing_media',
|
||||
count: totalRows,
|
||||
priority: 3,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
@@ -810,6 +865,22 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
const groupedErrorMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type === 'execution') {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.displayMessage ?? err.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.add(group.displayMessage ?? group.displayTitle)
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
})
|
||||
|
||||
return {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
@@ -818,6 +889,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
|
||||
@@ -12,15 +12,14 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -147,17 +146,16 @@ function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
const source = widgetPromotedSource(widgetNode, widget)
|
||||
return parents.some((parent) => {
|
||||
if (source) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? source.nodeId
|
||||
? widget.sourceNodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
})
|
||||
}
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
@@ -236,10 +234,7 @@ function navigateToErrorTab() {
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
|
||||
// are addressed by widgetId; writing there keeps the displayed value in sync.
|
||||
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
|
||||
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
@@ -250,18 +245,18 @@ function handleResetAllWidgets() {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||
const defaultValue = getWidgetDefaultValue(spec)
|
||||
if (defaultValue !== undefined) {
|
||||
setWidgetValue(widget, defaultValue)
|
||||
writeWidgetValue(widget, defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
if (newValue === undefined) return
|
||||
setWidgetValue(widget, newValue)
|
||||
writeWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
setWidgetValue(widget, newValue)
|
||||
writeWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
import TabSubgraphInputs from './TabSubgraphInputs.vue'
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
|
||||
})
|
||||
|
||||
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
|
||||
rows: []
|
||||
}
|
||||
|
||||
const SectionWidgetsStub = {
|
||||
props: ['widgets', 'node', 'parents'],
|
||||
setup(props: Record<string, unknown>) {
|
||||
captured.rows = props.widgets as {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}[]
|
||||
return () => null
|
||||
}
|
||||
}
|
||||
|
||||
function buildHostWithPromotedSeed(): {
|
||||
host: SubgraphNode
|
||||
sourceNode: LGraphNode
|
||||
} {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const graph = host.graph as LGraph
|
||||
graph.add(host)
|
||||
|
||||
const sourceNode = new LGraphNode('Sampler')
|
||||
const input = sourceNode.addInput('seed', 'INT')
|
||||
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
|
||||
input.widget = { name: seedWidget.name }
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
|
||||
return { host, sourceNode }
|
||||
}
|
||||
|
||||
function renderPanel(node: SubgraphNode) {
|
||||
return render(TabSubgraphInputs, {
|
||||
props: { node },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SectionWidgets: SectionWidgetsStub,
|
||||
AsyncSearchInput: true,
|
||||
CollapseToggleButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TabSubgraphInputs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
captured.rows = []
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow).toBeDefined()
|
||||
expect(seedRow?.node.id).toBe(host.id)
|
||||
expect(seedRow?.widget.type).toBe('number')
|
||||
expect(seedRow?.widget.widgetId).toBe(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
)
|
||||
expect(seedRow?.widget.value).toBe(42)
|
||||
})
|
||||
|
||||
it('reflects the current host widget value from the store', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
const id = widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
useWidgetValueStore().setValue(id, 7)
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow?.widget.value).toBe(7)
|
||||
})
|
||||
|
||||
it('reflects value changes through the same descriptor without rebuilding it', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
|
||||
expect(seedRow.widget.value).toBe(42)
|
||||
|
||||
// A value edit must not require a new descriptor object: the same row
|
||||
// reflects the store change via its live getter, keeping render keys stable.
|
||||
useWidgetValueStore().setValue(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed'),
|
||||
100
|
||||
)
|
||||
expect(seedRow.widget.value).toBe(100)
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,14 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
@@ -44,6 +45,32 @@ const isAllCollapsed = computed({
|
||||
})
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(a) &&
|
||||
isPromotedWidgetView(b) &&
|
||||
a.sourceNodeId === b.sourceNodeId &&
|
||||
a.sourceWidgetName === b.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function getPromotedWidgets(): IBaseWidget[] {
|
||||
const inputWidgets = node.inputs
|
||||
.map((input) => input._widget)
|
||||
.filter((widget): widget is IBaseWidget =>
|
||||
Boolean(widget && isPromotedWidgetView(widget))
|
||||
)
|
||||
const extraWidgets = (node.widgets ?? []).filter(
|
||||
(widget) =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
!inputWidgets.some((inputWidget) =>
|
||||
isSamePromotedWidget(inputWidget, widget)
|
||||
)
|
||||
)
|
||||
|
||||
return [...inputWidgets, ...extraWidgets]
|
||||
}
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
async (section) => {
|
||||
@@ -66,7 +93,7 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
|
||||
return getPromotedWidgets().map((widget) => ({ node, widget }))
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
isLinkedPromotion,
|
||||
promoteWidget
|
||||
@@ -17,7 +16,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -47,10 +45,8 @@ const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const isLinked = computed(() => {
|
||||
if (!node.isSubgraphNode()) return false
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (!source) return false
|
||||
return isLinkedPromotion(node, source.nodeId, source.widgetName)
|
||||
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
|
||||
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
|
||||
})
|
||||
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
|
||||
const favoriteNode = computed(() =>
|
||||
@@ -68,16 +64,9 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
|
||||
|
||||
const hasDefault = computed(() => defaultValue.value !== undefined)
|
||||
|
||||
const currentValue = computed(
|
||||
() =>
|
||||
(widget.widgetId &&
|
||||
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
|
||||
widget.value
|
||||
)
|
||||
|
||||
const isCurrentValueDefault = computed(() => {
|
||||
if (!hasDefault.value) return true
|
||||
return isEqual(currentValue.value, defaultValue.value)
|
||||
return isEqual(widget.value, defaultValue.value)
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
@@ -88,15 +77,21 @@ async function handleRename() {
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (source) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
for (const parent of parents) {
|
||||
const sourceNodeId =
|
||||
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
|
||||
demotePromotedInput(parent, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id)
|
||||
demoteWidget(
|
||||
{
|
||||
id: sourceNodeId,
|
||||
title: node.title,
|
||||
type: node.type
|
||||
},
|
||||
widget,
|
||||
[parent]
|
||||
)
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
} else {
|
||||
|
||||
@@ -7,8 +7,6 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
|
||||
@@ -44,6 +42,10 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
@@ -94,6 +96,43 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock PromotedWidgetView that mirrors the real class:
|
||||
* properties like name, type, value, options are prototype getters,
|
||||
* NOT own properties — so object spread loses them.
|
||||
*/
|
||||
function createMockPromotedWidgetView(
|
||||
sourceOptions: IBaseWidget['options'] = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
): IBaseWidget {
|
||||
class MockPromotedWidgetView {
|
||||
readonly sourceNodeId = '42'
|
||||
readonly sourceWidgetName = 'ckpt_name'
|
||||
readonly serialize = false
|
||||
|
||||
get name(): string {
|
||||
return 'ckpt_name'
|
||||
}
|
||||
get type(): string {
|
||||
return 'combo'
|
||||
}
|
||||
get value(): unknown {
|
||||
return 'model_a.safetensors'
|
||||
}
|
||||
get options(): IBaseWidget['options'] {
|
||||
return sourceOptions
|
||||
}
|
||||
get label(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
get y(): number {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
|
||||
}
|
||||
|
||||
function renderWidgetItem(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode = createMockNode()
|
||||
@@ -128,7 +167,7 @@ describe('WidgetItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('widget state rendering', () => {
|
||||
describe('promoted widget options', () => {
|
||||
it('passes options from a regular widget to the widget component', () => {
|
||||
const widget = createMockWidget({
|
||||
options: { values: ['a', 'b', 'c'] }
|
||||
@@ -141,63 +180,35 @@ describe('WidgetItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes options from widget state to the widget component', () => {
|
||||
it('passes options from a PromotedWidgetView to the widget component', () => {
|
||||
const expectedOptions = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: expectedOptions
|
||||
})
|
||||
|
||||
const widget = createMockPromotedWidgetView(expectedOptions)
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.options).toEqual(expectedOptions)
|
||||
})
|
||||
|
||||
it('passes type from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, type: 'string' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
it('passes type from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.type).toBe('combo')
|
||||
})
|
||||
|
||||
it('passes name from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
it('passes name from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.name).toBe('ckpt_name')
|
||||
})
|
||||
|
||||
it('passes value from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, value: 'source value' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
it('passes value from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -16,12 +17,11 @@ import {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
useWidgetValueStore,
|
||||
stripGraphPrefix
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
@@ -67,32 +67,35 @@ const widgetComponent = computed(() => {
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
const source = resolvePromotedWidgetSource(node, widget)
|
||||
return source ?? { node, widget }
|
||||
}
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(node.id))
|
||||
const widgetState = widget.widgetId
|
||||
? useWidgetValueStore().getWidget(widget.widgetId)
|
||||
: graphId
|
||||
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
|
||||
: undefined
|
||||
const widgetName = widgetState?.name ?? widget.name
|
||||
const widgetType = widgetState?.type ?? widget.type
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
name: widgetName,
|
||||
type: widgetType,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widgetState?.value ?? widget.value,
|
||||
label: widgetState?.label ?? widget.label,
|
||||
options: widgetState?.options ?? widget.options,
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
|
||||
controlWidget: getControlWidget(widget)
|
||||
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
|
||||
controlWidget: getControlWidget(sourceWidget)
|
||||
}
|
||||
})
|
||||
|
||||
const displayNodeName = computed((): string | null => {
|
||||
if (!node) return null
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
|
||||
if (!sourceNode) return null
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(node, {
|
||||
return resolveNodeDisplayName(sourceNode, {
|
||||
emptyLabel: fallbackNodeTitle,
|
||||
untitledLabel: fallbackNodeTitle,
|
||||
st
|
||||
@@ -164,10 +167,10 @@ const displayLabel = customRef((track, trigger) => {
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && displayNodeName"
|
||||
v-if="(showNodeName || hasParents) && sourceNodeName"
|
||||
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{{ displayNodeName }}
|
||||
{{ sourceNodeName }}
|
||||
</span>
|
||||
<div
|
||||
v-if="!hiddenWidgetActions"
|
||||
|
||||
@@ -14,9 +14,8 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import type DraggableList from '@/components/common/DraggableList.vue'
|
||||
@@ -168,20 +167,11 @@ describe('SubgraphEditor', () => {
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
const rowFor = (sourceNode: LGraphNode) => {
|
||||
const input = host.inputs.find((input) => {
|
||||
if (!input.widgetId) return false
|
||||
const target = resolveSubgraphInputTarget(host, input.name)
|
||||
return target?.nodeId === String(sourceNode.id)
|
||||
})!
|
||||
return {
|
||||
kind: 'promoted',
|
||||
node: sourceNode,
|
||||
input,
|
||||
widget: promotedInputWidget(input)!
|
||||
}
|
||||
}
|
||||
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
|
||||
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
|
||||
const reversed = [
|
||||
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
|
||||
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
|
||||
] as PromotedRow[]
|
||||
listSetter?.(reversed)
|
||||
await nextTick()
|
||||
|
||||
@@ -192,42 +182,6 @@ describe('SubgraphEditor', () => {
|
||||
).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('moves a widget to shown when promoted from the hidden section', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const sourceInput = sourceNode.addInput('first', 'STRING')
|
||||
const sourceWidget = sourceNode.addWidget('text', 'first', '', () => {})
|
||||
sourceInput.widget = { name: sourceWidget.name }
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
template:
|
||||
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const hidden = screen.getByTestId('subgraph-editor-hidden-section')
|
||||
await userEvent.click(within(hidden).getByTestId('subgraph-widget-toggle'))
|
||||
await nextTick()
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -259,13 +213,13 @@ describe('SubgraphEditor', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
const hideAllLink = within(shown).getByText('Hide all')
|
||||
await userEvent.click(hideAllLink)
|
||||
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
isLinkedPromotion,
|
||||
@@ -15,14 +16,8 @@ import {
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import {
|
||||
promotedInputSource,
|
||||
promotedInputWidget
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -38,8 +33,7 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
type PromotedRow = {
|
||||
kind: 'promoted'
|
||||
node: LGraphNode
|
||||
input: INodeInputSlot
|
||||
widget: IBaseWidget
|
||||
widget: PromotedWidgetView
|
||||
}
|
||||
type PreviewRow = {
|
||||
kind: 'preview'
|
||||
@@ -60,23 +54,11 @@ const activeNode = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const promotedRows = shallowRef<readonly PromotedRow[]>([])
|
||||
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return node.inputs.flatMap((input): PromotedRow[] => {
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) return []
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, input, widget }]
|
||||
})
|
||||
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
|
||||
function refreshPromotedWidgets() {
|
||||
promotedWidgets.value = activeNode.value?.widgets ?? []
|
||||
}
|
||||
function refreshPromotedRows() {
|
||||
const node = activeNode.value
|
||||
promotedRows.value = node ? buildPromotedRows(node) : []
|
||||
}
|
||||
watch(activeNode, refreshPromotedRows, { immediate: true })
|
||||
watch(activeNode, refreshPromotedWidgets, { immediate: true })
|
||||
useEventListener(
|
||||
() => activeNode.value?.subgraph.events,
|
||||
[
|
||||
@@ -86,29 +68,34 @@ useEventListener(
|
||||
'removing-input',
|
||||
'inputs-reordered'
|
||||
],
|
||||
refreshPromotedRows
|
||||
refreshPromotedWidgets
|
||||
)
|
||||
|
||||
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
|
||||
const node = activeNode.value
|
||||
return node ? promotedInputSource(node, row.input) : undefined
|
||||
}
|
||||
|
||||
const activeRows = computed<ActiveRow[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return [...promotedRows.value, ...getActivePreviewRows(node)]
|
||||
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
|
||||
})
|
||||
|
||||
const activePromotedRows = computed<PromotedRow[]>({
|
||||
get() {
|
||||
return [...promotedRows.value]
|
||||
const node = activeNode.value
|
||||
return node ? getActivePromotedRows(node) : []
|
||||
},
|
||||
set(value: PromotedRow[]) {
|
||||
updateActivePromotedRows(value, activePromotedRows.value)
|
||||
}
|
||||
})
|
||||
|
||||
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
|
||||
if (!isPromotedWidgetView(widget)) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, widget }]
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
|
||||
const hostLocator = String(node.id)
|
||||
const rootGraphId = node.rootGraph.id
|
||||
@@ -143,7 +130,7 @@ function updateActivePromotedRows(
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map((row) => ({ widgetId: row.widget.widgetId }))
|
||||
value.map((row) => row.widget)
|
||||
)
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
@@ -164,11 +151,9 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
})
|
||||
|
||||
function activeRowSourceKey(row: ActiveRow): string {
|
||||
if (row.kind !== 'promoted')
|
||||
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
|
||||
const source = promotedRowSource(row)
|
||||
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
|
||||
return row.kind === 'promoted'
|
||||
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
|
||||
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
}
|
||||
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
@@ -243,16 +228,18 @@ function rowDisplayName(row: ActiveRow): string {
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === -1) return true
|
||||
const source = promotedRowSource(row)
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
!!source &&
|
||||
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(row.node.id),
|
||||
row.widget.sourceWidgetName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedRowKey(row: PromotedRow): string {
|
||||
return `${row.node.id}: ${row.widget.name}`
|
||||
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
|
||||
}
|
||||
|
||||
function rowKey(row: ActiveRow): string {
|
||||
@@ -269,14 +256,7 @@ function demoteRow(row: ActiveRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
if (row.kind === 'promoted') {
|
||||
const source = promotedRowSource(row)
|
||||
if (source) {
|
||||
demotePromotedInput(subgraphNode, {
|
||||
sourceNodeId: source.nodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
demoteWidget(row.node, row.widget, [subgraphNode])
|
||||
return
|
||||
}
|
||||
if (row.realWidget) {
|
||||
@@ -294,18 +274,13 @@ function demoteRow(row: ActiveRow) {
|
||||
function promotePromotedRow(row: PromotedRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
const source = promotedRowSource(row)
|
||||
const sourceWidget = source
|
||||
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
|
||||
: undefined
|
||||
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
|
||||
promoteWidget(row.node, row.widget, [subgraphNode])
|
||||
}
|
||||
|
||||
function promoteCandidate([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
refreshPromotedRows()
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
|
||||
@@ -586,9 +586,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
modelUrl: asset.preview_url || getAssetUrl(asset)
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -189,9 +189,7 @@ const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
modelUrl: previewOutput.url || ''
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,15 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
<template>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
dialogContentVariants({ size, maximized }),
|
||||
customClass,
|
||||
// Custom dimension classes must yield to maximize, mirroring the
|
||||
// PrimeVue `.p-dialog-maximized` !important behavior.
|
||||
maximized && 'size-auto max-h-none max-w-none sm:max-w-none'
|
||||
)
|
||||
"
|
||||
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,8 +4,12 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointer,
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -252,6 +256,273 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(mediaStore.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('uses interior node execution ID for promoted widget error clearing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['model.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'other_model.safetensors',
|
||||
'model.safetensors',
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears range errors for promoted widgets by interior widget name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps_input', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('KSampler')
|
||||
const interiorInput = interiorNode.addInput('steps_input', 'INT')
|
||||
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
|
||||
min: 1,
|
||||
max: 100
|
||||
})
|
||||
interiorInput.widget = { name: 'steps' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
store.lastNodeErrors = {
|
||||
[interiorExecId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'steps',
|
||||
50,
|
||||
150,
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.type = 'CheckpointLoaderSimple'
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
id: 65,
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
})
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
|
||||
canvasX: 190,
|
||||
canvasY: 20,
|
||||
deltaX: 0
|
||||
})
|
||||
const pointer = fromAny<CanvasPointer, unknown>({
|
||||
eDown: clickEvent
|
||||
})
|
||||
const canvas = fromAny<LGraphCanvas, unknown>({
|
||||
graph_mouse: [190, 20],
|
||||
last_mouseclick: 0
|
||||
})
|
||||
|
||||
const handled = promotedWidget!.onPointerDown?.(
|
||||
pointer,
|
||||
subgraphNode,
|
||||
canvas
|
||||
)
|
||||
expect(handled).toBe(true)
|
||||
expect(pointer.onClick).toBeDefined()
|
||||
|
||||
pointer.onClick?.(clickEvent)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first_ckpt', type: '*' },
|
||||
{ name: 'second_ckpt', type: '*' }
|
||||
]
|
||||
})
|
||||
const firstNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
firstNode.type = 'CheckpointLoaderSimple'
|
||||
const firstInput = firstNode.addInput('first_ckpt', '*')
|
||||
const firstWidget = firstNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
firstInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(firstNode)
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
secondNode.type = 'CheckpointLoaderSimple'
|
||||
const secondInput = secondNode.addInput('second_ckpt', '*')
|
||||
secondNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
secondInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const promotedWidgets =
|
||||
subgraphNode.widgets?.filter(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget &&
|
||||
widget.sourceWidgetName === 'ckpt_name'
|
||||
) ?? []
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
|
||||
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: firstExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate,
|
||||
{
|
||||
nodeId: secondExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
firstWidget.value = 'present.safetensors'
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'present.safetensors',
|
||||
'missing.safetensors',
|
||||
firstWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: secondExecId,
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('installErrorClearingHooks lifecycle', () => {
|
||||
@@ -978,54 +1249,4 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
clearSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears promoted widget errors by interior execution id', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const graph = subgraph.rootGraph
|
||||
const host = createTestSubgraphNode(subgraph, { id: 2 })
|
||||
graph.add(host)
|
||||
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.id = 1
|
||||
subgraph.add(interiorNode)
|
||||
const input = interiorNode.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'fake_model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['fake_model.safetensors', 'real_model.safetensors'] }
|
||||
)
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, widget).ok
|
||||
).toBe(true)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const missingModelStore = useMissingModelStore()
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: '2:1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake_model.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const promotedWidget = host.widgets[0]
|
||||
host.onWidgetChanged!.call(
|
||||
host,
|
||||
promotedWidget.name,
|
||||
'real_model.safetensors',
|
||||
'fake_model.safetensors',
|
||||
promotedWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.hasMissingModels).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
* works in legacy canvas mode as well.
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
NodeSlotType
|
||||
@@ -43,6 +46,130 @@ import {
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface WidgetErrorClearingTarget {
|
||||
executionId: string
|
||||
validationInputName: string
|
||||
assetWidgetName: string
|
||||
currentValue: unknown
|
||||
options?: { min?: number; max?: number }
|
||||
}
|
||||
|
||||
function getWidgetRangeOptions(widget: IBaseWidget): {
|
||||
min?: number
|
||||
max?: number
|
||||
} {
|
||||
return {
|
||||
min: widget.options?.min,
|
||||
max: widget.options?.max
|
||||
}
|
||||
}
|
||||
|
||||
function plainWidgetToErrorTarget(
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
return {
|
||||
executionId: hostExecId,
|
||||
validationInputName: widget.name,
|
||||
assetWidgetName: widget.name,
|
||||
currentValue: widget.value,
|
||||
options: getWidgetRangeOptions(widget)
|
||||
}
|
||||
}
|
||||
|
||||
function promotedWidgetToErrorTarget(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: PromotedWidgetView,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
const execId =
|
||||
result.status === 'resolved' && result.resolved.node
|
||||
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
|
||||
: hostExecId
|
||||
const resolvedWidget =
|
||||
result.status === 'resolved' ? result.resolved.widget : widget
|
||||
|
||||
return {
|
||||
executionId: execId,
|
||||
validationInputName: resolvedWidget.name,
|
||||
assetWidgetName: widget.sourceWidgetName,
|
||||
currentValue: resolvedWidget.value,
|
||||
options: getWidgetRangeOptions(resolvedWidget)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
|
||||
|
||||
// Canvas-path events lose promoted identity, so the post-write value
|
||||
// disambiguates same-named promoted widgets.
|
||||
return (hostNode.widgets ?? [])
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
|
||||
.map((promotedWidget) =>
|
||||
promotedWidgetToErrorTarget(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
promotedWidget,
|
||||
hostExecId
|
||||
)
|
||||
)
|
||||
.filter((target) => Object.is(target.currentValue, newValue))
|
||||
}
|
||||
|
||||
function resolveWidgetErrorTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return [
|
||||
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
|
||||
]
|
||||
}
|
||||
|
||||
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
return canvasPathTargets.length
|
||||
? canvasPathTargets
|
||||
: [plainWidgetToErrorTarget(widget, hostExecId)]
|
||||
}
|
||||
|
||||
function clearWidgetErrorTargets(
|
||||
targets: WidgetErrorClearingTarget[],
|
||||
newValue: unknown
|
||||
): void {
|
||||
const store = useExecutionErrorStore()
|
||||
for (const target of targets) {
|
||||
store.clearWidgetRelatedErrors(
|
||||
target.executionId,
|
||||
target.validationInputName,
|
||||
target.assetWidgetName,
|
||||
newValue,
|
||||
target.options
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
type OriginalCallbacks = {
|
||||
@@ -76,24 +203,21 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
|
||||
node.onWidgetChanged = useChainCallback(
|
||||
node.onWidgetChanged,
|
||||
// _name is the LiteGraph callback arg; re-derive from the widget
|
||||
// object to handle promoted widgets where sourceWidgetName differs.
|
||||
function (_name, newValue, _oldValue, widget) {
|
||||
if (!app.rootGraph) return
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const promotedSource = widgetPromotedSource(node, widget)
|
||||
const executionId = promotedSource
|
||||
? `${hostExecId}:${promotedSource.nodeId}`
|
||||
: hostExecId
|
||||
const widgetName = promotedSource?.widgetName ?? widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
executionId,
|
||||
widgetName,
|
||||
widgetName,
|
||||
newValue,
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
const targets = resolveWidgetErrorTargets(
|
||||
app.rootGraph,
|
||||
node,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
clearWidgetErrorTargets(targets, newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -45,10 +47,9 @@ describe('Node Reactivity', () => {
|
||||
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||
|
||||
// Initial value should be in store after setNodeId was called
|
||||
const id = widgetId(graph.id, node.id, 'testnum')
|
||||
expect(store.getWidget(id)?.value).toBe(2)
|
||||
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
|
||||
|
||||
const state = store.getWidget(id)
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const onValueChange = vi.fn()
|
||||
@@ -73,7 +74,7 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const widgetValue = computed(() => state.value)
|
||||
@@ -210,32 +211,105 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
|
||||
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
|
||||
// Subgraph input named "value" promotes an interior "prompt" widget. The
|
||||
// projected widget's name is the input slot name "value"; the interior
|
||||
// source widget name "prompt" is carried separately for backend lookups.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'STRING' }]
|
||||
})
|
||||
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
const interiorInput = interiorNode.addInput('value', 'STRING')
|
||||
interiorNode.id = 10
|
||||
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
interiorInput.widget = { name: 'prompt' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
'value'
|
||||
)
|
||||
|
||||
// Host the promoted view on a regular node so we can control widgets
|
||||
// directly (SubgraphNode.widgets is a synthetic getter).
|
||||
const graph = new LGraph()
|
||||
const hostNode = new LGraphNode('host')
|
||||
hostNode.widgets = [promotedView]
|
||||
const input = hostNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(hostNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(hostNode.id))
|
||||
|
||||
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
||||
// input slot widget name is "value" — slotName bridges this gap.
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceNodeId = '9999'
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.sourceWidgetName).toBe('prompt')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
@@ -374,8 +448,8 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
expect(mappedWidget?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -410,13 +484,13 @@ describe('Nested promoted widget mapping', () => {
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets?.[0]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'first_seed')
|
||||
expect(widgets?.[0]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
|
||||
)
|
||||
expect(widgets?.[1]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'second_seed')
|
||||
expect(widgets?.[1]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
|
||||
)
|
||||
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
|
||||
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -454,11 +528,10 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = nodeData?.widgets?.find(
|
||||
(w) => w.name === 'ckpt_input'
|
||||
(w) => w.name === 'ckpt_name'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
|
||||
// The interior node is inside subgraphNode (id=65),
|
||||
// so its execution ID should be "65:<interiorNodeId>"
|
||||
expect(promotedWidget?.sourceExecutionId).toBe(
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import {
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidgets
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetSource
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -26,11 +27,10 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -38,8 +38,7 @@ import type {
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam,
|
||||
SubgraphNode
|
||||
LGraphTriggerParam
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -61,7 +60,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
widgetId?: WidgetId
|
||||
entityId?: WidgetEntityId
|
||||
nodeId?: NodeId
|
||||
name: string
|
||||
type: string
|
||||
@@ -82,12 +81,17 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Original LiteGraph widget name used for slot metadata matching.
|
||||
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
|
||||
* which differs from the subgraph node's input slot widget name.
|
||||
*/
|
||||
slotName?: string
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the
|
||||
@@ -95,14 +99,10 @@ export interface SafeWidgetData {
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
* widget name, which can differ from the slot name (e.g. after a rename).
|
||||
*/
|
||||
sourceWidgetName?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -143,6 +143,18 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
if (!isPromotedWidgetView(widget)) return false
|
||||
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
|
||||
if (!sourceWidget) return false
|
||||
|
||||
const innerWidget = sourceWidget.widget
|
||||
return (
|
||||
('element' in innerWidget && !!innerWidget.element) ||
|
||||
('component' in innerWidget && !!innerWidget.component)
|
||||
)
|
||||
}
|
||||
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
@@ -202,83 +214,73 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
('element' in widget && !!widget.element) ||
|
||||
('component' in widget && !!widget.component)
|
||||
)
|
||||
}
|
||||
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: string
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the interior source of a promoted subgraph input to derive the
|
||||
* metadata that backend lookups key by (execution ID, interior widget name)
|
||||
* plus the source widget's control + DOM nature. Also seeds host widget state
|
||||
* if it is somehow missing. Returns undefined when the widget is not promoted.
|
||||
*/
|
||||
function resolvePromotedMetadata(
|
||||
node: SubgraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetMetadata | undefined {
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input?.widgetId) return undefined
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return undefined
|
||||
|
||||
const resolution = resolveConcretePromotedWidget(
|
||||
node,
|
||||
source.nodeId,
|
||||
source.widgetName
|
||||
)
|
||||
const resolved =
|
||||
resolution.status === 'resolved' ? resolution.resolved : undefined
|
||||
const sourceWidget = resolved?.widget
|
||||
const sourceNode = resolved?.node
|
||||
|
||||
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
|
||||
|
||||
return {
|
||||
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
|
||||
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
sourceWidgetName: sourceWidget?.name
|
||||
}
|
||||
}
|
||||
|
||||
function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
const duplicateIndexByKey = new Map<string, number>()
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedSourceByInputName(
|
||||
inputName: string
|
||||
): PromotedWidgetSource | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
|
||||
displayName: string
|
||||
promotedSource: PromotedWidgetSource | null
|
||||
} {
|
||||
if (!isPromotedWidgetView(widget)) {
|
||||
return {
|
||||
displayName: widget.name,
|
||||
promotedSource: null
|
||||
}
|
||||
}
|
||||
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource: PromotedWidgetSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
promotedSource
|
||||
}
|
||||
}
|
||||
|
||||
return function (widget) {
|
||||
try {
|
||||
const duplicateKey = `${widget.name}:${widget.type}`
|
||||
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
|
||||
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
const { displayName, promotedSource } =
|
||||
resolvePromotedWidgetIdentity(widget)
|
||||
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo =
|
||||
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
const callback = (v: unknown) => {
|
||||
@@ -292,26 +294,67 @@ function safeWidgetMapper(
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
const promoted = node.isSubgraphNode()
|
||||
? resolvePromotedMetadata(node, widget)
|
||||
const isPromotedPseudoWidget =
|
||||
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
const options = extractWidgetDisplayOptions(widget)
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const resolvedSourceResult =
|
||||
isPromotedWidgetView(widget) && promotedSource
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
resolvedSourceResult?.status === 'resolved'
|
||||
? resolvedSourceResult.resolved
|
||||
: undefined
|
||||
const sourceWidget = resolvedSource?.widget
|
||||
const sourceNode = resolvedSource?.node
|
||||
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const sourceWidgetName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = sourceWidgetName ?? displayName
|
||||
|
||||
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
|
||||
|
||||
return {
|
||||
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
...getSharedWidgetEnhancements(node, widget),
|
||||
...(promoted?.controlWidget && {
|
||||
controlWidget: promoted.controlWidget
|
||||
}),
|
||||
entityId: getWidgetEntityIdForNode(node, widget),
|
||||
nodeId,
|
||||
name,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
|
||||
options: extractWidgetDisplayOptions(widget),
|
||||
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
|
||||
options: isPromotedPseudoWidget
|
||||
? {
|
||||
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
canvasOnly: true
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
slotMetadata: slotInfo,
|
||||
sourceExecutionId: promoted?.sourceExecutionId,
|
||||
sourceWidgetName: promoted?.sourceWidgetName,
|
||||
tooltip: widget.tooltip
|
||||
// For promoted widgets, name is sourceWidgetName while widget.name
|
||||
// is the subgraph input slot name — store the slot name for lookups.
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -327,24 +370,6 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePromotedHostWidgetState(
|
||||
id: WidgetId,
|
||||
input: INodeInputSlot,
|
||||
sourceWidget: IBaseWidget | undefined
|
||||
): void {
|
||||
if (!sourceWidget) return
|
||||
const store = useWidgetValueStore()
|
||||
if (store.getWidget(id)) return
|
||||
store.registerWidget(id, {
|
||||
type: sourceWidget.type,
|
||||
value: sourceWidget.value,
|
||||
options: cloneDeep(sourceWidget.options ?? {}),
|
||||
label: input.label ?? input.name,
|
||||
serialize: sourceWidget.serialize,
|
||||
disabled: sourceWidget.disabled
|
||||
})
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined,
|
||||
graphRef: LGraph | null | undefined
|
||||
@@ -446,16 +471,14 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const widgetsSnapshot = node.widgets ?? []
|
||||
|
||||
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
|
||||
slotMetadata.clear()
|
||||
for (const [key, value] of freshMetadata) {
|
||||
slotMetadata.set(key, value)
|
||||
}
|
||||
|
||||
const widgets = node.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: (node.widgets ?? [])
|
||||
return widgets.map(safeWidgetMapper(node, slotMetadata))
|
||||
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
@@ -511,7 +534,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
widget.slotMetadata = slotMetadata.get(widget.name)
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,7 +812,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeRef.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
// Re-extract widget data so the label reflects the rename
|
||||
// Re-extract widget data so promotedLabel reflects the rename
|
||||
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
@@ -264,8 +265,16 @@ export function useMoreOptionsMenu() {
|
||||
options.push(...getImageMenuOptions(selectedNodes.value[0]))
|
||||
options.push({ type: 'divider' })
|
||||
}
|
||||
const [widgetName] = hoveredWidget.value ?? []
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
const [widgetName, nodeId] = hoveredWidget.value ?? []
|
||||
const widget =
|
||||
nodeId !== undefined
|
||||
? node?.widgets?.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceWidgetName === widgetName &&
|
||||
w.sourceNodeId === nodeId
|
||||
)
|
||||
: node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) {
|
||||
const widgetOptions = convertContextMenuToOptions(
|
||||
getExtraOptionsForWidget(node, widget)
|
||||
|
||||
@@ -43,18 +43,13 @@ export function useKeyboard() {
|
||||
}
|
||||
|
||||
const addListeners = (): void => {
|
||||
// Capture phase: the Mask Editor content root carries `@keydown.stop`
|
||||
// (MaskEditorContent.vue), so a bubble-phase listener never sees keydowns
|
||||
// that originate inside it. Under the Reka dialog the focus trap keeps
|
||||
// focus on an in-editor input, so Ctrl+Z/Y (undo/redo) and the space-pan
|
||||
// blur were swallowed. Capturing runs this before that stopPropagation.
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', clearKeys)
|
||||
}
|
||||
|
||||
const removeListeners = (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', clearKeys)
|
||||
}
|
||||
|
||||
@@ -23,16 +23,21 @@ export function useMaskEditor() {
|
||||
node
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
// `mask-editor-dialog` is a styling-free hook class consumed by
|
||||
// browser_tests (MaskEditorHelper, maskEditor.spec).
|
||||
contentClass: 'mask-editor-dialog w-[90vw] h-[90vh] max-h-[90vh]',
|
||||
headerClass: 'p-2',
|
||||
bodyClass: 'flex min-h-0 flex-col p-0',
|
||||
style: 'width: 90vw; height: 90vh;',
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
},
|
||||
header: {
|
||||
class: '!p-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
|
||||
const getNodeDisplayPrice = vi.fn(
|
||||
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
|
||||
String(overrides?.get('prompt') ?? 'missing override')
|
||||
)
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({ getNodeDisplayPrice })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
@@ -64,43 +54,4 @@ describe('subgraph pricing', () => {
|
||||
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'uses promoted widget override from any matching internal link',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode, subgraph } = subgraphWithNode
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
const apiNode = new ApiNode('api node')
|
||||
apiNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
const apiInput = apiNode.addInput('prompt', 'STRING')
|
||||
apiInput.widget = { name: 'prompt' }
|
||||
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
|
||||
|
||||
const decoyNode = new LGraphNode('decoy node')
|
||||
const decoyInput = decoyNode.addInput('prompt', 'STRING')
|
||||
decoyInput.widget = { name: 'prompt' }
|
||||
decoyNode.addWidget(
|
||||
'string',
|
||||
'prompt',
|
||||
'decoy value',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
|
||||
subgraph.add(decoyNode)
|
||||
subgraph.add(apiNode)
|
||||
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
|
||||
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const inputWidgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
|
||||
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
|
||||
|
||||
updateSubgraphCredits(subgraphNode)
|
||||
|
||||
expect(getBadgeText(subgraphNode)).toBe('outer value')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,14 +2,9 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: { linkIds?: number[] }
|
||||
}
|
||||
|
||||
const componentIconSvg = new Image()
|
||||
componentIconSvg.src =
|
||||
@@ -100,20 +95,11 @@ export const usePriceBadge = () => {
|
||||
): ReadonlyMap<string, unknown> {
|
||||
const overrides = new Map<string, unknown>()
|
||||
if (!wrapper.isSubgraphNode()) return overrides
|
||||
|
||||
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
|
||||
if (!input.widgetId) continue
|
||||
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
|
||||
const link = wrapper.subgraph.getLink(linkId)
|
||||
if (link?.target_id !== innerNode.id) continue
|
||||
const targetInput = innerNode.inputs[link.target_slot]
|
||||
const widgetName = targetInput?.widget?.name
|
||||
if (!widgetName) continue
|
||||
overrides.set(
|
||||
widgetName,
|
||||
useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
)
|
||||
}
|
||||
const innerId = String(innerNode.id)
|
||||
for (const w of wrapper.widgets ?? []) {
|
||||
if (!isPromotedWidgetView(w)) continue
|
||||
if (w.sourceNodeId !== innerId) continue
|
||||
overrides.set(w.sourceWidgetName, w.value)
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
|
||||
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
|
||||
|
||||
function widget(name: string, value: unknown): WidgetState {
|
||||
return {
|
||||
name,
|
||||
type: 'INPUT',
|
||||
value,
|
||||
nodeId: '1' as NodeId,
|
||||
options: {},
|
||||
y: 0
|
||||
}
|
||||
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
|
||||
}
|
||||
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
|
||||
@@ -2,9 +2,9 @@ import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
|
||||
type ValueExtractor<T = unknown> = (
|
||||
widgets: WidgetState[],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -20,8 +21,8 @@ import {
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
@@ -54,15 +55,39 @@ function addInnerNode(
|
||||
return node
|
||||
}
|
||||
|
||||
function getPromotedInputValue(
|
||||
function addPromotedHostInput(
|
||||
host: SubgraphNode,
|
||||
name: string
|
||||
): TWidgetValue | undefined {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) return undefined
|
||||
return useWidgetValueStore().getWidget(input.widgetId)?.value as
|
||||
| TWidgetValue
|
||||
| undefined
|
||||
args: {
|
||||
inputName: string
|
||||
promotedName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
initialValue?: TWidgetValue
|
||||
}
|
||||
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
|
||||
let widgetValue: TWidgetValue = args.initialValue ?? 0
|
||||
const slot = host.addInput(args.inputName, '*')
|
||||
slot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: args.promotedName,
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourceWidgetName,
|
||||
get value() {
|
||||
return widgetValue
|
||||
},
|
||||
set value(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
},
|
||||
hydrateHostValue(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
}
|
||||
})
|
||||
return {
|
||||
setValue: (v) => {
|
||||
widgetValue = v
|
||||
},
|
||||
getValue: () => widgetValue
|
||||
}
|
||||
}
|
||||
|
||||
function addPrimitiveWithTargets(
|
||||
@@ -116,6 +141,29 @@ describe('flushProxyWidgetMigration', () => {
|
||||
})
|
||||
|
||||
describe('value-widget repair', () => {
|
||||
it('alreadyLinked: applies host value to the matching promoted widget', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 0
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(handle.getValue()).toBe(99)
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
@@ -135,61 +183,23 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(getPromotedInputValue(host, 'seed')).toBe(99)
|
||||
expect(host.widgets[0].value).toBe(99)
|
||||
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
|
||||
expect(innerWidget.value).toBe(0)
|
||||
})
|
||||
|
||||
it('createSubgraphInput: uses disambiguator for duplicate nested widget names', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const firstText = new LGraphNode('CLIPTextEncode')
|
||||
const firstSlot = firstText.addInput('text', 'STRING')
|
||||
firstSlot.widget = { name: 'text' }
|
||||
firstText.addWidget('text', 'text', '11111111111', () => {})
|
||||
innerSubgraph.add(firstText)
|
||||
|
||||
const secondText = new LGraphNode('CLIPTextEncode')
|
||||
const secondSlot = secondText.addInput('text', 'STRING')
|
||||
secondSlot.widget = { name: 'text' }
|
||||
secondText.addWidget('text', 'text', '22222222222', () => {})
|
||||
innerSubgraph.add(secondText)
|
||||
|
||||
const nestedHost = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
nestedHost.properties.proxyWidgets = [
|
||||
[String(firstText.id), 'text'],
|
||||
[String(secondText.id), 'text']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: nestedHost })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
outerSubgraph.add(nestedHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
outerHost.properties.proxyWidgets = [
|
||||
[String(nestedHost.id), 'text', String(secondText.id)]
|
||||
]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: outerHost })
|
||||
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.graph!.add(host)
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
|
||||
slot.widget = { name: innerWidget.name }
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 7
|
||||
})
|
||||
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
const sparse: unknown[] = []
|
||||
@@ -198,7 +208,43 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: sparse
|
||||
})
|
||||
|
||||
expect(getPromotedInputValue(host, 'seed')).toBe(7)
|
||||
expect(handle.getValue()).toBe(7)
|
||||
})
|
||||
|
||||
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const a = addPromotedHostInput(host, {
|
||||
inputName: 'first_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 1
|
||||
})
|
||||
const b = addPromotedHostInput(host, {
|
||||
inputName: 'second_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 2
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(a.getValue()).toBe(1)
|
||||
expect(b.getValue()).toBe(2)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(inner.id), 'seed'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
|
||||
@@ -218,25 +264,29 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(created?._widget).toBeDefined()
|
||||
})
|
||||
|
||||
it('createSubgraphInput: preserves the source slot label', () => {
|
||||
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('text', 'STRING')
|
||||
slot.label = 'renamed_from_sidepanel'
|
||||
slot.widget = { name: 'text' }
|
||||
n.addWidget('text', 'text', '', () => {})
|
||||
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
|
||||
const slot1 = n.addInput('text', 'STRING')
|
||||
slot1.widget = { name: 'text' }
|
||||
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
|
||||
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
|
||||
|
||||
const slot2 = n.addInput('text_1', 'STRING')
|
||||
slot2.widget = { name: 'text_1' }
|
||||
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
|
||||
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'text']]
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const promotedInput = host.inputs.find((input) => input.name === 'text')
|
||||
expect(promotedInput?.label).toBe('renamed_from_sidepanel')
|
||||
expect(
|
||||
promotedInput?.widgetId
|
||||
? useWidgetValueStore().getWidget(promotedInput.widgetId)?.label
|
||||
: undefined
|
||||
).toBe('renamed_from_sidepanel')
|
||||
const created = host.subgraph.inputs.at(-1)
|
||||
expect(created?._widget).toBeDefined()
|
||||
const linkedSlot = inner.inputs.find(
|
||||
(slot) => slot.link === created?.linkIds[0]
|
||||
)
|
||||
expect(linkedSlot?.name).toBe('text_1')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
@@ -311,7 +361,8 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: [123]
|
||||
})
|
||||
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(123)
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(123)
|
||||
})
|
||||
|
||||
it('seeds value from the primitive widget when no host value is supplied', () => {
|
||||
@@ -324,7 +375,8 @@ describe('flushProxyWidgetMigration', () => {
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(11)
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(11)
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
@@ -422,8 +474,10 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
|
||||
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
|
||||
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
|
||||
const widgetA = hostA.inputs.at(-1)?._widget
|
||||
const widgetB = hostB.inputs.at(-1)?._widget
|
||||
expect(widgetA?.value).toBe(11)
|
||||
expect(widgetB?.value).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
findHostInputForPromotion,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
@@ -28,7 +27,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
interface LegacyProxyEntrySource extends PromotedWidgetSource {
|
||||
disambiguatingSourceNodeId?: string
|
||||
@@ -95,24 +93,23 @@ function resolveSourceWidget(
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): IBaseWidget | undefined {
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const input = sourceNode.inputs.find((input) => {
|
||||
const target = resolveSubgraphInputTarget(sourceNode, input.name)
|
||||
if (disambiguatingSourceNodeId) {
|
||||
return (
|
||||
target?.widgetName === sourceWidgetName &&
|
||||
target.nodeId === disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
if (input.name === sourceWidgetName) return true
|
||||
return target?.widgetName === sourceWidgetName
|
||||
})
|
||||
// Store-backed projection for a promoted input on a nested subgraph node:
|
||||
// getSlotFromWidget locates the backing slot by widgetId.
|
||||
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
|
||||
const widgets = sourceNode.widgets
|
||||
if (widgets && disambiguatingSourceNodeId !== undefined) {
|
||||
const byDisambiguator = widgets.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === disambiguatingSourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
if (byDisambiguator) return byDisambiguator
|
||||
// Disambiguator missed: fall back only to non-promoted same-name widgets.
|
||||
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
|
||||
const byName = widgets.find(
|
||||
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
|
||||
)
|
||||
if (byName) return byName
|
||||
}
|
||||
|
||||
const widgets = sourceNode.widgets
|
||||
return (
|
||||
widgets?.find((w) => w.name === sourceWidgetName) ??
|
||||
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
|
||||
@@ -303,6 +300,19 @@ function classify(
|
||||
normalized.sourceWidgetName
|
||||
)
|
||||
if (linkedInput) {
|
||||
const ambiguous =
|
||||
hostNode.inputs.filter((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
!!w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === normalized.sourceNodeId &&
|
||||
w.sourceWidgetName === normalized.sourceWidgetName
|
||||
)
|
||||
}).length > 1
|
||||
if (ambiguous) {
|
||||
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
|
||||
}
|
||||
|
||||
@@ -363,23 +373,19 @@ function classify(
|
||||
}
|
||||
}
|
||||
|
||||
function applyHostValueToInput(
|
||||
input: INodeInputSlot,
|
||||
entry: PendingEntry
|
||||
): boolean {
|
||||
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
|
||||
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
|
||||
}
|
||||
|
||||
function applyHostLabelToInput(
|
||||
input: INodeInputSlot,
|
||||
label: string | undefined
|
||||
): void {
|
||||
if (label === undefined) return
|
||||
input.label = label
|
||||
if (!input.widgetId) return
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (state) state.label = label
|
||||
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
|
||||
if (entry.isHole) return
|
||||
if (
|
||||
isPromotedWidgetView(widget) &&
|
||||
typeof widget.hydrateHostValue === 'function'
|
||||
) {
|
||||
widget.hydrateHostValue(entry.hostValue)
|
||||
return
|
||||
}
|
||||
console.error(
|
||||
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
|
||||
{ widgetName: widget.name, type: widget.type }
|
||||
)
|
||||
}
|
||||
|
||||
function addUniqueSubgraphInput(
|
||||
@@ -416,9 +422,10 @@ function repairAlreadyLinked(
|
||||
return { ok: false, reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
const hostInput = matches[0]
|
||||
if (!applyHostValueToInput(hostInput, entry)) {
|
||||
if (!hostInput._widget) {
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
return { ok: true, subgraphInputName: hostInput.name }
|
||||
}
|
||||
|
||||
@@ -473,10 +480,11 @@ function repairCreateSubgraphInput(
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
if (hostInput) {
|
||||
applyHostLabelToInput(hostInput, slot.label)
|
||||
applyHostValueToInput(hostInput, entry)
|
||||
if (!hostInput?._widget) {
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
@@ -641,19 +649,22 @@ function repairPrimitive(
|
||||
return failPrimitive('mutation failed; rolled back', { error: e })
|
||||
}
|
||||
|
||||
// Apply through the host's input mirror (PromotedWidgetView), NOT
|
||||
// `newSubgraphInput._widget`: the interior is shared across hosts.
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
if (hostInput) {
|
||||
const hostInputWidget = hostInput?._widget
|
||||
if (hostInputWidget) {
|
||||
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
|
||||
if (valueEntry) {
|
||||
applyHostValueToInput(hostInput, valueEntry)
|
||||
applyHostValue(hostInputWidget, valueEntry)
|
||||
} else {
|
||||
const primitiveValue = primitiveNode.widgets?.find(
|
||||
(w) => w.name === validated.sourceWidgetName
|
||||
)?.value as TWidgetValue | undefined
|
||||
if (primitiveValue !== undefined) {
|
||||
applyHostValueToInput(hostInput, {
|
||||
applyHostValue(hostInputWidget, {
|
||||
...validated.uniqueEntries[0],
|
||||
hostValue: primitiveValue,
|
||||
isHole: false
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
|
||||
|
||||
/**
|
||||
* Where a promoted subgraph input is sourced from inside the subgraph. The
|
||||
* interior node id + widget name that the host input slot forwards to. Resolved
|
||||
* by walking the live link, so it is authoritative derived data — never stored
|
||||
* on the projected widget.
|
||||
*/
|
||||
export interface PromotedSource {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a host input slot, or undefined when the slot is not a
|
||||
* promoted widget input.
|
||||
*/
|
||||
export function promotedInputSource(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot
|
||||
): PromotedSource | undefined {
|
||||
if (!input.widgetId) return undefined
|
||||
return resolveSubgraphInputTarget(node, input.name)
|
||||
}
|
||||
|
||||
/** The host input slot backing a projected widget, matched by widgetId. */
|
||||
export function inputForWidget(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): INodeInputSlot | undefined {
|
||||
return node.getSlotFromWidget(widget)
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a widget when it is a promoted subgraph input.
|
||||
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
|
||||
* host node is a subgraph node and its backing input slot has an interior
|
||||
* source.
|
||||
*/
|
||||
export function widgetPromotedSource(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedSource | undefined {
|
||||
if (!node.isSubgraphNode()) return undefined
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input) return undefined
|
||||
return promotedInputSource(node, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects a promoted subgraph input into an ordinary widget descriptor. The
|
||||
* descriptor is store-backed: type/value/options read live from
|
||||
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
|
||||
* list does not reactively rebuild — and re-key — on every value edit.
|
||||
*
|
||||
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
|
||||
* `label` is the mutable display label. Returns null when the input is not a
|
||||
* promoted widget input.
|
||||
*/
|
||||
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
|
||||
const id = input.widgetId
|
||||
if (!id) return null
|
||||
const store = useWidgetValueStore()
|
||||
return {
|
||||
get name() {
|
||||
return store.getWidget(id)?.name ?? input.name
|
||||
},
|
||||
get label() {
|
||||
return store.getWidget(id)?.label ?? input.label ?? input.name
|
||||
},
|
||||
set label(next) {
|
||||
const state = store.getWidget(id)
|
||||
if (state) state.label = next
|
||||
},
|
||||
get y() {
|
||||
return store.getWidget(id)?.y ?? 0
|
||||
},
|
||||
set y(next) {
|
||||
const state = store.getWidget(id)
|
||||
if (state) state.y = next
|
||||
},
|
||||
widgetId: id,
|
||||
get type() {
|
||||
return store.getWidget(id)?.type ?? 'text'
|
||||
},
|
||||
get options() {
|
||||
return store.getWidget(id)?.options ?? {}
|
||||
},
|
||||
get value() {
|
||||
const value = store.getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
},
|
||||
set value(next) {
|
||||
store.setValue(id, next)
|
||||
},
|
||||
// Canvas edits operate on a transient concrete widget (toConcreteWidget),
|
||||
// so the value setter above is never invoked; BaseWidget.setValue writes its
|
||||
// own local state and then calls this callback, which is the only bridge
|
||||
// back to the store.
|
||||
callback(next) {
|
||||
store.setValue(id, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
|
||||
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
|
||||
return node.inputs.flatMap((input) => {
|
||||
const widget = promotedInputWidget(input)
|
||||
return widget ? [widget] : []
|
||||
})
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export interface ResolvedPromotedWidget {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted promotion's source identity: the interior node + widget a host
|
||||
* subgraph input was promoted from. Used by the migration/schema layer, where
|
||||
* the source is a stored tuple rather than something link-derivable.
|
||||
*/
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
readonly entityId: WidgetEntityId
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void
|
||||
|
||||
ensureHostWidgetState(): void
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
widget: IBaseWidget
|
||||
): widget is PromotedWidgetView {
|
||||
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
|
||||
}
|
||||
|
||||
100
src/core/graph/subgraph/promotedWidgetView.test.ts
Normal file
100
src/core/graph/subgraph/promotedWidgetView.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function createNumericInteriorNode(initialValue: number) {
|
||||
const node = new LGraphNode('Interior')
|
||||
const input = node.addInput('value', 'number')
|
||||
node.addOutput('out', 'number')
|
||||
|
||||
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
describe('PromotedWidgetView — host-wins semantics', () => {
|
||||
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: interior, widget: interiorWidget } =
|
||||
createNumericInteriorNode(42)
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
|
||||
const viewA = hostA.widgets.find(isPromotedWidgetView)
|
||||
const viewB = hostB.widgets.find(isPromotedWidgetView)
|
||||
if (!viewA || !viewB)
|
||||
throw new Error('Expected promoted views on both hosts')
|
||||
|
||||
viewA.value = 7
|
||||
|
||||
expect(viewA.value).toBe(7)
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
expect(viewB.value).toBe(42)
|
||||
})
|
||||
|
||||
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: interior } = createNumericInteriorNode(42)
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(subgraph.rootGraph.id, {
|
||||
nodeId: String(interior.id),
|
||||
name: 'widget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 200 })
|
||||
const view = host.widgets.find(isPromotedWidgetView)
|
||||
if (!view) throw new Error('Expected promoted view on host')
|
||||
|
||||
view.value = 99
|
||||
|
||||
const interiorState = widgetStore.getWidget(
|
||||
subgraph.rootGraph.id,
|
||||
String(interior.id),
|
||||
'widget'
|
||||
)
|
||||
expect(interiorState?.value).toBe(42)
|
||||
})
|
||||
})
|
||||
614
src/core/graph/subgraph/promotedWidgetView.ts
Normal file
614
src/core/graph/subgraph/promotedWidgetView.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
interface SubgraphSlotRef {
|
||||
name: string
|
||||
label?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
|
||||
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
return 'mouse' in widget && typeof widget.mouse === 'function'
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
identityName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
subgraphNode,
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
identityName
|
||||
)
|
||||
}
|
||||
|
||||
class PromotedWidgetView implements IPromotedWidgetView {
|
||||
[symbol: symbol]: boolean
|
||||
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
private readonly graphId: string
|
||||
private yValue = 0
|
||||
private _computedDisabled = false
|
||||
|
||||
private projectedSourceNode?: LGraphNode
|
||||
private projectedSourceWidget?: IBaseWidget
|
||||
private projectedSourceWidgetType?: IBaseWidget['type']
|
||||
private projectedWidget?: BaseWidget
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
private _boundSlot?: SubgraphSlotRef
|
||||
private _boundSlotVersion = -1
|
||||
|
||||
private _lastAutoSeededValue?: IBaseWidget['value']
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
private readonly identityName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
this.graphId = subgraphNode.rootGraph.id
|
||||
}
|
||||
|
||||
get node(): SubgraphNode {
|
||||
return this.subgraphNode
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.identityName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId {
|
||||
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this.yValue
|
||||
}
|
||||
|
||||
set y(value: number) {
|
||||
this.yValue = value
|
||||
this.syncDomOverride()
|
||||
}
|
||||
|
||||
get computedDisabled(): boolean {
|
||||
return this._computedDisabled
|
||||
}
|
||||
|
||||
set computedDisabled(value: boolean | undefined) {
|
||||
this._computedDisabled = value ?? false
|
||||
}
|
||||
|
||||
get type(): IBaseWidget['type'] {
|
||||
return this.resolveDeepest()?.widget.type ?? 'button'
|
||||
}
|
||||
|
||||
get options(): IBaseWidget['options'] {
|
||||
return this.resolveDeepest()?.widget.options ?? {}
|
||||
}
|
||||
|
||||
get tooltip(): string | undefined {
|
||||
return this.resolveDeepest()?.widget.tooltip
|
||||
}
|
||||
|
||||
get linkedWidgets(): IBaseWidget[] | undefined {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const hostState = this.getHostWidgetState()
|
||||
if (hostState && isWidgetValue(hostState.value)) return hostState.value
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getHostWidgetState(): WidgetState | undefined {
|
||||
return getWidgetState(this.entityId)
|
||||
}
|
||||
|
||||
private setHostWidgetState(value: IBaseWidget['value']): void {
|
||||
if (!isWidgetValue(value)) return
|
||||
|
||||
const state = this.getHostWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
this._lastAutoSeededValue = undefined
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(value)
|
||||
this._lastAutoSeededValue = undefined
|
||||
}
|
||||
|
||||
ensureHostWidgetState(): void {
|
||||
const fallback = this.fallbackEffectiveValue()
|
||||
const existing = this.getHostWidgetState()
|
||||
|
||||
if (existing) {
|
||||
if (
|
||||
this._lastAutoSeededValue !== undefined &&
|
||||
existing.value === this._lastAutoSeededValue &&
|
||||
isWidgetValue(fallback) &&
|
||||
fallback !== existing.value
|
||||
) {
|
||||
existing.value = fallback
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(fallback)
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
|
||||
private fallbackEffectiveValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private registerHostWidgetState(value: IBaseWidget['value']): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
ensureWidgetState(this.entityId, {
|
||||
type: resolved?.widget.type ?? 'button',
|
||||
value,
|
||||
options: { ...(resolved?.widget.options ?? {}) },
|
||||
label: this.displayName,
|
||||
serialize: this.serialize,
|
||||
disabled: this.computedDisabled
|
||||
})
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName
|
||||
}
|
||||
|
||||
set label(value: string | undefined) {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) slot.label = value || undefined
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
const version = this.subgraphNode.inputs?.length ?? 0
|
||||
if (this._boundSlotVersion === version) return this._boundSlot
|
||||
|
||||
this._boundSlot = this.findBoundSubgraphSlot()
|
||||
this._boundSlotVersion = version
|
||||
return this._boundSlot
|
||||
}
|
||||
|
||||
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
for (const input of this.subgraphNode.inputs ?? []) {
|
||||
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
|
||||
if (!slot) continue
|
||||
|
||||
const w = input._widget
|
||||
if (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === this.sourceNodeId &&
|
||||
w.sourceWidgetName === this.sourceWidgetName
|
||||
) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.resolveDeepest()?.widget.hidden ?? false
|
||||
}
|
||||
|
||||
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeLayoutSize = resolved?.widget.computeLayoutSize
|
||||
if (!computeLayoutSize) return undefined
|
||||
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
|
||||
}
|
||||
|
||||
get computeSize(): IBaseWidget['computeSize'] {
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeSize = resolved?.widget.computeSize
|
||||
if (!computeSize) return undefined
|
||||
return (width?: number) => computeSize.call(resolved.widget, width)
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number,
|
||||
H: number,
|
||||
lowQuality?: boolean
|
||||
): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) {
|
||||
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
|
||||
return
|
||||
}
|
||||
|
||||
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
|
||||
|
||||
const projected = this.getProjectedWidget(resolved)
|
||||
if (!projected || typeof projected.drawWidget !== 'function') return
|
||||
|
||||
const originalY = projected.y
|
||||
const originalComputedHeight = projected.computedHeight
|
||||
const originalComputedDisabled = projected.computedDisabled
|
||||
|
||||
const originalLabel = projected.label
|
||||
|
||||
projected.y = this.y
|
||||
projected.computedHeight = this.computedHeight
|
||||
projected.computedDisabled = this.computedDisabled
|
||||
projected.value = this.value
|
||||
projected.label = this.label
|
||||
|
||||
try {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
} finally {
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
projected.label = originalLabel
|
||||
}
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
pointer: CanvasPointer,
|
||||
_node: LGraphNode,
|
||||
canvas: LGraphCanvas
|
||||
): boolean {
|
||||
const resolved = this.resolveAtHost()
|
||||
if (!resolved) return false
|
||||
|
||||
const interior = resolved.widget
|
||||
if (typeof interior.onPointerDown === 'function') {
|
||||
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
|
||||
if (handled) return true
|
||||
}
|
||||
|
||||
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
|
||||
if (concrete)
|
||||
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
|
||||
|
||||
if (hasLegacyMouse(interior))
|
||||
return this.handleLegacyMouse(pointer, interior)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
callback(
|
||||
value: unknown,
|
||||
canvas?: LGraphCanvas,
|
||||
node?: LGraphNode,
|
||||
pos?: Point,
|
||||
e?: CanvasPointerEvent
|
||||
) {
|
||||
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
afterQueued({
|
||||
isPartialExecution
|
||||
}: { isPartialExecution?: boolean } = {}): void {
|
||||
this.applyValueControlToHost(isPartialExecution)
|
||||
}
|
||||
|
||||
private applyValueControlToHost(isPartialExecution?: boolean): void {
|
||||
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
const next = nextValueForLinkedTarget({
|
||||
target: this,
|
||||
linkedWidgets: resolved?.widget.linkedWidgets,
|
||||
nodeId: this.subgraphNode.id,
|
||||
isPartialExecution
|
||||
})
|
||||
if (next === undefined) return
|
||||
|
||||
this.hydrateHostValue(next)
|
||||
}
|
||||
|
||||
private resolveAtHost():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
private resolveDeepest():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
|
||||
if (frame !== undefined && this.cachedDeepestFrame === frame)
|
||||
return this.cachedDeepestByFrame
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
if (frame !== undefined) {
|
||||
this.cachedDeepestFrame = frame
|
||||
this.cachedDeepestByFrame = resolved
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}): BaseWidget | undefined {
|
||||
const shouldRebuild =
|
||||
!this.projectedWidget ||
|
||||
this.projectedSourceNode !== resolved.node ||
|
||||
this.projectedSourceWidget !== resolved.widget ||
|
||||
this.projectedSourceWidgetType !== resolved.widget.type
|
||||
|
||||
if (!shouldRebuild) return this.projectedWidget
|
||||
|
||||
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
|
||||
if (!concrete) {
|
||||
this.projectedWidget = undefined
|
||||
this.projectedSourceNode = undefined
|
||||
this.projectedSourceWidget = undefined
|
||||
this.projectedSourceWidgetType = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
|
||||
this.projectedSourceNode = resolved.node
|
||||
this.projectedSourceWidget = resolved.widget
|
||||
this.projectedSourceWidgetType = resolved.widget.type
|
||||
return this.projectedWidget
|
||||
}
|
||||
|
||||
private bindConcretePointerHandlers(
|
||||
pointer: CanvasPointer,
|
||||
canvas: LGraphCanvas,
|
||||
concrete: BaseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
pointer.onClick = () =>
|
||||
concrete.onClick({
|
||||
e: downEvent,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
pointer.onDrag = (eMove) =>
|
||||
concrete.onDrag?.({
|
||||
e: eMove,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private handleLegacyMouse(
|
||||
pointer: CanvasPointer,
|
||||
interior: LegacyMouseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
const downPosition: Point = [
|
||||
downEvent.canvasX - this.subgraphNode.pos[0],
|
||||
downEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(downEvent, downPosition, this.subgraphNode)
|
||||
|
||||
pointer.finally = () => {
|
||||
const upEvent = pointer.eUp
|
||||
if (!upEvent) return
|
||||
|
||||
const upPosition: Point = [
|
||||
upEvent.canvasX - this.subgraphNode.pos[0],
|
||||
upEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(upEvent, upPosition, this.subgraphNode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private syncDomOverride(
|
||||
resolved:
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined = this.resolveAtHost()
|
||||
) {
|
||||
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
|
||||
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
|
||||
node: this.subgraphNode,
|
||||
widget: this
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isBaseDOMWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { id: string } {
|
||||
return 'id' in widget && ('element' in widget || 'component' in widget)
|
||||
}
|
||||
|
||||
function drawDisconnectedPlaceholder(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
H: number
|
||||
) {
|
||||
const backgroundColor = readDesignToken(
|
||||
'--color-secondary-background',
|
||||
'#333'
|
||||
)
|
||||
const textColor = readDesignToken('--color-text-secondary', '#999')
|
||||
const fontSize = readDesignToken('--text-2xs', '11px')
|
||||
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
|
||||
|
||||
ctx.save()
|
||||
ctx.fillStyle = backgroundColor
|
||||
ctx.fillRect(15, y, width - 30, H)
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = `${fontSize} ${fontFamily}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function readDesignToken(token: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
|
||||
const cachedValue = designTokenCache.get(token)
|
||||
if (cachedValue) return cachedValue
|
||||
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
const resolvedValue = value || fallback
|
||||
designTokenCache.set(token, resolvedValue)
|
||||
return resolvedValue
|
||||
}
|
||||
@@ -3,46 +3,22 @@ import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
function promotedInputNames(host: {
|
||||
inputs: Array<{ widgetId?: unknown; name: string }>
|
||||
}) {
|
||||
return host.inputs
|
||||
.filter((input) => input.widgetId)
|
||||
.map((input) => input.name)
|
||||
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
|
||||
}
|
||||
|
||||
function promotedHostWidgetNames(host: { widgets?: IBaseWidget[] }) {
|
||||
return host.widgets?.map((widget) => widget.name) ?? []
|
||||
}
|
||||
|
||||
function writePromotedInputValue(
|
||||
host: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
|
||||
name: string,
|
||||
value: IBaseWidget['value']
|
||||
) {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
useWidgetValueStore().setValue(input.widgetId, value)
|
||||
}
|
||||
|
||||
function promotedWidgetRef(host: SubgraphNode, name: string): IBaseWidget {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) throw new Error(`Missing promoted input ${name}`)
|
||||
return widget
|
||||
type TestPromotedWidget = IBaseWidget & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
@@ -55,9 +31,11 @@ import {
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getWidgetName,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
@@ -190,18 +168,15 @@ describe('pruneDisconnected', () => {
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
|
||||
|
||||
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
|
||||
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'missing-widget'
|
||||
})
|
||||
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
|
||||
const keptWidgetId = subgraphNode.inputs.find(
|
||||
(input) => input.name === 'kept'
|
||||
)?.widgetId
|
||||
if (!keptWidgetId) throw new Error('Missing kept widgetId')
|
||||
for (const input of [missingWidgetInput, missingNodeInput]) {
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(entry) => entry._subgraphSlot === input
|
||||
)
|
||||
if (!hostInput) throw new Error(`Missing host input ${input.name}`)
|
||||
hostInput.widgetId = keptWidgetId
|
||||
}
|
||||
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: '9999',
|
||||
sourceWidgetName: 'missing-node'
|
||||
})
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -326,25 +301,6 @@ describe('promoteRecommendedWidgets', () => {
|
||||
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the source slot label when promoting a value widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Prompt')
|
||||
const input = interiorNode.addInput('text', 'STRING')
|
||||
input.label = 'renamed_from_sidepanel'
|
||||
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
input.widget = { name: textWidget.name }
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
|
||||
const hostInput = subgraphNode.inputs.find((input) => input.name === 'text')
|
||||
expect(hostInput?.label).toBe('renamed_from_sidepanel')
|
||||
expect(promotedWidgetRef(subgraphNode, 'text').label).toBe(
|
||||
'renamed_from_sidepanel'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes virtual previews through preview exposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -529,45 +485,79 @@ describe('isLinkedPromotion', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
|
||||
const node = new LGraphNode('Source')
|
||||
const input = node.addInput(widgetName, 'STRING')
|
||||
const widget = node.addWidget('text', widgetName, '', () => {})
|
||||
input.widget = { name: widget.name }
|
||||
host.subgraph.add(node)
|
||||
promoteValueWidgetViaSubgraphInput(host, node, widget)
|
||||
return node
|
||||
function linkedWidget(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...extra
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
it('returns true for a linked promotion', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
function createSubgraphWithInputs(count = 1) {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: Array.from({ length: count }, (_, i) => ({
|
||||
name: `input_${i}`,
|
||||
type: 'STRING' as const
|
||||
}))
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
|
||||
it('returns true when an input has a matching _widget', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no promotion exists', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
it('returns false when no inputs exist or none match', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when sourceWidgetName does not match', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies linked widgets across different inputs', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const nodeA = promoteSource(host, 'string_a')
|
||||
const nodeB = promoteSource(host, 'value')
|
||||
it('returns false when _widget is undefined on input', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies multiple linked widgets across different inputs', () => {
|
||||
const subgraphNode = createSubgraphWithInputs(2)
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
|
||||
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -617,13 +607,17 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
expect(promotedInputNames(host)).toEqual(['first', 'second'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['first', 'second'])
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(promotedInputNames(host)).toEqual(['second', 'first'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
|
||||
@@ -643,13 +637,15 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
subgraph.addInput('plain', 'STRING')
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
writePromotedInputValue(host, 'first', 'first value')
|
||||
writePromotedInputValue(host, 'second', 'second value')
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
|
||||
|
||||
expect(promotedInputNames(host)).toEqual(['second', 'first'])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
@@ -731,21 +727,15 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
writePromotedInputValue(host, 'text', 'first value')
|
||||
writePromotedInputValue(host, 'text_1', 'second value')
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
const firstPromotedWidget = promotedWidgetRef(host, 'text')
|
||||
const secondPromotedWidget = promotedWidgetRef(host, 'text_1')
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
secondPromotedWidget,
|
||||
firstPromotedWidget
|
||||
])
|
||||
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'text_1',
|
||||
'text'
|
||||
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
expect(promotedHostWidgetNames(host)).toEqual(['text_1', 'text'])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
@@ -785,10 +775,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
hostInput.link = 9999
|
||||
const promotedInputId = hostInput.widgetId
|
||||
const promotedViewsBefore = host.widgets.length
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
expect(promotedInputId).toBeDefined()
|
||||
expect(promotedViewsBefore).toBeGreaterThan(0)
|
||||
|
||||
demoteWidget(interiorNode, interiorWidget, [host])
|
||||
|
||||
@@ -798,9 +788,13 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
expect(
|
||||
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
|
||||
).toBe(false)
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
|
||||
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
|
||||
expect(
|
||||
host.widgets.some(
|
||||
(widget) =>
|
||||
widgetSourceNodeId(widget) === String(interiorNode.id) &&
|
||||
widget.name === interiorWidget.name
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('removes the slot entirely when host slot has no external link', () => {
|
||||
@@ -818,7 +812,12 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
const { host, nodeA, widgetA, nodeB, widgetB } =
|
||||
buildDuplicateNamePromotion()
|
||||
|
||||
demoteWidget(nodeB, widgetB, [host])
|
||||
const promotedViewForB = host.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(promotedViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(nodeB, promotedViewForB!, [host])
|
||||
|
||||
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
|
||||
@@ -826,19 +825,15 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
})
|
||||
|
||||
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const input of innerHost.inputs) {
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, input.name)
|
||||
).ok
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
).toBe(true)
|
||||
}
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
|
||||
@@ -846,7 +841,12 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
'text_1'
|
||||
])
|
||||
|
||||
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
|
||||
const innerViewForB = innerHost.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(innerViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(innerHost, innerViewForB!, [outerHost])
|
||||
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
|
||||
@@ -863,19 +863,66 @@ describe('disambiguated nested promotion identity', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
subgraph.add(innerHost)
|
||||
function linkedView(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: sourceWidgetName,
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
host,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, 'text_1')
|
||||
).ok
|
||||
).toBe(true)
|
||||
function createSubgraphHost() {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
|
||||
const host = createSubgraphHost()
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
|
||||
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
|
||||
const interiorNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner'
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const source = {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(interiorWidget)
|
||||
}
|
||||
|
||||
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
const nestedSubgraphNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner',
|
||||
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
|
||||
} as unknown as LGraphNode
|
||||
subgraph.add(nestedSubgraphNode)
|
||||
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
|
||||
pruneDisconnected(host)
|
||||
|
||||
@@ -909,13 +956,9 @@ describe('disambiguated nested promotion identity', () => {
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const input of innerHost.inputs) {
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, input.name)
|
||||
).ok
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -18,9 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { readWidgetValue } from '@/world/widgetValueIO'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
@@ -48,47 +46,16 @@ export function findHostInputForPromotion(
|
||||
sourceWidgetName: string
|
||||
) {
|
||||
return subgraphNode.inputs.find((input) => {
|
||||
const source = input._subgraphSlot
|
||||
? resolvePromotionSource(subgraphNode, input._subgraphSlot)
|
||||
: undefined
|
||||
const w = input._widget
|
||||
return (
|
||||
source?.sourceNodeId === sourceNodeId &&
|
||||
source.sourceWidgetName === sourceWidgetName
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === sourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePromotionSource(
|
||||
subgraphNode: SubgraphNode,
|
||||
subgraphInput: { linkIds: readonly number[] }
|
||||
): PromotedWidgetSource | undefined {
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(subgraphNode.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
|
||||
if (!targetInput) continue
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputsByName(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedInputNames: readonly string[]
|
||||
@@ -111,12 +78,13 @@ export function reorderSubgraphInputsByName(
|
||||
|
||||
export function reorderSubgraphInputsByWidgetOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedWidgets: readonly Pick<IBaseWidget, 'widgetId'>[]
|
||||
orderedWidgets: readonly IBaseWidget[]
|
||||
): void {
|
||||
const remainingIndices = new Set(subgraphNode.inputs.keys())
|
||||
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
|
||||
for (const index of remainingIndices) {
|
||||
if (isSamePromotedInput(subgraphNode, index, orderedWidget)) {
|
||||
const widget = subgraphNode.inputs[index]?._widget
|
||||
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
|
||||
remainingIndices.delete(index)
|
||||
return [index]
|
||||
}
|
||||
@@ -133,48 +101,37 @@ function applySubgraphInputOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedIndices: readonly number[]
|
||||
): void {
|
||||
const widgetValues = subgraphNode.inputs.map((input) => {
|
||||
const id = input?.widgetId
|
||||
if (!id) return undefined
|
||||
const value = useWidgetValueStore().getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
})
|
||||
const widgetValues = subgraphNode.inputs.map((input) =>
|
||||
getExplicitHostWidgetValue(input?._widget)
|
||||
)
|
||||
|
||||
reorderSubgraphInputs(subgraphNode, orderedIndices)
|
||||
|
||||
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
|
||||
const value = widgetValues[oldIndex]
|
||||
const id = subgraphNode.inputs[newIndex]?.widgetId
|
||||
if (value === undefined || !id) continue
|
||||
useWidgetValueStore().setValue(id, value)
|
||||
if (value === undefined) continue
|
||||
const widget = subgraphNode.inputs[newIndex]?._widget
|
||||
if (widget) widget.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function isSamePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
inputIndex: number,
|
||||
orderedWidget: Pick<IBaseWidget, 'widgetId'>
|
||||
): boolean {
|
||||
const input = subgraphNode.inputs[inputIndex]
|
||||
const linkedInput = input?._subgraphSlot
|
||||
if (!input || !linkedInput) return false
|
||||
function getExplicitHostWidgetValue(
|
||||
widget: IBaseWidget | undefined
|
||||
): IBaseWidget['value'] | undefined {
|
||||
if (!widget) return undefined
|
||||
if (!isPromotedWidgetView(widget)) return widget.value
|
||||
|
||||
for (const linkId of linkedInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
}
|
||||
|
||||
const { inputNode, input: targetInput } = link.resolve(
|
||||
subgraphNode.subgraph
|
||||
)
|
||||
if (!inputNode || !targetInput) continue
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (targetWidget === orderedWidget) return true
|
||||
|
||||
if (input.widgetId && input.widgetId === orderedWidget.widgetId) return true
|
||||
}
|
||||
|
||||
return false
|
||||
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(left) &&
|
||||
isPromotedWidgetView(right) &&
|
||||
left.sourceNodeId === right.sourceNodeId &&
|
||||
left.sourceWidgetName === right.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function isPreviewExposed(
|
||||
@@ -211,9 +168,13 @@ function toPromotionSource(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetSource {
|
||||
const widgetIsParentLevelView =
|
||||
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
sourceWidgetName: widgetIsParentLevelView
|
||||
? widget.sourceWidgetName
|
||||
: getWidgetName(widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,53 +211,15 @@ export function promoteValueWidgetViaSubgraphInput(
|
||||
inputName,
|
||||
String(sourceSlot.type ?? sourceWidget.type ?? '*')
|
||||
)
|
||||
subgraphInput.label = sourceSlot.label
|
||||
const link = subgraphInput.connect(sourceSlot, sourceNode)
|
||||
if (!link) {
|
||||
subgraphNode.subgraph.removeInput(subgraphInput)
|
||||
return { ok: false, reason: 'connectFailed' }
|
||||
}
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(input) => input._subgraphSlot === subgraphInput
|
||||
)
|
||||
if (hostInput) hostInput.label = sourceSlot.label
|
||||
|
||||
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function seedNestedPromotedInputState(
|
||||
subgraphNode: SubgraphNode,
|
||||
inputName: string,
|
||||
sourceSlot: { widgetId?: WidgetId; label?: string }
|
||||
): void {
|
||||
if (!sourceSlot.widgetId) return
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(input) => input._subgraphSlot?.name === inputName
|
||||
)
|
||||
if (!hostInput || hostInput.widgetId) return
|
||||
|
||||
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
|
||||
if (!sourceState) return
|
||||
|
||||
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
|
||||
hostInput.widget ??= { name: inputName }
|
||||
hostInput.widget.name = inputName
|
||||
hostInput.widgetId = id
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: sourceState.type,
|
||||
value: sourceState.value,
|
||||
options: cloneDeep(sourceState.options ?? {}),
|
||||
label: hostInput.label ?? sourceSlot.label ?? inputName,
|
||||
serialize: sourceState.serialize,
|
||||
disabled: sourceState.disabled,
|
||||
isDOMWidget: sourceState.isDOMWidget
|
||||
})
|
||||
}
|
||||
|
||||
function promotePreviewViaExposure(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNode: LGraphNode,
|
||||
@@ -360,32 +283,6 @@ export function promoteWidget(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the host input projecting a linked promotion identified by source.
|
||||
* Returns true when an input was found and demoted.
|
||||
*/
|
||||
export function demotePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
if (!subgraphNode.subgraph) return false
|
||||
|
||||
const hostInput = findHostInputForPromotion(
|
||||
subgraphNode,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (!linkedInput) return false
|
||||
|
||||
if (hostInput.link != null) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
subgraphNode.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
@@ -395,7 +292,21 @@ export function demoteWidget(
|
||||
for (const parent of parents) {
|
||||
if (!parent.subgraph) continue
|
||||
|
||||
if (demotePromotedInput(parent, source)) continue
|
||||
const hostInput = findHostInputForPromotion(
|
||||
parent,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (linkedInput) {
|
||||
const hasExternalLink = hostInput.link != null
|
||||
if (hasExternalLink) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
parent.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
const previewStore = usePreviewExposureStore()
|
||||
@@ -594,19 +505,37 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
const removedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
const staleInputs = subgraph.inputs.filter((input) => {
|
||||
const source = resolvePromotionSource(subgraphNode, input)
|
||||
if (source) return false
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return false
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(entry) => entry._subgraphSlot === input
|
||||
// If the SubgraphInput has any live link to an interior target slot that
|
||||
// still has a widget, the promotion is alive — even when the widget's
|
||||
// sourceNodeId points at a deeply-nested interior node that does not exist
|
||||
// directly in `subgraph` (nested SubgraphNode promotions).
|
||||
for (const linkId of input.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode) continue
|
||||
const targetInputSlot = inputNode.inputs?.find(
|
||||
(slot) => slot.link === linkId
|
||||
)
|
||||
if (!targetInputSlot) continue
|
||||
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
|
||||
}
|
||||
|
||||
const node = subgraph.getNodeById(widget.sourceNodeId)
|
||||
if (!node) {
|
||||
removedEntries.push(widget)
|
||||
return true
|
||||
}
|
||||
const hasWidget = getPromotableWidgets(node).some(
|
||||
(iw) => iw.name === widget.sourceWidgetName
|
||||
)
|
||||
if (!hostInput?.widgetId && !hostInput?._widget) return false
|
||||
|
||||
removedEntries.push({
|
||||
sourceNodeId: String(subgraphNode.id),
|
||||
sourceWidgetName: input.name
|
||||
})
|
||||
return true
|
||||
if (!hasWidget) {
|
||||
removedEntries.push(widget)
|
||||
}
|
||||
return !hasWidget
|
||||
})
|
||||
|
||||
for (const input of staleInputs) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
@@ -22,6 +24,15 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
type PromotedWidgetStub = Pick<
|
||||
IBaseWidget,
|
||||
'name' | 'type' | 'options' | 'value' | 'y'
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
function createHostNode(id: number): SubgraphNode {
|
||||
return createTestSubgraphNode(createTestSubgraph(), { id })
|
||||
}
|
||||
@@ -36,10 +47,55 @@ function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
|
||||
return node.addWidget('text', name, `${name}-value`, () => undefined)
|
||||
}
|
||||
|
||||
function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
type: 'button',
|
||||
options: {},
|
||||
y: 0,
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('resolvePromotedWidgetAtHost', () => {
|
||||
test('resolves a direct concrete widget on the host subgraph node', () => {
|
||||
const host = createHostNode(100)
|
||||
const concreteNode = addNodeToHost(host, 'leaf')
|
||||
addConcreteWidget(concreteNode, 'seed')
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(concreteNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node.id).toBe(concreteNode.id)
|
||||
expect(resolved?.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('returns undefined when host does not contain the target node', () => {
|
||||
const host = createHostNode(100)
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
test('resolves a direct concrete source widget', () => {
|
||||
const host = createHostNode(100)
|
||||
@@ -58,86 +114,102 @@ describe('resolveConcretePromotedWidget', () => {
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('descends through nested subgraph inputs to the deepest concrete widget', () => {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'x', type: '*' }]
|
||||
})
|
||||
const leaf = new LGraphNode('Leaf')
|
||||
const leafInput = leaf.addInput('x', '*')
|
||||
leaf.addWidget('combo', 'seed', 'a', () => undefined, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
leafInput.widget = { name: 'seed' }
|
||||
innerSubgraph.add(leaf)
|
||||
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const innerNode = createTestSubgraphNode(innerSubgraph, { id: 11 })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'y', type: '*' }]
|
||||
})
|
||||
outerSubgraph.add(innerNode)
|
||||
innerNode._internalConfigureAfterSlots()
|
||||
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
|
||||
|
||||
const outerNode = createTestSubgraphNode(outerSubgraph, { id: 22 })
|
||||
test('descends through nested promoted widgets to resolve concrete source', () => {
|
||||
const rootHost = createHostNode(100)
|
||||
const nestedHost = createHostNode(101)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'seed')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
outerNode,
|
||||
String(innerNode.id),
|
||||
'x'
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'outer'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
expect(result.resolved.node.id).toBe(leaf.id)
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
expect(result.resolved.widget.type).toBe('combo')
|
||||
})
|
||||
|
||||
test('returns cycle when nested promoted widget traversal revisits the same input', () => {
|
||||
const recursiveInput = { name: 'x', link: 1 }
|
||||
const recursiveNode = fromAny<LGraphNode, unknown>({
|
||||
id: 11,
|
||||
inputs: [recursiveInput],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
inputNode: { slots: [{ name: 'x', linkIds: [1] }] },
|
||||
getLink: () => ({
|
||||
resolve: () => ({ inputNode: recursiveNode })
|
||||
}),
|
||||
getNodeById: () => recursiveNode
|
||||
}
|
||||
})
|
||||
const host = fromAny<SubgraphNode, unknown>({
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
getNodeById: () => recursiveNode
|
||||
}
|
||||
})
|
||||
test('returns cycle failure when promoted widgets form a loop', () => {
|
||||
const hostA = createHostNode(200)
|
||||
const hostB = createHostNode(201)
|
||||
const relayA = addNodeToHost(hostA, 'relayA')
|
||||
const relayB = addNodeToHost(hostB, 'relayB')
|
||||
|
||||
const result = resolveConcretePromotedWidget(host, '11', 'x')
|
||||
relayA.widgets = [
|
||||
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
|
||||
]
|
||||
relayB.widgets = [
|
||||
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
|
||||
]
|
||||
|
||||
expect(result).toEqual({ status: 'failure', failure: 'cycle' })
|
||||
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'cycle'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns max-depth-exceeded for a chain over the traversal limit', () => {
|
||||
const subgraphs = Array.from({ length: 102 }, () =>
|
||||
createTestSubgraph({ inputs: [{ name: 'x', type: '*' }] })
|
||||
test('does not report a cycle when different host objects share an id', () => {
|
||||
const rootHost = createHostNode(41)
|
||||
const nestedHost = createHostNode(41)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'w')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'w'
|
||||
)
|
||||
|
||||
for (let index = 0; index < subgraphs.length - 1; index++) {
|
||||
const current = subgraphs[index]
|
||||
const next = subgraphs[index + 1]
|
||||
const nextNode = createTestSubgraphNode(next, { id: index + 1 })
|
||||
current.add(nextNode)
|
||||
nextNode._internalConfigureAfterSlots()
|
||||
current.inputNode.slots[0].connect(nextNode.inputs[0], nextNode)
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('w')
|
||||
})
|
||||
|
||||
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
|
||||
const hosts = Array.from({ length: 102 }, (_, index) =>
|
||||
createHostNode(index + 1)
|
||||
)
|
||||
const relayNodes = hosts.map((host, index) =>
|
||||
addNodeToHost(host, `relay-${index}`)
|
||||
)
|
||||
|
||||
for (let index = 0; index < relayNodes.length - 1; index += 1) {
|
||||
relayNodes[index].widgets = [
|
||||
createPromotedWidget(
|
||||
`w-${index}`,
|
||||
String(relayNodes[index + 1].id),
|
||||
`w-${index + 1}`,
|
||||
hosts[index + 1]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const host = createTestSubgraphNode(subgraphs[0], { id: 200 })
|
||||
addConcreteWidget(
|
||||
relayNodes[relayNodes.length - 1],
|
||||
`w-${relayNodes.length - 1}`
|
||||
)
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hosts[0],
|
||||
String(relayNodes[0].id),
|
||||
'w-0'
|
||||
)
|
||||
|
||||
const result = resolveConcretePromotedWidget(host, '1', 'x')
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'max-depth-exceeded'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedWidgetResolutionFailure =
|
||||
| 'invalid-host'
|
||||
@@ -40,17 +41,6 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
|
||||
if (!target) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
currentHost = sourceNode
|
||||
currentNodeId = target.nodeId
|
||||
currentWidgetName = target.widgetName
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === currentWidgetName
|
||||
)
|
||||
@@ -58,15 +48,39 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
if (!isPromotedWidgetView(sourceWidget)) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceWidget.node?.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = node.widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
@@ -77,3 +91,20 @@ export function resolveConcretePromotedWidget(
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
if (!isPromotedWidgetView(widget)) return undefined
|
||||
if (!hostNode.isSubgraphNode()) return undefined
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function hasWidgetNode(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { node: LGraphNode } {
|
||||
return 'node' in widget && !!widget.node
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
|
||||
let nextWidgetRenderKeyId = 0
|
||||
|
||||
@@ -7,7 +9,9 @@ export function getStableWidgetRenderKey(widget: IBaseWidget): string {
|
||||
const cachedKey = widgetRenderKeys.get(widget)
|
||||
if (cachedKey) return cachedKey
|
||||
|
||||
const key = `widget:${nextWidgetRenderKeyId++}`
|
||||
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
|
||||
const key = `${prefix}:${nextWidgetRenderKeyId++}`
|
||||
|
||||
widgetRenderKeys.set(widget, key)
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
@@ -191,9 +190,7 @@ function dynamicComboWidget(
|
||||
const getState = () => {
|
||||
const graphId = resolveNodeRootGraphId(node)
|
||||
if (!graphId) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
widgetId(graphId, node.id, widget.name)
|
||||
)
|
||||
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
|
||||
}
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
|
||||
applyFirstWidgetValueToGraph(this, extraLinks)
|
||||
@@ -52,15 +51,16 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return (
|
||||
useWidgetValueStore().getWidget(
|
||||
widgetId(app.rootGraph.id, node.id, widgetName)
|
||||
)?.value ?? localValue
|
||||
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
|
||||
?.value ?? localValue
|
||||
)
|
||||
},
|
||||
set(v: string) {
|
||||
localValue = v
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
widgetId(app.rootGraph.id, node.id, widgetName)
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
widgetName
|
||||
)
|
||||
if (state) state.value = v
|
||||
updateCombo()
|
||||
|
||||
@@ -271,10 +271,7 @@ useExtensionService().registerExtension({
|
||||
component: Load3DViewerContent,
|
||||
props: props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[80vw] max-w-[80vw] sm:max-w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true,
|
||||
onClose: async () => {
|
||||
await useLoad3dService().handleViewerClose(props.node)
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const { app } = vi.hoisted(() => ({
|
||||
app: {
|
||||
registerExtension: vi.fn(),
|
||||
graph: undefined as unknown as LGraph
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app }))
|
||||
|
||||
type BeforeRegisterNodeDef = NonNullable<
|
||||
ComfyExtension['beforeRegisterNodeDef']
|
||||
>
|
||||
|
||||
interface FilenamePrefixWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
serializeValue?: () => string
|
||||
}
|
||||
|
||||
async function loadExtension(): Promise<ComfyExtension> {
|
||||
vi.resetModules()
|
||||
app.registerExtension.mockClear()
|
||||
await import('./saveImageExtraOutput')
|
||||
return app.registerExtension.mock.calls[0][0] as ComfyExtension
|
||||
}
|
||||
|
||||
async function createNodeWithFilenamePrefix(
|
||||
nodeName: string,
|
||||
prefix: string
|
||||
): Promise<FilenamePrefixWidget> {
|
||||
const ext = await loadExtension()
|
||||
|
||||
const nodeType = {
|
||||
prototype: {}
|
||||
} as unknown as Parameters<BeforeRegisterNodeDef>[0]
|
||||
const nodeData = { name: nodeName } as ComfyNodeDef
|
||||
|
||||
await ext.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<BeforeRegisterNodeDef>[2]
|
||||
)
|
||||
|
||||
const widget: FilenamePrefixWidget = {
|
||||
name: 'filename_prefix',
|
||||
value: prefix
|
||||
}
|
||||
const node = { widgets: [widget] }
|
||||
const proto = nodeType.prototype as { onNodeCreated?: () => void }
|
||||
proto.onNodeCreated!.call(node)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
describe('Comfy.SaveImageExtraOutput', () => {
|
||||
beforeEach(() => {
|
||||
const graph = new LGraph()
|
||||
graph.add({
|
||||
properties: { 'Node name for S&R': 'Sampler' },
|
||||
widgets: [{ name: 'seed', value: 12345 }]
|
||||
} as unknown as LGraphNode)
|
||||
app.graph = graph
|
||||
})
|
||||
|
||||
it.each([
|
||||
'SaveImage',
|
||||
'SaveImageAdvanced',
|
||||
'SaveSVGNode',
|
||||
'SaveVideo',
|
||||
'SaveAnimatedWEBP',
|
||||
'SaveWEBM',
|
||||
'SaveAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
'ModelSave',
|
||||
'LoraSave',
|
||||
'SaveLatent'
|
||||
])(
|
||||
'resolves text replacements in the filename_prefix of %s on serialize',
|
||||
async (nodeName) => {
|
||||
const widget = await createNodeWithFilenamePrefix(
|
||||
nodeName,
|
||||
'ComfyUI_%Sampler.seed%'
|
||||
)
|
||||
|
||||
expect(widget.serializeValue!()).toBe('ComfyUI_12345')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -6,15 +6,10 @@ import { app } from '../../scripts/app'
|
||||
|
||||
const saveNodeTypes = new Set([
|
||||
'SaveImage',
|
||||
'SaveImageAdvanced',
|
||||
'SaveSVGNode',
|
||||
'SaveVideo',
|
||||
'SaveAnimatedWEBP',
|
||||
'SaveWEBM',
|
||||
'SaveAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
|
||||
@@ -20,7 +20,6 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import { type NodeLocatorId } from '@/types'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { api } from '../../scripts/api'
|
||||
@@ -153,16 +152,16 @@ app.registerExtension({
|
||||
|
||||
audioUIWidget.options.getValue = () =>
|
||||
(useWidgetValueStore().getWidget(
|
||||
widgetId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
)
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
)?.value as string) ?? ''
|
||||
audioUIWidget.options.setValue = (v) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
widgetId(graphId, node.id, inputName)
|
||||
graphId,
|
||||
node.id,
|
||||
inputName
|
||||
)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { UUID } from '@/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphData,
|
||||
@@ -297,8 +296,9 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const seedWidgetId = widgetId(graphId, '10' as NodeId, 'seed')
|
||||
widgetValueStore.registerWidget(seedWidgetId, {
|
||||
widgetValueStore.registerWidget(graphId, {
|
||||
nodeId: '10' as NodeId,
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 1,
|
||||
options: {},
|
||||
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
disabled: undefined
|
||||
})
|
||||
|
||||
expect(widgetValueStore.getWidget(seedWidgetId)).toEqual(
|
||||
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
|
||||
expect.objectContaining({ value: 1 })
|
||||
)
|
||||
expect(
|
||||
@@ -316,7 +316,9 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(widgetValueStore.getWidget(seedWidgetId)).toBeUndefined()
|
||||
expect(
|
||||
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
|
||||
).toBeUndefined()
|
||||
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -8931,20 +8931,71 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all nodes that are children of groups in the selection
|
||||
*/
|
||||
private collectNodesInGroups(items: Set<Positionable>): Set<LGraphNode> {
|
||||
const nodesInGroups = new Set<LGraphNode>()
|
||||
for (const item of items) {
|
||||
if (item instanceof LGraphGroup) {
|
||||
for (const child of item._children) {
|
||||
if (child instanceof LGraphNode) {
|
||||
nodesInGroups.add(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodesInGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* Move group children (both nodes and non-nodes)
|
||||
*/
|
||||
private moveGroupChildren(
|
||||
group: LGraphGroup,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
|
||||
): void {
|
||||
for (const child of group._children) {
|
||||
if (child instanceof LGraphNode) {
|
||||
const node = child as LGraphNode
|
||||
nodesToMove.push({
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else if (!(child instanceof LGraphGroup)) {
|
||||
// Non-node, non-group children (reroutes, etc.)
|
||||
// Skip groups here - they're already in allItems and will be
|
||||
// processed in the main loop of moveChildNodesInGroupVueMode
|
||||
child.move(deltaX, deltaY, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveChildNodesInGroupVueMode(
|
||||
allItems: Set<Positionable>,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
) {
|
||||
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
|
||||
const nodesToMove: NewNodePosition[] = []
|
||||
|
||||
// First, collect all the moves we need to make
|
||||
for (const item of allItems) {
|
||||
if (item instanceof LGraphNode) {
|
||||
const isNode = item instanceof LGraphNode
|
||||
if (isNode) {
|
||||
const node = item as LGraphNode
|
||||
if (nodesInMovingGroups.has(node)) {
|
||||
continue
|
||||
}
|
||||
nodesToMove.push({
|
||||
node: item,
|
||||
newPos: this.calculateNewPosition(item, deltaX, deltaY)
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else if (item instanceof LGraphGroup) {
|
||||
item.move(deltaX, deltaY, true)
|
||||
this.moveGroupChildren(item, deltaX, deltaY, nodesToMove)
|
||||
} else {
|
||||
// Other items (reroutes, etc.)
|
||||
item.move(deltaX, deltaY, true)
|
||||
|
||||
@@ -96,9 +96,11 @@ import { BaseWidget } from './widgets/BaseWidget'
|
||||
import { toConcreteWidget } from './widgets/widgetMap'
|
||||
import type { WidgetTypeMap } from './widgets/widgetMap'
|
||||
|
||||
import type { NodeId } from '@/world/entityIds'
|
||||
|
||||
// #region Types
|
||||
|
||||
export type NodeId = number | string
|
||||
export type { NodeId }
|
||||
|
||||
export type NodeProperty = string | number | boolean | object
|
||||
|
||||
@@ -4238,9 +4240,7 @@ export class LGraphNode
|
||||
if (!widget) continue
|
||||
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const pos: [number, number] = [offset, widget.y + offset]
|
||||
slot.pos = pos
|
||||
this.inputs[i].pos = pos
|
||||
slot.pos = [offset, widget.y + offset]
|
||||
this._measureSlot(slot, i, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Rectangle } from './infrastructure/Rectangle'
|
||||
import type { CanvasColour } from './interfaces'
|
||||
import { litegraph } from './litegraphInstance'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import { RenderShape, TitleMode } from './types/globalEnums'
|
||||
import { cachedMeasureText } from './utils/textMeasureCache'
|
||||
|
||||
@@ -81,12 +81,12 @@ export function strokeShape(
|
||||
}: IDrawBoundingOptions = {}
|
||||
): void {
|
||||
// These param defaults are not compile-time static, and must be re-evaluated at runtime
|
||||
round_radius ??= litegraph().ROUND_RADIUS
|
||||
color ??= litegraph().NODE_BOX_OUTLINE_COLOR
|
||||
round_radius ??= LiteGraph.ROUND_RADIUS
|
||||
color ??= LiteGraph.NODE_BOX_OUTLINE_COLOR
|
||||
|
||||
// Adjust area if title is transparent
|
||||
if (title_mode === TitleMode.TRANSPARENT_TITLE) {
|
||||
const height = title_height ?? litegraph().NODE_TITLE_HEIGHT
|
||||
const height = title_height ?? LiteGraph.NODE_TITLE_HEIGHT
|
||||
area[1] -= height
|
||||
area[3] += height
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user