Compare commits

..

16 Commits

Author SHA1 Message Date
huang47
ae6d29f4e8 chore: add CodeRabbit app queue cleanup check
Add a deterministic CodeRabbit pre-merge check for the DrJKL app queuePrompt finding: when app.ts temporarily assigns api.authToken/api.apiKey around api.queuePrompt, require rejected-path tests that prove both credentials are cleared. Path instructions alone did not reliably surface this on the canary.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file and confirmed the custom check list includes App queue credential cleanup on rejection; ran git diff --check successfully.
2026-07-07 20:02:38 -07:00
huang47
676c3c5f1d chore: tighten CodeRabbit app queue rejection instruction
Strengthen app-specific CodeRabbit guidance after the canary still missed DrJKL’s rejected queuePrompt credential cleanup finding. Add production-path guidance for src/scripts/app.ts and make rejection-without-cleanup assertions explicitly reviewable.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file successfully; ran git diff --check successfully; inspected diff to confirm only app queue cleanup guidance changed.
2026-07-07 18:56:09 -07:00
huang47
02f7a27215 chore: add CodeRabbit LGraph configure instruction
Add targeted LGraph production-path guidance so CodeRabbit asks for direct configure() regression coverage when deserialization or nullable link handling changes. This targets the DrJKL review pattern where helper-only null-link coverage did not prove graph deserialization behavior.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file successfully; ran git diff --check successfully; inspected diff to confirm only src/lib/litegraph/src/LGraph.ts path guidance changed.
2026-07-07 18:34:36 -07:00
huang47
f4526fdd8f chore: add CodeRabbit app queue cleanup instruction
Add a targeted app test path instruction for queuePrompt reviews so CodeRabbit asks for rejected-path authToken/apiKey cleanup coverage, matching the DrJKL review pattern on app.core.test.ts.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file successfully; ran git diff --check successfully; inspected diff to confirm only src/scripts/app*.test.ts path guidance changed.
2026-07-07 18:34:05 -07:00
huang47
7ef845bda7 chore: sharpen CodeRabbit Vitest failure-path review
Teach the shared Vitest path instruction to require rejected-path cleanup assertions and direct runtime-entrypoint coverage. This targets the missed DrJKL pattern where success cleanup existed but rejected queuePrompt cleanup was unverified.

Verification: parsed .coderabbit.yaml with Ruby YAML.load_file successfully; ran git diff --check successfully; inspected staged diff to confirm only the shared Vitest path instruction changed.
2026-07-07 18:33:09 -07:00
huang47
3e043acfdb chore: add CodeRabbit Playwright path instruction
Route changed browser_tests/**/*.spec.ts and apps/website/e2e/**/*.spec.ts files through CodeRabbit guidance that explicitly names .agents/checks/test-quality.md and docs/testing/README.md as required review context.

Verification: parsed .coderabbit.yaml with Ruby YAML, ran git diff --check, and ran pnpm typecheck in this worktree.
2026-07-07 15:16:37 -07:00
huang47
0b66a8124d chore: add CodeRabbit litegraph test path instruction
Move litegraph-only test guidance out of the blanket **/*.test.ts rule and into src/lib/litegraph/**/*.test.ts so unrelated Vitest files are not nudged toward litegraph factories or litegraph class mocking rules.

Verification: parsed .coderabbit.yaml with Ruby YAML, ran git diff --check, and ran pnpm typecheck in this worktree.
2026-07-07 15:11:58 -07:00
huang47
bba941b112 chore: add CodeRabbit Vitest path instruction
Route changed .test.ts files through CodeRabbit guidance that explicitly names .agents/checks/test-quality.md, docs/testing/README.md, and docs/guidance/vitest.md as required review context.

Verification: parsed .coderabbit.yaml with Ruby YAML, ran git diff --check, and ran pnpm typecheck in this worktree.
2026-07-07 15:11:30 -07:00
Wei Hai
747f76db76 ci: auto-merge approved, green backport PRs (#13390)
## Problem
`pr-backport.yaml` opens each backport PR (labelled `backport`) and
calls `gh pr merge --auto --squash`. GitHub's `--auto` only takes effect
when the repository's **"Allow auto-merge"** setting is enabled — it's
currently off, so that call is a silent no-op (swallowed by its `|| echo
"::warning::…"`). The result: every backport PR sits unmerged until
someone manually clicks merge, even when it's already approved with
green checks.

## What this does
Adds `.github/workflows/backport-auto-merge.yaml`, which completes the
merge directly — a plain `gh pr merge --squash` (which does **not**
depend on the "Allow auto-merge" setting) — once GitHub reports the PR
ready to merge.

Ready = `reviewDecision == APPROVED` **and** `mergeStateStatus` is
`CLEAN` or `UNSTABLE`. `UNSTABLE` means the required checks passed but a
*non-required* check is still pending/failing — GitHub still permits
that merge, and gating on `CLEAN` alone would leave backports stuck
behind slow/flaky non-required checks (Socket, codecov, perf, storybook,
etc.).

**Branch protection stays the real gate.** The `core/**` / `cloud/**`
ruleset unconditionally requires an approval + the required checks and
can't be bypassed, and GitHub's merge API re-enforces it at merge time —
so this workflow can only ever finish a merge that already satisfies
those rules. The eligibility check just avoids pointless attempts.

## Design notes
- **Merges with `PR_GH_TOKEN`, not the default token**, on purpose: a
merge by the default `GITHUB_TOKEN` does not emit the `pull_request:
closed` event, which would silently starve `cloud-backport-tag.yaml` (it
creates the `cloud/vX.Y.Z` tag on that event).
- **Triggers:** review submission + check-suite completion (low
latency), plus a 30-min sweep as a backstop for cases the events miss.
- **Never checks out PR code** (no untrusted-code path); only reads PR
metadata via the API. `permissions` on the default token are read-only.
- **Idempotent, bounded merge loop:** treats an already-merged PR (e.g.
a concurrent run or a human) as success, so it won't post a false
failure comment.
- Leaves the existing conflict path in `pr-backport.yaml` untouched
(conflicts never create a PR, so there's nothing here to act on).

## Validation
YAML parses; `actionlint` (with shellcheck) and `zizmor` both clean (0
findings).

## Before relying on it
- Confirm the org allows this workflow to run/merge (Actions policy) —
the merge uses a PAT so it shouldn't depend on the "Actions can approve
PRs" toggle, but worth verifying.
- First real backport: confirm it merges on ready and that
`cloud-backport-tag.yaml` then fires and creates the tag.
2026-07-07 16:50:23 +00:00
Mobeen Abdullah
386460afef fix(website): center button labels by tuning ppformula-text-center (#13445)
## Summary

Vertically center button/badge/nav labels by tuning the shared
`ppformula-text-center` utility from `top: 0.19em` to `top: 0.1em`.

## The alignment issue

PP Formula (our brand font) has asymmetric vertical metrics: its caps
sit high in the line box, so a naively centered label looks too high.
`ppformula-text-center` compensates by nudging the label down with
`position: relative; top: <em>` (a purely visual shift, it does not
change the element's box, so button/badge sizes are unaffected).

The value was `0.19em`, which **over-corrected**: the glyph ink ended up
~1.4px **below** center on every button, so labels read slightly low.
Measuring the actual glyph ink (canvas `measureText`
`actualBoundingBox*`) showed ~**0.09-0.10em** centers uppercase labels;
`0.1em` lands the ink within ~0.1px of center.

## Why it's safe (verified)

This utility is used site-wide (Button, Badge, ButtonPill, ButtonMask,
BrandButton, nav triggers, section labels). Because it's a
`position:relative` nudge, there is **no layout/box-size change**
anywhere. I measured glyph-ink centering across **12 pages** (home,
cloud, cloud/pricing, download, careers, customers, demos, enterprise,
api, mcp, gallery, learning):

- Buttons/badges/pills went from ~1.37px low to **~0.11px** (centered).
- **Nothing regressed** (no element pushed too high).
- The handful of numeric "outliers" were `text-transform: uppercase`
measurement artifacts (source text with descenders that don't render);
confirmed visually as centered.

## Changes

- **What**: `apps/website/src/styles/global.css` —
`ppformula-text-center` `top: 0.19em` → `0.1em` (one line).

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 20:37:32 +05:00
Mobeen Abdullah
5cf647d183 feat(website): add GPT Image 2 to cloud page model list (#13431)
## Summary

Add GPT Image 2 to the `/cloud` "AI models" section, and apply the
design review polish from Bert and June on the same section and the
neighbouring cloud-page cards.

## Changes

- **GPT Image 2 card**: 6th card in `AIModelsSection` (workflow video on
`media.comfy.org`, OpenAI badge reused from `packages/design-system`),
new `cloud.aiModels.card.gptImage2` i18n key (en + zh-CN), and GPT Image
2 added to the `cloud.reason.2.description` partner-model list.
- **AI models layout**: the six cards are now equal 1:1 squares in one
shared grey container (was a per-card treatment, then simplified to a
single container per design), with a corner arrow affordance on each.
- **Audience cards** (`AudienceSection`): the creators / teams cards now
link to `cloud.comfy.org` with the same corner arrow (highlights on card
hover).
- **Reusable `CardArrow`**: extracted the corner arrow into a shared,
decorative (`aria-hidden`) component used by both sections;
`hover="group"` (card hover) for audience, self-hover for the model
cards so it doesn't double up with the provider badge.
- **`ProductCard` CTA**: swapped the hand-rolled pill `<span>` for the
shared `Button` (`as="span"`) so the label is vertically centered (fixes
Bert's off-centre text) without nesting an anchor inside the card link.

## Split out of this PR

- **Button label centering** (the global `ppformula-text-center` tweak)
→ separate PR #13445, so the site-wide change is reviewed in isolation.
- **Pricing banner frame fix** reverted here; it belongs in Michael's
upcoming pricing PR (team tier, edu billing, FAQ). `PricingSection.vue`
shows only an automatic Tailwind class-order reformat from the
pre-commit hook, no behaviour change.

## Review focus

- The single-container AI models layout and the `CardArrow` hover
behaviour (group vs self).
- `Button as="span"` inside the `ProductCard` link (avoids nested
`<a>`).

Linear: FE-423

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 20:19:54 +05:00
Mobeen Abdullah
fe1fc8baa6 fix(website): standardize favicon to square brand icon (#13467)
## Summary

Standardize the marketing-site favicons to the square, full-bleed brand
mark (ink background, yellow C) so each platform applies its own corner
mask instead of double-rounding a pre-rounded asset.

## Changes

- **What**: Replace three files in `apps/website/public/`:
- `favicon.svg` — was a 57 KB RealFaviconGenerator wrapper around an
embedded PNG; now a 1.2 KB vector of the square mark.
- `favicon-96x96.png` and `apple-touch-icon.png` — regenerated square.
The apple-touch icon previously had transparent rounded corners, which
iOS composites onto a white tile; it is now full-bleed.
- `favicon.ico` and the `web-app-manifest-*.png` files were already the
square mark, so they are left unchanged.

## Review Focus

- Favicons cache aggressively (browser + CDN, and these paths are marked
`immutable` in `vercel.json`), so verify the preview with a hard refresh
or a fresh profile.
- Part of the org-wide favicon standardization (FE-705). Companion PRs
update the workflows hub, docs, and registry favicons to the same mark.
Design direction (square, not rounded) confirmed by Bert.

## Screenshots

The before/after for each binary is visible inline in the Files changed
tab. Square ink + yellow C, no transparency, sharp corners.
2026-07-07 20:18:12 +05:00
Benjamin Lu
3e4dd59e5f feat(navigation): add opt-in strip-on-capture to preserved-query tracker (#13465)
## What

- `installPreservedQueryTracker` definitions accept an opt-in
`stripAfterCapture` flag: the marked keys are captured into the
sessionStorage stash and removed from the URL before the navigation
completes (single guard redirect at the decoded query-object level;
push/replace semantics are inherited from the original navigation, and
vue-router force-replaces the initial one).
- `preservedQueryManager` now captures the first non-empty string
element of repeated (array-valued) params instead of silently dropping
them.
- New real-router test suite for the tracker (createRouter +
createMemoryHistory, no router mocks), including a history-depth test
pinning the push/replace inheritance; manager tests extended for the
array/junk-value cases.

## Why

One-time secrets in query params (first consumer: desktop login codes,
GTM-93) must not linger in the visible URL, browser history,
`previousFullPath` redirects, or telemetry. Stripping after navigation —
what each loader does ad hoc today — leaves a window and forces
per-feature URL scrubbing; #13418 originally needed a hand-rolled
encoding-aware string parser in three places. Stripping at capture time,
at the decoded query-object level, makes the stash the only carrier and
lets vue-router round-trip the surviving params' encoding itself.

Capability only — no existing namespace opts in; behavior is unchanged
for all current definitions. `stripAfterCapture`'s contract is
documented on the option: strip-marked keys must never be read from
`route.query` by later guards or views; the stash is the only
post-capture source.

## Landing order

Independent of everything else; #13418 stacks on this branch.
2026-07-07 07:08:11 +00:00
Benjamin Lu
e25e0f2e16 refactor: simplify slot link drop finalization (#13471)
## Summary

Small cleanup in `useSlotLinkInteraction.ts`, no behavior change:

- Removed a duplicated `raf.flush()` in `finishInteraction` (it was
called twice back-to-back).
- Collapsed four single-line `attempt*` alias closures in
`connectByPriority` into a direct short-circuit chain, preserving the
same evaluation order:

  ```ts
  return (
    tryConnectToCandidate(snappedCandidate) ||
    tryConnectToCandidate(domSlotCandidate) ||
    tryConnectToCandidate(nodeSurfaceSlotCandidate) ||
    tryConnectViaRerouteAtPointer()
  )
  ```

The closures added no behavior beyond renaming the calls (AGENTS.md rule
26), and `||` gives the same first-truthy-wins semantics as the previous
`if (attempt()) return true` ladder.

Verification not rerun after rebasing onto `Comfy-Org/main`; original
branch reported `pnpm typecheck`, `pnpm lint`, `pnpm format:check`,
`pnpm knip`, and the existing `useSlotLinkInteraction` unit tests
passing.

Link to Devin session:
https://app.devin.ai/sessions/1351ff5174494106a7a688777554f387
Requested by: @benceruleanlu

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-07 07:05:36 +00:00
Benjamin Lu
2ee91c30ee fix: report CLA check in merge queue (#13477)
Report the CLA Assistant required check for merge queue commits so
queued PRs do not wait indefinitely on a check that ran on the PR head.
2026-07-07 07:57:02 +00:00
Alexis Rolland
854770d305 ci: Update CLA workflow to build author-only allowlist (#13378)
## Summary

Update CLA workflow to build a dynamic `allowlist` that includes
everyone except the author of the PR. This relaxes the CLA signature
requirement so that it is limited to the PR author only. By signing, the
author confirms he gots approval from other contributors.

## Changes

- **What**: `cla.yml`

## Screenshots (if applicable)

<img width="1831" height="756"
alt="{B6F6C23D-EC2E-4BB3-A288-99B6087F4CAC}"
src="https://github.com/user-attachments/assets/62c04465-d1a3-4ddb-bfe7-950a29a802c4"
/>
2026-07-06 19:29:59 -07:00
102 changed files with 3049 additions and 2982 deletions

View File

@@ -63,3 +63,62 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
- name: App queue credential cleanup on rejection
mode: warning
instructions: |
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
This check applies ONLY when the PR changes `src/scripts/app.ts` and that diff assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`.
When applicable, require a changed app test file such as `src/scripts/app.core.test.ts` or `src/scripts/app.test.ts` to include rejected-queue coverage that:
1. Populates both credential sources (`authToken` and API key, or the stores that feed them) with non-empty values.
2. Makes `api.queuePrompt` reject.
3. Calls `app.queuePrompt`.
4. Asserts after the rejection path that both `api.authToken` and `api.apiKey` are cleared, deleted, or `undefined`.
Warn if rejection is tested without those cleanup assertions, or if cleanup is asserted only on the successful `api.queuePrompt` path. Mention that success-path cleanup alone does not prove credentials are cleared after a failed queue request.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
Flag missing behavioral coverage for changed behavior, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and unrestored global mutations.
Prefer colocated behavioral tests named after the source file.
Build partial mocks with fromPartial<T>() from @total-typescript/shoehorn; flag `as unknown as` double assertions and fromAny().
Mock only at seams (Pinia stores, settings, third-party libs); flag mocked type guards or sibling composables.
Use a real createI18n instance rather than vi.mock('vue-i18n').
Flag bare expect(fn).not.toThrow() as a sole assertion, assertions that echo stub return values, and .mock.results assertions.
For rejected promises, thrown errors, and failed async calls, require assertions for post-error state cleanup and side-effect rollback, especially auth tokens, API keys, globals, listeners, timers, subscriptions, and caches that production mutates before awaiting.
Tests for production changes must exercise the changed runtime/public entrypoint directly; flag helper-only coverage when the changed branch runs through a higher-level entrypoint.
Use @testing-library/vue for component tests, not @vue/test-utils.
For platform-owned types (Response, CustomEvent, DOM events), require real instances (new Response(), Response.json(), new CustomEvent()) instead of fromPartial or casts.
When a fixture cast hides a too-wide production signature, suggest narrowing the production type instead of casting the fixture.
Flag process-level listeners (process.on('unhandledRejection')) in tests; assert the rejected promise directly.
Tests should import the module under test from its public entrypoint, not deep internal paths.
- path: 'src/scripts/app*.test.ts'
instructions: |
For app tests that mock or spy on `api.queuePrompt`, require rejected-queue coverage whenever the code temporarily populates `api.authToken` or `api.apiKey`.
The rejected-path test must populate both values, make `api.queuePrompt` reject, and assert both fields are cleared afterward; success-path cleanup alone is insufficient.
If `api.queuePrompt` rejection is tested without asserting `api.authToken` and `api.apiKey` cleanup afterward, flag the missing assertion even when a success-path test already covers cleanup.
- path: 'src/scripts/app.ts'
instructions: |
When app queuing code temporarily assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`, require app tests for both resolved and rejected `api.queuePrompt` paths.
The rejected-path test must prove both credential fields are cleared after `api.queuePrompt` rejects; a rejection assertion without cleanup checks is insufficient.
- path: 'src/lib/litegraph/src/LGraph.ts'
instructions: |
When changes touch `LGraph.configure()`, graph deserialization, link loading, or nullable serialized links, require a direct regression test that constructs an `LGraph` and calls `configure()` with the changed serialized shape.
Helper-only coverage is insufficient for these changes. For sparse legacy links, expect a v0.4 workflow with `links: [null, validLink]` to not throw and still create the valid link.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed litegraph Vitest test file.
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling litegraph mock builders.
Flag mocked litegraph classes when a real instance or shared factory would exercise behavior directly.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md` and `docs/testing/README.md` as required review context for every changed Playwright test file.
Flag missing behavioral coverage, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and test isolation problems.
Every route.fulfill() body must be typed with generated types or schemas from packages/ingest-types, packages/registry-types, src/workbench/extensions/manager/types/generatedManagerTypes.ts, or src/schemas/; flag untyped inline JSON objects.
Never use waitForTimeout; use Locator actions and auto-retrying assertions instead.
Restrict page.evaluate() to reading internal state or fixture setup; flag any page.evaluate() that drives UI actions when a Playwright action method exists.
New shared test helpers must be Playwright fixtures via base.extend(), not properties added to ComfyPage.

View File

@@ -0,0 +1,197 @@
---
name: Backport Auto-Merge
# Completes the merge of backport PRs once they are approved and their required
# checks pass.
#
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
# sit unmerged until a human clicks merge. This workflow performs the merge
# directly (a plain `gh pr merge --squash`, which does not depend on that
# setting) once GitHub itself reports the PR as ready to merge.
#
# Safety: branch protection on core/** and cloud/** is the hard gate — it
# unconditionally requires an approval + the required status checks and cannot
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
# workflow can only ever complete a merge that already satisfies those rules;
# the eligibility check below only avoids pointless merge attempts.
#
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
# performed by the default token does not emit events that trigger other
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
# backport PR's `pull_request: closed` event to create the release tag).
on:
# Fires when someone approves — if the required checks are already green, the
# PR merges immediately.
pull_request_review:
types: [submitted]
# Primary catch for the "approved first, checks went green later" case, plus a
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
# checks completing, but GitHub suppresses `check_suite` events for its own
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
# non-latency-critical task. Backports wait hours today, so a short sweep is a
# large improvement and needs neither.
schedule:
- cron: '*/15 * * * *'
# Only constrains the default github.token (used for read-only PR lookups below).
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
permissions:
contents: read # read-only; required for gh api / gh pr list to resolve candidates
pull-requests: read # read-only; required for gh pr view eligibility checks
# Serialize runs that act on the same PR (review events keyed by PR number; all
# scheduled sweeps share one key). Cross-key overlaps are still possible but
# harmless: the merge loop treats an already-merged PR as success (idempotent).
concurrency:
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
merge:
name: Merge eligible backport PRs
# Skip review events that can't possibly make a PR mergeable — non-approval
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
# spending any API call. Schedule sweeps always proceed. The per-PR
# eligibility checks in the job still re-verify the label and decision from
# live state.
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
runs-on: ubuntu-latest
permissions:
contents: read # read-only PR/commit lookups via the default token
pull-requests: read # read-only PR metadata via the default token
steps:
- name: Collect candidate backport PRs
id: candidates
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
numbers=""
case "$EVENT_NAME" in
pull_request_review)
numbers="$PR_FROM_REVIEW"
;;
schedule)
# Sweep every open backport PR.
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
--limit 100 --json number --jq '.[].number')
;;
esac
# De-duplicate and emit space-separated, digit-only tokens.
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
echo "Candidate PRs: '${numbers:-<none>}'"
- name: Merge eligible backport PRs
if: steps.candidates.outputs.numbers != ''
env:
GH_REPO: ${{ github.repository }}
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
READ_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
run: |
set -euo pipefail
is_merged() {
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
}
for pr in $CANDIDATES; do
echo "::group::PR #${pr}"
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
if [ -z "$info" ]; then
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
fi
state=$(echo "$info" | jq -r '.state')
is_draft=$(echo "$info" | jq -r '.isDraft')
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
base=$(echo "$info" | jq -r '.baseRefName')
review=$(echo "$info" | jq -r '.reviewDecision')
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
# Only ever act on open, non-draft, backport-labelled PRs targeting a
# protected release branch.
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
echo "::endgroup::"; continue
fi
case "$base" in
cloud/*|core/*) : ;;
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
esac
# Ready = approved AND GitHub says it's mergeable with required checks green.
# CLEAN = approved, all required checks green, mergeable, no conflict.
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
# still allows the merge, so we do too (matches what a human
# clicking "Squash and merge" can do; required checks are the
# only merge gate per the ruleset). Requiring CLEAN alone would
# stick forever behind flaky/slow non-required checks.
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
# by a later event or the next sweep.
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
echo "::endgroup::"; continue
fi
echo "PR #${pr} is ready — attempting squash merge."
attempt=0
max=3
merged=false
while [ "$attempt" -lt "$max" ]; do
attempt=$((attempt + 1))
# A concurrent run (or a human) may have merged it already.
if is_merged "$pr"; then merged=true; break; fi
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
merged=true; break
fi
echo "Merge attempt ${attempt}/${max} failed: ${out}"
# No sleep after the final attempt.
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
done
# Final reconciliation: a failed merge command may just mean a concurrent
# run won the race — don't post a false failure if the PR is in fact merged.
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
if [ "$merged" = "true" ]; then
echo "PR #${pr} merged."
else
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
# Avoid spamming a persistently-stuck PR: only re-warn if the last
# warning (identified by its marker) is more than an hour old.
marker='<!-- backport-auto-merge:merge-failed -->'
# `gh api --paginate` emits one JSON array per page; `--jq` would run
# per page (missing the true latest across pages), so slurp all pages
# into one array first and filter with a separate jq pass.
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
stale=true
if [ -n "$last_warned" ]; then
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
now_epoch=$(date -u +%s)
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
fi
if [ "$stale" = "true" ]; then
body=$(printf '%s\n\n%s' \
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
"$marker")
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
else
echo "Already warned within the last hour — skipping duplicate comment."
fi
fi
echo "::endgroup::"
done

View File

@@ -6,6 +6,7 @@ on:
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
@@ -17,13 +18,45 @@ jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA already verified before merge queue
if: github.event_name == 'merge_group'
run: echo "CLA is checked on the pull request before it enters merge queue."
# The CLA action normally requires every commit author in a PR to sign.
# We only want the PR author to sign, so we allowlist all other committers
# by computing them from the PR's commits and excluding the PR author.
- name: Build author-only allowlist
id: allowlist
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
else
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
fi
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -39,9 +72,10 @@ jobs:
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# Only the PR author must sign: bots plus every non-author committer
# are allowlisted via the "Build author-only allowlist" step above.
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
allowlist: ${{ steps.allowlist.outputs.allowlist }}
# Custom PR comment messages
custom-notsigned-prcomment: |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 938 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1483_15836)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1483_15836">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
const { hover = 'self', class: className } = defineProps<{
hover?: 'self' | 'group'
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
hover === 'group'
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
className
)
"
aria-hidden="true"
>
<ChevronRight class="size-5" :stroke-width="2" />
</div>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from '../ui/button/Button.vue'
const { title, description, cta, href, bg } = defineProps<{
title: string
description: string
@@ -28,11 +30,9 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
>
<Button as="span" variant="default" size="sm" class="mt-4">
{{ cta }}
</span>
</Button>
</div>
</a>
</template>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import CardArrow from '../../common/CardArrow.vue'
import GlassCard from '../../common/GlassCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -27,7 +29,7 @@ const cards = [
<template>
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
>
{{ headingParts[0]
}}<span class="text-white">{{
@@ -37,10 +39,11 @@ const cards = [
</h2>
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
<div
<a
v-for="card in cards"
:key="card.labelKey"
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
:href="externalLinks.cloud"
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
>
<img
:src="card.image"
@@ -51,23 +54,27 @@ const cards = [
/>
<div class="mt-8 p-6">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<div class="flex items-center justify-between gap-4">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<CardArrow hover="group" class="shrink-0" />
</div>
<h3
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ t(card.titleKey, locale) }}
</h3>
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
{{ t(card.descriptionKey, locale) }}
</p>
</div>
</div>
</a>
</GlassCard>
</section>
</template>

View File

@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
>
<div class="max-w-2xl">
<h2
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
>
{{ t('cloud.pricing.title', locale) }}
</h2>
<p class="text-primary-comfy-ink mt-4 text-base">
<p class="mt-4 text-base text-primary-comfy-ink">
{{ t('cloud.pricing.description', locale) }}
</p>
<p
v-if="SHOW_FREE_TIER"
class="text-primary-comfy-ink mt-4 text-base font-bold"
class="mt-4 text-base font-bold text-primary-comfy-ink"
>
{{ t('cloud.pricing.tagline', locale) }}
</p>
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<a
:href="getRoutes(locale).cloudPricing"
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
>
{{ t('cloud.pricing.cta', locale) }}
</a>

View File

@@ -6,6 +6,7 @@ import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import BrandButton from '../../common/BrandButton.vue'
import CardArrow from '../../common/CardArrow.vue'
type ModelCard = {
titleKey:
@@ -14,11 +15,10 @@ type ModelCard = {
| 'cloud.aiModels.card.seedance20'
| 'cloud.aiModels.card.qwenImageEdit'
| 'cloud.aiModels.card.wan22TextToVideo'
| 'cloud.aiModels.card.gptImage2'
imageSrc: string
badgeIcon: string
badgeClass: string
layoutClass: string
objectPosition?: string
}
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -32,48 +32,45 @@ const modelCards: ModelCard[] = [
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
badgeIcon: '/icons/ai-models/bytedance.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.nanoBananaPro',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
badgeIcon: '/icons/ai-models/gemini.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
objectPosition: 'center 20%'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.grokImagine',
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
badgeIcon: '/icons/ai-models/grok.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.qwenImageEdit',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
badgeIcon: '/icons/ai-models/qwen.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
badgeIcon: '/icons/ai-models/wan.svg',
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.gptImage2',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
badgeIcon: '/icons/ai-models/openai.svg',
badgeClass: `${badgeBase} rounded-2xl`
}
]
function getCardClass(layoutClass: string): string {
return cn(
layoutClass,
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
)
}
const cardClass =
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
</script>
<template>
@@ -100,23 +97,18 @@ function getCardClass(layoutClass: string): string {
</p>
<div class="mt-16 w-full lg:mt-24">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
<a
v-for="card in modelCards"
:key="card.titleKey"
:href="externalLinks.workflows"
:class="getCardClass(card.layoutClass)"
:class="cardClass"
>
<video
v-if="card.imageSrc.endsWith('.webm')"
:src="card.imageSrc"
:aria-label="t(card.titleKey, locale)"
:style="
card.objectPosition
? { objectPosition: card.objectPosition }
: undefined
"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
autoplay
loop
@@ -134,11 +126,6 @@ function getCardClass(layoutClass: string): string {
v-else
:src="card.imageSrc"
:alt="t(card.titleKey, locale)"
:style="
card.objectPosition
? { objectPosition: card.objectPosition }
: undefined
"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
@@ -168,10 +155,14 @@ function getCardClass(layoutClass: string): string {
</div>
<p
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
>
{{ t(card.titleKey, locale) }}
</p>
<CardArrow
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
/>
</a>
</div>
</div>

View File

@@ -932,9 +932,9 @@ const translations = {
'zh-CN': '所有模型。\n商业许可保证。'
},
'cloud.reason.2.description': {
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
'zh-CN':
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
},
'cloud.reason.2.badge.onlyOn': {
en: 'ONLY ON',
@@ -996,6 +996,10 @@ const translations = {
en: 'Wan 2.2',
'zh-CN': 'Wan 2.2'
},
'cloud.aiModels.card.gptImage2': {
en: 'GPT Image 2',
'zh-CN': 'GPT Image 2'
},
'cloud.aiModels.ctaDesktop': {
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
'zh-CN': '探索最新模型工作流'

View File

@@ -248,7 +248,7 @@
@utility ppformula-text-center {
display: inline-block;
position: relative;
top: 0.19em;
top: 0.1em;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked

View File

@@ -6,7 +6,7 @@
"nodes": [
{
"id": 3,
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
@@ -20,7 +20,7 @@
},
{
"id": 4,
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
@@ -38,7 +38,7 @@
"definitions": {
"subgraphs": [
{
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"id": "outer-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
@@ -71,7 +71,7 @@
"nodes": [
{
"id": 2,
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"type": "inner-subgraph-with-promoted-missing-model",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
@@ -105,7 +105,7 @@
]
},
{
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"id": "inner-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1270,57 +1270,3 @@ test(
})
}
)
test.fail('Floating reroutes', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
const previewNodePos = { position: { x: 800, y: 200 } }
await comfyPage.searchBoxV2.addNode('Preview Image', previewNodePos)
const previewNode =
await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Create floating reroute', async () => {
const reroutePos = { targetPosition: { x: 700, y: 400 } }
await previewNode
.getSlot('images')
.first()
.dragTo(comfyPage.canvas, reroutePos)
await comfyPage.contextMenu.clickLitegraphMenuItem('Add Reroute')
await comfyPage.searchBoxV2.addNode('Load Image')
})
await test.step('Connect node on top of floating link', async () => {
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadNode
.getSlot('IMAGE')
.first()
.dragTo(previewNode.getSlot('images').first())
})
await test.step('Create node from floating reroute', async () => {
await comfyPage.canvas.dragTo(comfyPage.canvas, {
sourcePosition: { x: 680, y: 400 },
targetPosition: { x: 500, y: 500 }
})
await comfyPage.contextMenu.clickLitegraphMenuItem('LoadImage')
})
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
if (!graph || graph.links.size !== 1) return 'invalid link count'
if (graph.reroutes.size !== 1) return 'invalid reroutes count'
const linkId = graph.nodes.find((n) => n.title === 'Preview Image')
?.inputs[0].link
if (!linkId) return 'failed to resolve link id'
const rerouteId = graph.getLink(linkId)?.parentId
if (!rerouteId) return 'failed to resolve reroute id'
return graph.reroutes.has(rerouteId) && 'reroute does not exist'
}),
'old link is disconnected, reroute is part of new connection'
)
.toBe(false)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -8,32 +8,25 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
let legacyNodeId = toNodeId(10)
await test.step('setup', async () => {
const legacyNode = await comfyPage.nodeOps.addNode(
'DevToolsNodeWithLegacyWidget',
undefined,
{
x: 0,
y: 0
}
)
legacyNodeId = legacyNode.id
await comfyPage.appMode.enterAppModeWithInputs([
[String(legacyNodeId), 'legacy_widget']
])
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
})
const getWidth = async () =>
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
?.width ?? 0
const getWidth = () =>
comfyPage.page.evaluate(
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
toNodeId(10)
)
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(legacyNodeId)
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
@@ -43,8 +36,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
})
await test.step('Resize to update width', async () => {
await expect.poll(getWidth).toBeGreaterThan(0)
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')

View File

@@ -3,43 +3,31 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { TestGraphAccess } from '@e2e/types/globals'
import { toNodeId } from '@/types/nodeId'
test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
test('Should display added widgets', async ({ comfyPage }) => {
const nodeId = toNodeId(
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => (node.widgets?.length ?? 0) === 1
)
if (!node) throw new Error('Node with one widget not found')
return String(node.id)
})
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
const widgets = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-widget')
await expect(widgets).toHaveCount(1)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_a', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(2)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_b', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(3)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_c', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(4)
})
await expect(loadCheckpointNode).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {

View File

@@ -11,8 +11,6 @@ import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import type { WidgetId } from '@/types/widgetId'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
@@ -21,8 +19,6 @@ import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.v
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -33,8 +29,9 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData>
widgetIds: readonly WidgetId[]
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
action: { widget: IBaseWidget; node: LGraphNode }
}
@@ -46,8 +43,6 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const widgetValueStore = useWidgetValueStore()
const linkStore = useLinkStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
@@ -59,61 +54,49 @@ provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
const resolvedInputs = useResolvedSelectedInputs()
function ensureSelectedWidgetState(
widgetId: WidgetId,
widget: IBaseWidget
): void {
if (widgetValueStore.getWidget(widgetId)) return
widgetValueStore.registerWidget(
widgetId,
{
type: widget.type,
value: widget.value,
options: widget.options,
label: widget.label,
serialize: widget.serialize,
disabled: widget.disabled
},
deriveWidgetRenderState(widget)
)
}
function isWidgetInputLinked(node: LGraphNode, widgetName: string): boolean {
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widgetName)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
}
const mappedSelections = computed((): WidgetEntry[] => {
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
ensureSelectedWidgetState(widgetId, widget)
const fullNodeData = nodeToNodeData(node, widgetId)
if (isWidgetInputLinked(node, widget.name)) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = node.id
return [
{
key: widgetId,
persistedHeight: config?.height,
nodeData: fullNodeData,
widgetIds: [widgetId],
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode, id: WidgetId) {
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(
widgetValueStore.getWidget(id)?.value
)
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
@@ -137,8 +120,8 @@ function getDropIndicator(node: LGraphNode, id: WidgetId) {
}
}
function nodeToNodeData(node: LGraphNode, id: WidgetId) {
const dropIndicator = getDropIndicator(node, id)
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
return {
@@ -165,13 +148,7 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{
key,
persistedHeight,
nodeData,
widgetIds,
action
} in mappedSelections"
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
:key
:class="
cn(
@@ -258,7 +235,6 @@ defineExpose({ handleDragDrop })
>
<NodeWidgets
:node-data
:widget-ids
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',

View File

@@ -13,8 +13,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
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'
@@ -256,10 +256,7 @@ function clearWidgetErrors(
source.sourceWidgetName,
source.sourceWidgetName,
value,
{
min: source.sourceWidget.options?.min,
max: source.sourceWidget.options?.max
}
options
)
}

View File

@@ -2,18 +2,14 @@ import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
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 { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -208,51 +204,5 @@ describe('WidgetItem', () => {
expect(stub.value).toBe('model_a.safetensors')
})
it('passes null from widget state to the widget component', () => {
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: null,
options: {}
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.value).toBe('null')
})
it('updates disabled options when the widget input is linked', async () => {
const inputs: INodeInputSlot[] = [
{
name: 'seed',
type: 'INT',
link: null,
boundingRect: [0, 0, 0, 0],
widget: { name: 'seed' }
}
]
const node = createMockNode(
fromAny<Partial<LGraphNode>, unknown>({ inputs })
)
const widget = createMockWidget({ name: 'seed', options: {} })
const { container } = renderWidgetItem(widget, node)
expect(getStubWidget(container).options.disabled).toBeUndefined()
useLinkStore().registerLink('test-graph-id', {
id: toLinkId(1),
originNodeId: toNodeId(2),
originSlot: 0,
targetNodeId: node.id,
targetSlot: 0,
type: 'INT'
})
await nextTick()
expect(getStubWidget(container).options.disabled).toBe(true)
})
})
})

View File

@@ -3,6 +3,8 @@ import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -13,18 +15,13 @@ import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useLinkStore } from '@/stores/linkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedWidget,
WidgetValue as SimplifiedWidgetValue
} from '@/types/simplifiedWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -64,7 +61,6 @@ const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const widgetValueStore = useWidgetValueStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const linkStore = useLinkStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
@@ -73,14 +69,16 @@ const widgetComponent = computed(() => {
})
const isLinked = computed(() => {
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widget.name)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
})
const simplifiedWidget = computed((): SimplifiedWidget => {
const graphId = node.graph?.rootGraph.id
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(node.id)
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
@@ -95,9 +93,7 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
return {
name: widgetName,
type: widgetType,
value: (widgetState
? widgetState.value
: widget.value) as SimplifiedWidgetValue,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: { ...baseOptions, disabled },
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),

View File

@@ -87,7 +87,7 @@ describe('Node Reactivity', () => {
})
})
describe('Widget input link reactivity', () => {
describe('Widget slotMetadata reactivity on link disconnect', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -96,8 +96,10 @@ describe('Widget input link reactivity', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
graph.add(node)
@@ -110,22 +112,31 @@ describe('Widget input link reactivity', () => {
return { graph, node, upstream, linkId: link.id }
}
it('exposes linked widget input slots through Vue node inputs', () => {
it('sets slotMetadata.linked to true when input has a link', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('reprojects vueNodeData for the node when node:slot-links:changed fires for an input slot', () => {
it('updates slotMetadata.linked to false after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeDataBefore = vueNodeData.get(node.id)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
@@ -134,7 +145,48 @@ describe('Widget input link reactivity', () => {
linkId: 42
})
expect(vueNodeData.get(node.id)).not.toBe(nodeDataBefore)
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
@@ -153,11 +205,15 @@ describe('Widget input link reactivity', () => {
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('registers promoted widget render state separately from value state', () => {
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' }]
})
@@ -173,34 +229,23 @@ describe('Widget input link reactivity', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const id = widgetId(graph.id, subgraphNode.id, 'value')
const store = useWidgetValueStore()
const valueState = store.getWidget(id)
const renderState = store.getWidgetRenderState(id)
expect(valueState?.name).toBe('value')
expect(valueState?.value).toBe('hello')
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('reflects input/widget renames after link refresh', async () => {
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(true)
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
@@ -216,11 +261,7 @@ describe('Widget input link reactivity', () => {
await nextTick()
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(false)
expect(widgetData.slotMetadata).toBeUndefined()
})
})
@@ -327,13 +368,15 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
const id = widgetId(graph.id, subgraphNodeB.id, 'b_input')
const state = useWidgetValueStore().getWidget(id)
expect(state?.type).toBe('combo')
expect(subgraphNodeB.widgets[0]?.widgetId).toBe(id)
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
@@ -362,23 +405,27 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
const ids = subgraphNode.widgets.map((widget) => widget.widgetId)
expect(ids).toStrictEqual([
widgetId(graph.id, subgraphNode.id, 'first_seed'),
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'second_seed')
])
expect(ids[0]).not.toBe(ids[1])
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
})
})
describe('Promoted widget render state', () => {
describe('Promoted widget sourceExecutionId', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('registers plain render metadata for promoted widgets', () => {
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
@@ -404,21 +451,22 @@ describe('Promoted widget render state', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
useGraphNodeManager(graph)
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, subgraphNode.id, 'ckpt_input')
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
)
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(renderState).not.toHaveProperty('sourceExecutionId')
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(
`${subgraphNode.id}:${interiorNode.id}`
)
})
it('registers plain render metadata for non-promoted widgets', () => {
it('does not set sourceExecutionId for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
@@ -426,14 +474,12 @@ describe('Promoted widget render state', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, node.id, 'steps')
)
expect(renderState).toBeDefined()
expect(renderState).not.toHaveProperty('sourceExecutionId')
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
})
})

View File

@@ -1,6 +1,14 @@
/**
* Vue node lifecycle management for LiteGraph integration
* 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 { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -11,6 +19,16 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
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 { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
@@ -18,13 +36,70 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerParam,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
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
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the host
* subgraph node. Retained for source-scoped validation errors.
*/
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name` is
* the host input slot name and the source widget name can differ.
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
}
export interface VueNodeData {
executing: boolean
id: NodeId
@@ -49,24 +124,260 @@ export interface VueNodeData {
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
}
function makeReactiveNodeArrays(node: LGraphNode): {
inputs: INodeInputSlot[]
outputs: INodeOutputSlot[]
} {
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
spec?: InputSpec
}
function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
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?: NodeExecutionId
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 source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (!source) return undefined
ensurePromotedHostWidgetState(
source.input.widgetId,
source.input,
source.sourceWidget
)
return {
controlWidget: getControlWidget(source.sourceWidget),
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
sourceExecutionId: source.sourceExecutionId,
sourceWidgetName: source.sourceWidgetName
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
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)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
}
}
}
}
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
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
'widgets'
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
@@ -95,7 +406,6 @@ function makeReactiveNodeArrays(node: LGraphNode): {
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
@@ -107,7 +417,6 @@ function makeReactiveNodeArrays(node: LGraphNode): {
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
@@ -120,16 +429,19 @@ function makeReactiveNodeArrays(node: LGraphNode): {
enumerable: true
})
return { inputs: reactiveInputs, outputs: reactiveOutputs }
}
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
export function extractVueNodeData(node: LGraphNode): VueNodeData {
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
const { inputs, outputs } = makeReactiveNodeArrays(node)
const nodeType =
node.type ||
node.constructor?.comfyClass ||
@@ -137,6 +449,9 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
@@ -144,13 +459,14 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false,
executing: false, // Will be updated separately based on execution state
subgraphId,
apiNode: node.constructor?.nodeData?.api_node ?? false,
badges: node.badges,
apiNode,
badges,
hasErrors: !!node.has_errors,
inputs,
outputs,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -161,26 +477,39 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<NodeId, LGraphNode>()
const refreshNodeInputs = (nodeId: NodeId) => {
const refreshNodeSlots = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef?.inputs || !currentData) return
nodeRef.inputs = [...nodeRef.inputs]
vueNodeData.set(nodeId, { ...currentData, inputs: nodeRef.inputs })
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
}
}
const getNode = (id: NodeId): LGraphNode | undefined => nodeRefs.get(id)
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
@@ -188,49 +517,76 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
position: nodePosition,
size: nodeSize,
zIndex: node.order || 0,
visible: true
})
}
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
@@ -247,13 +603,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) => {
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
deleteNode(id)
void deleteNode(id)
dropNodeReferences(id)
for (const nodeId of nodeRefs.keys()) refreshNodeInputs(nodeId)
originalCallback?.(node)
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
@@ -261,6 +620,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
@@ -270,16 +630,19 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
@@ -398,12 +761,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeInputs(toNodeId(slotErrorsEvent.nodeId))
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
},
// NodeSlots.linkedWidgetedInputs and usePartitionedBadges read vueNodeData.inputs[].link and need reprojection on link change; remove once those migrate to useLinkStore
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeInputs(toNodeId(slotLinksEvent.nodeId))
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
@@ -411,12 +773,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}
@@ -436,9 +802,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
return createCleanupFunction(
@@ -449,8 +817,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {

View File

@@ -10,6 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { app as comfyApp } from '@/scripts/app'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -43,6 +44,22 @@ function useVueNodeLifecycleIndividual() {
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
}
// Seed existing links into the Layout Store (topology only)
for (const link of activeGraph._links.values()) {
if (
link.origin_id === UNASSIGNED_NODE_ID ||
link.target_id === UNASSIGNED_NODE_ID
)
continue
layoutMutations.createLink(
link.id,
link.origin_id,
link.origin_slot,
link.target_id,
link.target_slot
)
}
// Start sync AFTER seeding so bootstrap operations don't trigger
// the Layout→LiteGraph writeback loop redundantly.
startSync(canvasStore.canvas)

View File

@@ -145,13 +145,6 @@ function applySubgraphInputOrder(
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
useWidgetValueStore().setNodeWidgetOrder(
subgraphNode.rootGraph.id,
subgraphNode.id,
subgraphNode.inputs.flatMap((input) =>
input.widgetId ? [input.widgetId] : []
)
)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
@@ -288,26 +281,22 @@ function seedNestedPromotedInputState(
)
if (!hostInput || hostInput.widgetId) return
const store = useWidgetValueStore()
const sourceState = store.getWidget(sourceSlot.widgetId)
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
store.registerWidget(
id,
{
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled
},
store.getWidgetRenderState(sourceSlot.widgetId) ?? {}
)
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(

View File

@@ -11,10 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import {
getWidgetIds,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/utils/widget'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -51,16 +48,6 @@ type AutogrowNode = LGraphNode &
}
}
function syncNodeWidgetOrder(node: LGraphNode) {
const graphId = resolveNodeRootGraphId(node)
if (!graphId || !node.widgets) return
useWidgetValueStore().setNodeWidgetOrder(
graphId,
node.id,
getWidgetIds(node.widgets)
)
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
node.widgets ??= []
const { widget } = input
@@ -118,10 +105,7 @@ function dynamicComboWidget(
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) {
syncNodeWidgetOrder(node)
return
}
if (!newSpec) return
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
@@ -156,7 +140,6 @@ function dynamicComboWidget(
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
syncNodeWidgetOrder(node)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
@@ -558,11 +541,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
for (const widget of remove(node.widgets, (w) => w.name === widgetName)) {
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
widget.onRemove?.()
if (widget.widgetId) useWidgetValueStore().deleteWidget(widget.widgetId)
}
syncNodeWidgetOrder(node)
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

@@ -1,5 +1,3 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -63,8 +61,6 @@ async function createNodeWithFilenamePrefix(
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },

View File

@@ -12,13 +12,9 @@ import {
Reroute,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
SerialisableGraph,
SerialisableLLink
} from '@/lib/litegraph/src/types/serialisation'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { useLinkStore } from '@/stores/linkStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toLinkId } from '@/types/linkId'
@@ -263,64 +259,6 @@ describe('Floating Links / Reroutes', () => {
})
})
describe('Link serialization goldens (ADR-0008 topology-store migration)', () => {
const LINK_KEYS = [
'id',
'origin_id',
'origin_slot',
'target_id',
'target_slot',
'type'
]
function expectContractKeyOrder(link: SerialisableLLink) {
const expectedKeys =
link.parentId === undefined ? LINK_KEYS : [...LINK_KEYS, 'parentId']
expect(Object.keys(link)).toEqual(expectedKeys)
}
test('plain links keep contract key order and round-trip byte-identically', ({
expect,
linkedNodesGraph
}) => {
const first = new LGraph(linkedNodesGraph).asSerialisable()
const second = new LGraph(first).asSerialisable()
expect(first.links?.length).toBeGreaterThan(0)
for (const link of first.links ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
test('reroute-chain links keep contract key order and round-trip byte-identically', ({
expect,
reroutesComplexGraph
}) => {
const first = reroutesComplexGraph.asSerialisable()
const second = new LGraph(first).asSerialisable()
const chainedLinks = (first.links ?? []).filter(
(link) => link.parentId !== undefined
)
expect(chainedLinks.length).toBeGreaterThan(0)
for (const link of first.links ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
test('floating links keep contract key order and round-trip byte-identically', ({
expect,
floatingLinkGraph
}) => {
const first = new LGraph(floatingLinkGraph).asSerialisable()
const second = new LGraph(first).asSerialisable()
expect(first.floatingLinks?.length).toBeGreaterThan(0)
for (const link of first.floatingLinks ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.floatingLinks)).toBe(
JSON.stringify(first.floatingLinks)
)
})
})
describe('Graph Clearing and Callbacks', () => {
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
expect
@@ -861,8 +799,6 @@ describe('ensureGlobalIdUniqueness', () => {
})
describe('_removeDuplicateLinks', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
class TestNode extends LGraphNode {
constructor(title?: string) {
super(title ?? 'TestNode')
@@ -894,7 +830,7 @@ describe('_removeDuplicateLinks', () => {
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
graph.state.lastLinkId = linkId
const dup = new LLink(linkId, 'number', source.id, 0, target.id, 0)
graph._addLink(dup)
graph._links.set(dup.id, dup)
source.outputs[0].links!.push(dup.id)
return dup
}
@@ -928,20 +864,6 @@ describe('_removeDuplicateLinks', () => {
expect(graph._links.has(dupLink.id)).toBe(false)
})
it('drops purged duplicates from the link store and keeps the survivor indexed', () => {
const { graph, source, target } = createConnectedGraph()
const keptLinkId = target.inputs[0].link!
const dup = injectDuplicateLink(graph, source, target)
graph._removeDuplicateLinks()
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(dup._graphId).toBeUndefined()
expect(store.getInputSlotLink(graphId, target.id, 0)?.id).toBe(keptLinkId)
})
it('keeps the valid link when input.link is at a shifted slot index', () => {
const { graph, source, target } = createConnectedGraph()
const validLinkId = target.inputs[0].link!

View File

@@ -8,11 +8,9 @@ import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/utils/uuid'
import { createUuidv4, zeroUuid } from '@/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { useLinkStore } from '@/stores/linkStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, parseNodeId, toNodeId } from '@/types/nodeId'
@@ -31,12 +29,7 @@ import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import type { GroupId } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import {
LLink,
registerLinkTopology,
unregisterAllLinkTopologies,
unregisterLinkTopology
} from './LLink'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
import { MapProxyHandler } from './MapProxyHandler'
import { Reroute } from './Reroute'
@@ -66,7 +59,6 @@ import {
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -403,11 +395,6 @@ export class LGraph
if (this.isRootGraph && graphId !== zeroUuid) {
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
useLinkStore().clearGraph(graphId)
} else {
// Subgraphs and unconfigured (zero-uuid) graphs share their store
// bucket with other graphs, so unregister each link individually.
unregisterAllLinkTopologies(this)
}
this.id = zeroUuid
@@ -1019,15 +1006,9 @@ export class LGraph
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
const widgetValueStore = useWidgetValueStore()
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
widgetValueStore.setNodeWidgetOrder(
this.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}
this._nodes.push(node)
@@ -1131,7 +1112,6 @@ export class LGraph
if (!hasRemainingReferences) {
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
unregisterAllLinkTopologies(node.subgraph)
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}
@@ -1442,7 +1422,6 @@ export class LGraph
link.id = toLinkId(++this._lastFloatingLinkId)
}
this.floatingLinksInternal.set(link.id, link)
registerLinkTopology(this, link)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
@@ -1466,7 +1445,6 @@ export class LGraph
removeFloatingLink(link: LLink): void {
this.floatingLinksInternal.delete(link.id)
unregisterLinkTopology(link)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
@@ -1487,30 +1465,6 @@ export class LGraph
}
}
/**
* Adds a link to this graph's {@link _links} map and registers its topology
* with the link store. The single entry point for populating {@link _links};
* routing every add through here keeps the store from silently desyncing.
*/
_addLink(link: LLink): void {
this._links.set(link.id, link)
registerLinkTopology(this, link)
}
/**
* Removes a link from this graph's {@link _links} map and unregisters it
* from the link and layout stores. The delete-side counterpart to
* {@link _addLink}; routing every removal through here keeps the stores
* from silently desyncing.
*/
_removeLink(linkId: LinkId): void {
const link = this._links.get(linkId)
if (!link) return
this._links.delete(linkId)
unregisterLinkTopology(link)
layoutStore.deleteLinkLayout(linkId)
}
/**
* Finds the link with the provided ID.
* @param id ID of link to find
@@ -1713,7 +1667,9 @@ export class LGraph
const node = this.getNodeById(sampleLink.target_id)
const keepId = selectSurvivorLink(ids, node)
purgeOrphanedLinks(ids, keepId, this)
purgeOrphanedLinks(ids, keepId, this._links, (id) =>
this.getNodeById(toNodeId(id))
)
repairInputLinks(ids, keepId, node)
}
}
@@ -1952,7 +1908,7 @@ export class LGraph
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
link.origin_id = subgraphNode.id
link.origin_slot = i - 1
this._addLink(link)
this.links.set(link.id, link)
if (subgraphOutput instanceof SubgraphOutput) {
subgraphOutput.connect(
subgraphNode.findOutputSlotByType(link.type, true, true),
@@ -2539,7 +2495,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.createFromArray(linkData)
this._addLink(link)
this._links.set(link.id, link)
}
}
// #region `extra` embeds for v0.4
@@ -2580,7 +2536,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.create(linkData)
this._addLink(link)
this._links.set(link.id, link)
}
}

View File

@@ -93,7 +93,7 @@ describe('drawConnections widget-input slot positioning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
setActivePinia(createTestingPinia())
canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

@@ -9,7 +9,6 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { adjustColor } from '@/utils/colorUtil'
@@ -97,7 +96,6 @@ import type {
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -2057,20 +2055,6 @@ export class LGraphNode
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
const graphId = this.graph?.rootGraph.id
if (graphId) {
const widgetValueStore = useWidgetValueStore()
// Drop the widget from the render order but keep its stored value, so a
// remove-then-re-add of the same widget id preserves what the user set.
if (widget.widgetId)
widgetValueStore.removeNodeWidgetOrder(widget.widgetId)
widgetValueStore.setNodeWidgetOrder(
graphId,
this.id,
getWidgetIds(this.widgets)
)
}
}
ensureWidgetRemoved(widget: IBaseWidget): void {
@@ -2907,6 +2891,8 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
const layoutMutations = useLayoutMutations()
const outputIndex = this.outputs.indexOf(output)
if (outputIndex === -1) {
console.warn('connectSlots: output not found')
@@ -2969,7 +2955,17 @@ export class LGraphNode
)
// add to graph links list
graph._addLink(link)
graph._links.set(link.id, link)
// Register link in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createLink(
link.id,
this.id,
outputIndex,
inputNode.id,
inputIndex
)
// connect in output
output.links ??= []

View File

@@ -1,198 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { registerLinkTopology } from './LLink'
import {
createTestSubgraph,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
describe('LLink ↔ linkStore integration', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('connect registers, disconnect removes', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
expect(store.isInputSlotConnected(graph.rootGraph.id, b.id, 0)).toBe(true)
graph.removeLink(link.id)
expect(store.isInputSlotConnected(graph.rootGraph.id, b.id, 0)).toBe(false)
})
it('keeps writing to a disconnected link after it leaves the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
graph.removeLink(link.id)
expect(() => {
link.target_slot = 3
}).not.toThrow()
expect(link.target_slot).toBe(3)
})
it('keeps the winner registered when a colliding loser link disconnects', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const winner = a.connect(0, b, 0)!
const loser = new LLink(winner.id, 'INT', a.id, 0, b.id, 0)
registerLinkTopology(graph, loser)
loser.disconnect(graph)
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(winner.id)
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(true)
})
it('unregisters a subgraph definitions links when its last instance is removed', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
const rootGraph = subgraph.rootGraph
const subgraphNode = createTestSubgraphNode(subgraph)
rootGraph.add(subgraphNode)
const store = useLinkStore()
expect(store.getInputSlotLink(rootGraph.id, second.id, 0)?.id).toBe(
innerLink.id
)
rootGraph.remove(subgraphNode)
expect(store.isInputSlotConnected(rootGraph.id, second.id, 0)).toBe(false)
})
it('keeps a subgraph definitions links registered while other instances remain', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
const rootGraph = subgraph.rootGraph
const keptInstance = createTestSubgraphNode(subgraph)
const removedInstance = createTestSubgraphNode(subgraph, { id: 99 })
rootGraph.add(keptInstance)
rootGraph.add(removedInstance)
rootGraph.remove(removedInstance)
const store = useLinkStore()
expect(store.getInputSlotLink(rootGraph.id, second.id, 0)?.id).toBe(
innerLink.id
)
})
it('clearing a subgraph unregisters its links but keeps root links', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const rootGraph = subgraph.rootGraph
const [first, second] = subgraph.nodes
first.connect(0, second, 0)
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', '*')
b.addInput('in', '*')
rootGraph.add(a)
rootGraph.add(b)
const rootLink = a.connect(0, b, 0)!
subgraph.clear()
const store = useLinkStore()
expect(store.isInputSlotConnected(rootGraph.id, second.id, 0)).toBe(false)
expect(store.getInputSlotLink(rootGraph.id, b.id, 0)?.id).toBe(rootLink.id)
})
it('clear() unregisters an unconfigured graphs links from the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const graphId = graph.rootGraph.id
const store = useLinkStore()
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(link.id)
graph.clear()
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(false)
})
it('detaches a floating link from the store when it is removed', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addOutput('out', '*')
graph.add(a)
const floating = new LLink(
toLinkId(7),
'*',
a.id,
0,
UNASSIGNED_NODE_ID,
-1
)
graph.addFloatingLink(floating)
const graphId = graph.rootGraph.id
expect(floating._graphId).toBe(graphId)
graph.removeFloatingLink(floating)
expect(floating._graphId).toBeUndefined()
floating.origin_slot = 5
expect(floating.origin_slot).toBe(5)
})
it('moving a link via target_slot reindexes the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
const nodeId = b.id
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 0)).toBe(true)
link.target_slot = 1
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 0)).toBe(
false
)
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 1)).toBe(true)
})
})

View File

@@ -2,7 +2,6 @@ import { describe, expect } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { test } from './__fixtures__/testExtensions'
@@ -22,17 +21,4 @@ describe('LLink', () => {
expect(link.hasOrigin(4, 2)).toBe(true)
expect(link.hasTarget(5, 3)).toBe(true)
})
test('exposes topology fields backed by a single _state object', () => {
const link = new LLink(toLinkId(1), 'INT', 5, 0, 9, 2)
expect(link.origin_id).toBe(toNodeId(5))
link.target_slot = 4
expect(link._state.targetSlot).toBe(4)
expect(link.asSerialisable()).toMatchObject({
id: toLinkId(1),
origin_id: 5,
target_slot: 4,
type: 'INT'
})
})
})

View File

@@ -5,19 +5,13 @@ import {
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import type { EndpointPatch } from '@/stores/linkStore'
import type { LinkId } from '@/types/linkId'
import type { LinkTopology } from '@/types/linkTopology'
import type { RerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import type { Reroute } from './Reroute'
@@ -99,89 +93,22 @@ type BasicReadonlyNetwork = Pick<
'getNodeById' | 'links' | 'getLink' | 'inputNode' | 'outputNode'
>
/** Routes an endpoint patch through {@link useLinkStore} if the link is registered, otherwise writes {@link LLink._state} directly. */
function applyEndpointPatch(link: LLink, patch: EndpointPatch): void {
if (link._graphId) {
const registered = useLinkStore().updateEndpoint(
link._graphId,
link._state,
patch
)
if (!registered) link._graphId = undefined
} else {
Object.assign(link._state, patch)
}
}
// this is the class in charge of storing link information
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
static _drawDebug = false
readonly _state: LinkTopology
/** The graph this link is registered with in {@link useLinkStore}, if any. */
_graphId?: UUID
/** Link ID */
get id() {
return this._state.id
}
set id(value: LinkId) {
this._state.id = value
}
get type() {
return this._state.type
}
set type(value: ISlotType) {
this._state.type = value
}
id: LinkId
parentId?: RerouteId
type: ISlotType
/** Output node ID */
get origin_id() {
return this._state.originNodeId
}
set origin_id(value: NodeId) {
applyEndpointPatch(this, { originNodeId: value })
}
origin_id: NodeId
/** Output slot index */
get origin_slot() {
return this._state.originSlot
}
set origin_slot(value: number) {
applyEndpointPatch(this, { originSlot: value })
}
origin_slot: number
/** Input node ID */
get target_id() {
return this._state.targetNodeId
}
set target_id(value: NodeId) {
applyEndpointPatch(this, { targetNodeId: value })
}
target_id: NodeId
/** Input slot index */
get target_slot() {
return this._state.targetSlot
}
set target_slot(value: number) {
applyEndpointPatch(this, { targetSlot: value })
}
get parentId() {
return this._state.parentId
}
set parentId(value: RerouteId | undefined) {
this._state.parentId = value
}
target_slot: number
data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown
@@ -238,15 +165,13 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
target_slot: number,
parentId?: RerouteId
) {
this._state = {
id,
type,
originNodeId: toNodeId(origin_id),
originSlot: origin_slot,
targetNodeId: toNodeId(target_id),
targetSlot: target_slot,
parentId
}
this.id = id
this.type = type
this.origin_id = toNodeId(origin_id)
this.origin_slot = origin_slot
this.target_id = toNodeId(target_id)
this.target_slot = target_slot
this.parentId = parentId
this._data = null
// center
@@ -547,8 +472,9 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
}
}
network.links.delete(this.id)
unregisterLinkTopology(this)
layoutStore.deleteLinkLayout(this.id)
// Delete link from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteLink(this.id)
}
/**
@@ -579,50 +505,3 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
return copy
}
}
/**
* Registers a link's topology into {@link useLinkStore} by reference, so the
* store and {@link LLink._state} always agree. Call this at every site that
* adds a link to a graph's link map (or floating link map).
*
* {@link LLink._graphId} is only set when the store keeps this link's state:
* a link that loses a first-wins id collision stays detached, so its writes
* and removal cannot corrupt the winner's registration.
* @param graph The graph (or subgraph) the link belongs to
* @param link The link to register
*/
export function registerLinkTopology(
graph: Pick<LGraph, 'rootGraph'>,
link: LLink
): void {
if (link.id === toLinkId(-1)) return // transient toFloating clone
const graphId = graph.rootGraph.id
if (useLinkStore().registerLink(graphId, link._state)) {
link._graphId = graphId
}
}
/**
* Removes a link's topology from {@link useLinkStore} and detaches the link.
* No-op for links that never won registration ({@link LLink._graphId} unset),
* so a first-wins collision loser cannot remove the winner's entry.
* @param link The link to unregister
*/
export function unregisterLinkTopology(link: LLink): void {
if (!link._graphId) return
useLinkStore().deleteLink(link._graphId, link._state)
link._graphId = undefined
}
/**
* Unregisters every link and floating link a graph owns. Used when a graph's
* links leave the store without a whole-bucket wipe: subgraph-definition
* removal, and clearing a graph that shares its bucket with other graphs.
* @param graph The graph whose links should be unregistered
*/
export function unregisterAllLinkTopologies(
graph: Pick<LGraph, 'links' | 'floatingLinks'>
): void {
for (const link of graph.links.values()) unregisterLinkTopology(link)
for (const link of graph.floatingLinks.values()) unregisterLinkTopology(link)
}

View File

@@ -1,7 +1,5 @@
// oxlint-disable no-empty-pattern
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, describe, expect, vi } from 'vitest'
import type {
@@ -23,7 +21,6 @@ import {
} from '@/utils/__tests__/litegraphTestUtils'
interface TestContext {
pinia: undefined
graph: LGraph
connector: LinkConnector
setConnectingLinks: (value: ConnectingLink[]) => void
@@ -40,14 +37,6 @@ interface TestContext {
}
const test = baseTest.extend<TestContext>({
pinia: [
async ({}, use) => {
setActivePinia(createTestingPinia({ stubActions: false }))
await use(undefined)
},
{ auto: true }
],
reroutesBeforeTest: async ({ reroutesComplexGraph }, use) => {
await use([...reroutesComplexGraph.reroutes])
},

View File

@@ -1,6 +1,4 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -33,7 +31,6 @@ describe('LinkConnector SubgraphInput connection validation', () => {
const mockSetConnectingLinks = vi.fn()
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
connector = new LinkConnector(mockSetConnectingLinks)
vi.clearAllMocks()
})

View File

@@ -1,7 +1,5 @@
import { registerLinkTopology } from './LLink'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import type { SerializedNodeId } from '@/types/nodeId'
import type { LLink, LinkId } from './LLink'
/** Generates a unique string key for a link's connection tuple. */
@@ -41,23 +39,20 @@ export function selectSurvivorLink(
return ids[0]
}
/**
* Removes duplicate links from origin outputs and the graph, routing map
* removal through {@link LGraph._removeLink} so the link and layout stores
* stay in sync.
*/
/** Removes duplicate links from origin outputs and the graph's link map. */
export function purgeOrphanedLinks(
ids: LinkId[],
keepId: LinkId,
graph: LGraph
links: Map<LinkId, LLink>,
getNodeById: (id: SerializedNodeId) => LGraphNode | null
): void {
for (const id of ids) {
if (id === keepId) continue
const link = graph._links.get(id)
const link = links.get(id)
if (!link) continue
const originNode = graph.getNodeById(link.origin_id)
const originNode = getNodeById(link.origin_id)
const output = originNode?.outputs?.[link.origin_slot]
if (output?.links) {
for (let i = output.links.length - 1; i >= 0; i--) {
@@ -65,13 +60,8 @@ export function purgeOrphanedLinks(
}
}
graph._removeLink(id)
links.delete(id)
}
// Purging a duplicate that owned the survivor's target-slot index entry
// removes that entry, so re-assert the survivor's registration afterwards.
const survivor = graph._links.get(keepId)
if (survivor) registerLinkTopology(graph, survivor)
}
/** Ensures input.link on the target node points to the surviving link. */

View File

@@ -1,6 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from './litegraph'
import type { NodeInputSlot } from './node/NodeInputSlot'
@@ -16,8 +14,6 @@ class TestNode extends LGraphNode {
LiteGraph.registerNodeType('test/TestNode', TestNode)
describe('Serialization - Circular Reference Prevention', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraph.toJSON()', () => {
it('should serialize without circular reference errors', () => {
const graph = new LGraph()

View File

@@ -114,7 +114,7 @@ export class SubgraphInput extends SubgraphSlot {
)
// Add to graph links list
subgraph._addLink(link)
subgraph._links.set(link.id, link)
// Set link ID in each slot
this.linkIds.push(link.id)

View File

@@ -32,7 +32,6 @@ import type {
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
@@ -644,20 +643,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
const store = useWidgetValueStore()
input.widgetId = id
store.registerWidget(
id,
{
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled
},
deriveWidgetRenderState(interiorWidget)
)
useWidgetValueStore().registerWidget(id, {
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled,
isDOMWidget:
'isDOMWidget' in interiorWidget &&
typeof interiorWidget.isDOMWidget === 'boolean'
? interiorWidget.isDOMWidget
: undefined
})
input._widget =
this.createPromotedHostWidget(input, id, interiorWidget) ??
this._projectPromotedWidget(input)

View File

@@ -78,7 +78,7 @@ export class SubgraphOutput extends SubgraphSlot {
)
// Add to graph links list
subgraph._addLink(link)
subgraph._links.set(link.id, link)
// Set link ID in each slot
this.linkIds[0] = link.id

View File

@@ -500,36 +500,6 @@ describe('SubgraphSerialization - Data Integrity', () => {
}
})
it('serializes interior links with contract key order and round-trips byte-identically', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
const nodeA = createRegisteredNode(subgraph, [], ['number'], 'A')
const nodeB = createRegisteredNode(subgraph, ['number'], ['string'], 'B')
const nodeC = createRegisteredNode(subgraph, ['string'], [], 'C')
nodeA.connect(0, nodeB, 0)
nodeB.connect(0, nodeC, 0)
const first = subgraph.asSerialisable()
expect(first.links?.length).toBe(2)
for (const link of first.links ?? []) {
expect(Object.keys(link)).toEqual([
'id',
'origin_id',
'origin_slot',
'target_id',
'target_slot',
'type'
])
}
const restored = new Subgraph(new LGraph(), first)
restored.configure(first)
const second = restored.asSerialisable()
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
it('deduplicates duplicate subgraph node IDs while keeping root nodes canonical', () => {
const graph = new LGraph()
graph.configure(structuredClone(duplicateSubgraphNodeIds))

View File

@@ -8,7 +8,7 @@ import type {
SerialisableLLink
} from '../types/serialisation'
const MAX_ID = 100_000_000
const MAX_NODE_ID = 100_000_000
interface DeduplicationResult {
subgraphs: ExportedSubgraph[]
@@ -81,7 +81,7 @@ function remapNodeIds(
const numericId = numericSerializedNodeId(id)
if (usedNodeIdKeys.has(key)) {
const newId = findNextAvailableId(usedNodeIds, () => ++state.lastNodeId)
const newId = findNextAvailableId(usedNodeIds, state)
remappedIds.set(key, newId)
node.id = newId
usedNodeIds.add(newId)
@@ -101,7 +101,10 @@ function remapNodeIds(
return remappedIds
}
/** Parses a serialized node ID as an integer, or `null` when non-numeric. */
/**
* Finds the next unused node ID by incrementing `state.lastNodeId`.
* Throws if the ID space is exhausted.
*/
function numericSerializedNodeId(id: SerializedNodeId): number | null {
const key = toNodeId(id)
const numericId = Number(key)
@@ -110,20 +113,17 @@ function numericSerializedNodeId(id: SerializedNodeId): number | null {
: null
}
/**
* Finds the next unused ID by repeatedly calling `advance`.
* Throws if the ID space is exhausted.
*/
function findNextAvailableId(
usedIds: Set<number>,
advance: () => number
usedNodeIds: Set<number>,
state: LGraphState
): number {
while (true) {
const nextId = advance()
if (nextId > MAX_ID) {
const nextId = state.lastNodeId + 1
if (nextId > MAX_NODE_ID) {
throw new Error('Node ID space exhausted')
}
if (!usedIds.has(nextId)) return nextId
state.lastNodeId = nextId
if (!usedNodeIds.has(nextId)) return nextId
}
}

View File

@@ -1,10 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { WidgetRenderState } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/utils/uuid'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
@@ -29,35 +24,6 @@ export function evaluateInput(input: string): number | undefined {
return newValue
}
export function getWidgetIds(
widgets: readonly { readonly widgetId?: WidgetId }[]
): WidgetId[] {
return widgets
.map((widget) => widget.widgetId)
.filter((id): id is WidgetId => id !== undefined)
}
function isDOMBackedWidget(widget: Readonly<IBaseWidget>): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
export function deriveWidgetRenderState(
widget: Readonly<IBaseWidget>
): WidgetRenderState {
return {
advanced: widget.options?.advanced ?? widget.advanced,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(widget),
tooltip: widget.tooltip
}
}
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>
): UUID | undefined

View File

@@ -4,15 +4,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
INumericWidget
} from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import type {
DrawWidgetOptions,
WidgetEventOptions
} from '@/lib/litegraph/src/widgets/BaseWidget'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toNodeId } from '@/types/nodeId'
@@ -35,28 +27,6 @@ function createTestWidget(
)
}
class MutableTypeWidget extends BaseWidget<IBaseWidget<number, string>> {
drawWidget(
_ctx: CanvasRenderingContext2D,
_options: DrawWidgetOptions
): void {}
onClick(_options: WidgetEventOptions): void {}
}
function createMutableTypeWidget(node: LGraphNode): MutableTypeWidget {
return new MutableTypeWidget(
{
type: 'number',
name: 'typeChangedWidget',
value: 42,
options: { min: 0, max: 100 },
y: 0
},
node
)
}
describe('BaseWidget store integration', () => {
let graph: LGraph
let node: LGraphNode
@@ -205,31 +175,6 @@ describe('BaseWidget store integration', () => {
store.getWidget(widgetId(graph.id, toNodeId(1), 'valuesWidget'))?.value
).toBe(77)
})
it('registers the live widget type', () => {
const widget = createMutableTypeWidget(node)
widget.type = 'number-custom'
widget.setNodeId(toNodeId(1))
expect(
store.getWidget(widgetId(graph.id, toNodeId(1), 'typeChangedWidget'))
?.type
).toBe('number-custom')
})
it('stores explicit isDOMWidget false over component presence', () => {
const widget = createTestWidget(node, { name: 'flaggedDomWidget' })
Object.assign(widget, { component: {}, isDOMWidget: false })
widget.setNodeId(toNodeId(1))
expect(
store.getWidgetRenderState(
widgetId(graph.id, toNodeId(1), 'flaggedDomWidget')
)?.isDOMWidget
).toBe(false)
})
})
describe('DOM widget value registration', () => {

View File

@@ -17,7 +17,6 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
@@ -148,16 +147,12 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
const store = useWidgetValueStore()
const id = widgetId(graphId, nodeId, this.name)
this._state = store.registerWidget(
id,
this._state = useWidgetValueStore().registerWidget(
widgetId(graphId, nodeId, this.name),
{
...this._state,
type: this.type,
value: this.value
},
deriveWidgetRenderState(this)
}
)
}

View File

@@ -30,6 +30,90 @@ describe('preservedQueryManager', () => {
expect(sessionStorage.getItem('Comfy.PreservedQuery.template')).toBeTruthy()
})
it('merges newly captured keys into the payload when merge is set', () => {
capturePreservedQuery(
NAMESPACE,
{ template: 'flux' },
['template', 'source', 'mode'],
{ merge: true }
)
capturePreservedQuery(
NAMESPACE,
{ source: 'custom' },
['template', 'source', 'mode'],
{ merge: true }
)
const merged = mergePreservedQueryIntoQuery(NAMESPACE)
expect(merged).toEqual({ template: 'flux', source: 'custom' })
})
it('replaces the whole payload on capture by default', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux', source: 'custom' }, [
'template',
'source',
'mode'
])
capturePreservedQuery(NAMESPACE, { template: 'sdxl' }, [
'template',
'source',
'mode'
])
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
template: 'sdxl'
})
})
it('leaves the payload untouched when a default capture has no valid values', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
capturePreservedQuery(NAMESPACE, { template: '' }, ['template'])
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
})
it('captures the first non-empty string element of an array-valued param', () => {
capturePreservedQuery(NAMESPACE, { template: ['', 'flux', 'sdxl'] }, [
'template'
])
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
})
it('does not stash empty, null, or all-junk array values', () => {
capturePreservedQuery(
NAMESPACE,
{ template: '', source: null, mode: ['', null] },
['template', 'source', 'mode']
)
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
expect(getPreservedQueryParam(NAMESPACE, 'source')).toBeUndefined()
expect(getPreservedQueryParam(NAMESPACE, 'mode')).toBeUndefined()
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toBeUndefined()
})
it('removes a preserved key on empty value when merge is set', () => {
capturePreservedQuery(
NAMESPACE,
{ template: 'flux', source: 'custom' },
['template', 'source'],
{ merge: true }
)
capturePreservedQuery(NAMESPACE, { template: '' }, ['template', 'source'], {
merge: true
})
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
source: 'custom'
})
})
it('reads a preserved query param by key', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
@@ -78,6 +162,16 @@ describe('preservedQueryManager', () => {
expect(merged).toBeUndefined()
})
it('overwrites an array-valued live query key with the stashed string', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
const merged = mergePreservedQueryIntoQuery(NAMESPACE, {
template: ['existing', 'other']
})
expect(merged).toEqual({ template: 'flux' })
})
it('clears cached payload', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])

View File

@@ -4,7 +4,12 @@ const STORAGE_PREFIX = 'Comfy.PreservedQuery.'
const preservedQueries = new Map<string, Record<string, string>>()
const readQueryParam = (value: unknown): string | undefined => {
return typeof value === 'string' ? value : undefined
if (typeof value === 'string') return value
if (!Array.isArray(value)) return undefined
return value.find(
(entry: unknown): entry is string =>
typeof entry === 'string' && entry !== ''
)
}
const getStorageKey = (namespace: string) => `${STORAGE_PREFIX}${namespace}`
@@ -65,25 +70,65 @@ export const hydratePreservedQuery = (namespace: string) => {
}
}
/**
* By default each capture replaces the namespace stash with the values present
* in the given query. With `merge`, values are merged into the existing stash
* and a key supplied with an empty value clears its stashed entry — for
* namespaces where the stash, not the URL, is the surviving carrier.
*/
export const capturePreservedQuery = (
namespace: string,
query: LocationQuery,
keys: string[]
keys: string[],
{ merge = false }: { merge?: boolean } = {}
) => {
const payload: Record<string, string> = {}
keys.forEach((key) => {
const value = readQueryParam(query[key])
if (value) {
payload[key] = value
if (!merge) {
const payload: Record<string, string> = {}
keys.forEach((key) => {
const value = readQueryParam(query[key])
if (value) {
payload[key] = value
}
})
if (Object.keys(payload).length === 0) {
return
}
})
if (Object.keys(payload).length === 0) {
preservedQueries.set(namespace, payload)
writeToStorage(namespace, payload)
return
}
preservedQueries.set(namespace, payload)
hydratePreservedQuery(namespace)
const payload: Record<string, string> = {
...(preservedQueries.get(namespace) ?? {})
}
let changed = false
keys.forEach((key) => {
if (!Object.hasOwn(query, key)) return
const value = readQueryParam(query[key])
if (value) {
payload[key] = value
changed = true
return
}
if (key in payload) {
delete payload[key]
changed = true
}
})
if (!changed) {
return
}
if (Object.keys(payload).length === 0) {
preservedQueries.delete(namespace)
} else {
preservedQueries.set(namespace, payload)
}
writeToStorage(namespace, payload)
}

View File

@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Router, RouterHistory } from 'vue-router'
import { createMemoryHistory, createRouter } from 'vue-router'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
const STRIPPED_NAMESPACE = 'test_strip'
const SECOND_STRIPPED_NAMESPACE = 'test_strip_b'
const PLAIN_NAMESPACE = 'test_plain'
const strippedDefinition = {
namespace: STRIPPED_NAMESPACE,
keys: ['one_time_code'],
stripAfterCapture: true
}
const plainDefinition = {
namespace: PLAIN_NAMESPACE,
keys: ['plain_code', 'plain_source']
}
function createTestRouter(
history: RouterHistory = createMemoryHistory()
): Router {
return createRouter({
history,
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
}
describe('installPreservedQueryTracker', () => {
beforeEach(() => {
sessionStorage.clear()
clearPreservedQuery(STRIPPED_NAMESPACE)
clearPreservedQuery(SECOND_STRIPPED_NAMESPACE)
clearPreservedQuery(PLAIN_NAMESPACE)
})
it('strips marked keys from the URL while preserving other query and hash', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/?one_time_code=otc_abc123&keep=a+b#frag')
expect(router.currentRoute.value.fullPath).toBe('/?keep=a+b#frag')
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
'otc_abc123'
)
})
it('keeps params of non-strip namespaces in the URL and still captures them', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [plainDefinition])
await router.push('/?plain_code=alpha&plain_source=beta')
expect(router.currentRoute.value.fullPath).toBe(
'/?plain_code=alpha&plain_source=beta'
)
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('alpha')
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')).toBe('beta')
})
it('replaces a non-strip namespace stash on later captures', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [plainDefinition])
await router.push('/?plain_code=alpha&plain_source=beta')
await router.push('/?plain_code=gamma')
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('gamma')
expect(
getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')
).toBeUndefined()
})
it('navigates exactly once when no strip-marked keys are present', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
let completedNavigations = 0
router.afterEach(() => {
completedNavigations++
})
await router.push('/?keep=1')
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
expect(completedNavigations).toBe(1)
})
it('scrubs empty and null values from the URL without stashing them', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/?one_time_code=')
expect(router.currentRoute.value.fullPath).toBe('/')
expect(
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
).toBeUndefined()
await router.push('/?one_time_code')
expect(router.currentRoute.value.fullPath).toBe('/')
expect(
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
).toBeUndefined()
})
it('clears a stale stripped value when the URL supplies an empty value', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/?one_time_code=otc_abc123')
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
'otc_abc123'
)
await router.push('/?one_time_code=')
expect(router.currentRoute.value.fullPath).toBe('/')
expect(
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
).toBeUndefined()
})
it('stashes the first value of a repeated param and cleans the URL', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/?one_time_code=otc_A&one_time_code=otc_B')
expect(router.currentRoute.value.fullPath).toBe('/')
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
'otc_A'
)
})
it('strips keys of multiple marked namespaces in a single redirect', async () => {
const router = createTestRouter()
let navigationAttempts = 0
router.beforeEach((_to, _from, next) => {
navigationAttempts++
next()
})
installPreservedQueryTracker(router, [
strippedDefinition,
{
namespace: SECOND_STRIPPED_NAMESPACE,
keys: ['second_code'],
stripAfterCapture: true
}
])
await router.push('/?one_time_code=otc_x&second_code=sc_y&keep=1')
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
expect(navigationAttempts).toBe(2)
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
'otc_x'
)
expect(
getPreservedQueryParam(SECOND_STRIPPED_NAMESPACE, 'second_code')
).toBe('sc_y')
})
it('keeps the prior history entry reachable after the strip redirect', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/start')
await router.push('/?one_time_code=otc_abc123')
expect(router.currentRoute.value.fullPath).toBe('/')
router.go(-1)
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe('/start')
)
})
it('keeps replace navigation from adding a back target', async () => {
const history = createMemoryHistory()
const router = createTestRouter(history)
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/start')
await router.replace('/?one_time_code=otc_abc123')
expect(router.currentRoute.value.fullPath).toBe('/')
router.go(-1)
expect(history.location).toBe('/')
})
})

View File

@@ -5,25 +5,48 @@ import {
hydratePreservedQuery
} from '@/platform/navigation/preservedQueryManager'
interface PreservedQueryDefinition {
namespace: string
keys: string[]
/**
* When set, keys present in the query are removed from the client-side URL
* before navigation completes. Later guards, afterEach hooks, and views must
* read a strip-marked key from the preserved-query stash instead of
* route.query or fullPath. Because the stash is the only carrier after
* stripping, captures for the namespace merge into the existing stash and an
* explicitly empty value clears the stashed key; non-strip namespaces keep
* replace-on-capture semantics.
*/
stripAfterCapture?: boolean
}
export const installPreservedQueryTracker = (
router: Router,
definitions: Array<{ namespace: string; keys: string[] }>
definitions: PreservedQueryDefinition[]
) => {
const trackedDefinitions = definitions.map((definition) => ({
...definition
}))
router.beforeEach((to, _from, next) => {
const queryKeys = new Set(Object.keys(to.query))
const keysToStrip = new Set<string>()
trackedDefinitions.forEach(({ namespace, keys }) => {
definitions.forEach(({ namespace, keys, stripAfterCapture }) => {
hydratePreservedQuery(namespace)
const shouldCapture = keys.some((key) => queryKeys.has(key))
if (shouldCapture) {
capturePreservedQuery(namespace, to.query, keys)
const presentKeys = keys.filter((key) => queryKeys.has(key))
if (presentKeys.length === 0) return
capturePreservedQuery(namespace, to.query, keys, {
merge: stripAfterCapture
})
if (stripAfterCapture) {
presentKeys.forEach((key) => keysToStrip.add(key))
}
})
next()
if (keysToStrip.size === 0) {
next()
return
}
const cleanedQuery = { ...to.query }
keysToStrip.forEach((key) => delete cleanedQuery[key])
next({ path: to.path, query: cleanedQuery, hash: to.hash })
})
}

View File

@@ -1,5 +1,3 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -13,7 +11,6 @@ const MISSING_NODE = toNodeId('999')
const NEW_NODE = toNodeId('99')
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
layoutStore.initializeFromLiteGraph([
{ id: NODE_1, pos: [10, 20], size: [200, 100] },
{ id: NODE_2, pos: [300, 400], size: [150, 80] }

View File

@@ -26,6 +26,14 @@ interface LayoutMutations {
setNodeZIndex(nodeId: NodeId, zIndex: number): void
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
deleteNode(nodeId: NodeId): void
createLink(
linkId: LinkId,
sourceNodeId: NodeId,
sourceSlot: number,
targetNodeId: NodeId,
targetSlot: number
): void
deleteLink(linkId: LinkId): void
// Reroute operations
createReroute(
@@ -209,6 +217,50 @@ export function useLayoutMutations(): LayoutMutations {
setNodeZIndex(nodeId, maxZIndex + 1)
}
/**
* Create a new link
*/
const createLink = (
linkId: LinkId,
sourceNodeId: NodeId,
sourceSlot: number,
targetNodeId: NodeId,
targetSlot: number
): void => {
logger.debug('Creating link:', {
linkId,
from: `${sourceNodeId}[${sourceSlot}]`,
to: `${targetNodeId}[${targetSlot}]`
})
layoutStore.applyOperation({
type: 'createLink',
entity: 'link',
linkId,
sourceNodeId,
sourceSlot,
targetNodeId,
targetSlot,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a link
*/
const deleteLink = (linkId: LinkId): void => {
logger.debug('Deleting link:', linkId)
layoutStore.applyOperation({
type: 'deleteLink',
entity: 'link',
linkId,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Create a new reroute
*/
@@ -287,6 +339,8 @@ export function useLayoutMutations(): LayoutMutations {
createNode,
deleteNode,
bringNodeToFront,
createLink,
deleteLink,
createReroute,
deleteReroute,
moveReroute

View File

@@ -1,6 +1,3 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toLinkId } from '@/types/linkId'
@@ -32,10 +29,6 @@ function expectSingleOperation(
expect(operations[0]).toEqual(expect.objectContaining(expectedOperation))
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
// Clear the store before each test
@@ -778,7 +771,7 @@ describe('layoutStore getNodeLayoutRef setter', () => {
}
)
it('ignores a null assignment; deletion goes through layoutMutations.deleteNode', () => {
it('emits a deleteNode operation when setter receives null', () => {
const ref = layoutStore.getNodeLayoutRef(REF_NODE)
const layout = baseLayout()
ref.value = layout
@@ -787,8 +780,12 @@ describe('layoutStore getNodeLayoutRef setter', () => {
ref.value = null
})
expect(operations).toEqual([])
expect(ref.value).toEqual(layout)
expectSingleOperation(operations, {
type: 'deleteNode',
nodeId: REF_NODE,
previousLayout: layout
})
expect(ref.value).toBeNull()
})
})
@@ -855,7 +852,7 @@ describe('layoutStore link layout updates', () => {
layoutStore.initializeFromLiteGraph([])
})
const stubPath = () => fromPartial<Path2D>({})
const stubPath = () => ({}) as unknown as Path2D
const baseLink = (path = stubPath()) => ({
id: toLinkId(1),
path,

View File

@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
@@ -18,8 +19,10 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import type {
BatchUpdateBoundsOperation,
Bounds,
CreateLinkOperation,
CreateNodeOperation,
CreateRerouteOperation,
DeleteLinkOperation,
DeleteNodeOperation,
DeleteRerouteOperation,
LayoutChange,
@@ -70,6 +73,18 @@ function asRerouteId(id: string | number): RerouteId {
return toRerouteId(Number(id))
}
function asLinkId(id: string | number): LinkId {
return toLinkId(Number(id))
}
interface LinkData {
id: LinkId
sourceNodeId: NodeId
targetNodeId: NodeId
sourceSlot: number
targetSlot: number
}
interface RerouteData {
id: RerouteId
position: Point
@@ -94,6 +109,7 @@ class LayoutStoreImpl implements LayoutStore {
// Yjs document and shared data structures
private ydoc = new Y.Doc()
private ynodes: Y.Map<NodeLayoutMap> // Maps nodeId -> NodeLayoutMap containing NodeLayout data
private ylinks: Y.Map<Y.Map<unknown>> // Maps linkId -> Y.Map containing link data
private yreroutes: Y.Map<Y.Map<unknown>> // Maps rerouteId -> Y.Map containing reroute data
private yoperations: Y.Array<LayoutOperation> // Operation log
@@ -156,6 +172,7 @@ class LayoutStoreImpl implements LayoutStore {
constructor() {
// Initialize Yjs data structures
this.ynodes = this.ydoc.getMap('nodes')
this.ylinks = this.ydoc.getMap('links')
this.yreroutes = this.ydoc.getMap('reroutes')
this.yoperations = this.ydoc.getArray('operations')
@@ -178,6 +195,14 @@ class LayoutStoreImpl implements LayoutStore {
})
})
// Listen for link changes and update spatial indexes
this.ylinks.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
this.version++
event.changes.keys.forEach((change, linkIdStr) => {
this.handleLinkChange(change, linkIdStr)
})
})
// Listen for reroute changes and update spatial indexes
this.yreroutes.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
this.version++
@@ -187,6 +212,14 @@ class LayoutStoreImpl implements LayoutStore {
})
}
private getLinkField<K extends keyof LinkData>(
ylink: Y.Map<unknown>,
field: K
): LinkData[K] | undefined {
const typedLink = ylink as TypedYMap<LinkData>
return typedLink.get(field)
}
private getRerouteField<K extends keyof RerouteData>(
yreroute: Y.Map<unknown>,
field: K,
@@ -216,68 +249,80 @@ class LayoutStoreImpl implements LayoutStore {
return layout
},
set: (newLayout: NodeLayout | null) => {
// No caller assigns null through this ref; deletion goes through
// layoutMutations.deleteNode, which carries a graphId.
if (newLayout === null) return
// Update operation - detect what changed
const existing = this.ynodes.get(nodeKey)
if (!existing) {
// Create operation
this.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout: newLayout,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
if (newLayout === null) {
// Delete operation
const existing = this.ynodes.get(nodeKey)
if (existing) {
this.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor,
previousLayout: yNodeToLayout(existing)
})
}
} else {
const existingLayout = yNodeToLayout(existing)
// Update operation - detect what changed
const existing = this.ynodes.get(nodeKey)
if (!existing) {
// Create operation
this.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout: newLayout,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
} else {
const existingLayout = yNodeToLayout(existing)
// Check what properties changed
if (
existingLayout.position.x !== newLayout.position.x ||
existingLayout.position.y !== newLayout.position.y
) {
this.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newLayout.position,
previousPosition: existingLayout.position,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (
existingLayout.size.width !== newLayout.size.width ||
existingLayout.size.height !== newLayout.size.height
) {
this.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newLayout.size,
previousSize: existingLayout.size,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (existingLayout.zIndex !== newLayout.zIndex) {
this.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex: newLayout.zIndex,
previousZIndex: existingLayout.zIndex,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
// Check what properties changed
if (
existingLayout.position.x !== newLayout.position.x ||
existingLayout.position.y !== newLayout.position.y
) {
this.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newLayout.position,
previousPosition: existingLayout.position,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (
existingLayout.size.width !== newLayout.size.width ||
existingLayout.size.height !== newLayout.size.height
) {
this.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newLayout.size,
previousSize: existingLayout.size,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (existingLayout.zIndex !== newLayout.zIndex) {
this.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex: newLayout.zIndex,
previousZIndex: existingLayout.zIndex,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
}
}
trigger()
@@ -817,6 +862,12 @@ class LayoutStoreImpl implements LayoutStore {
case 'batchUpdateBounds':
this.handleBatchUpdateBounds(operation, change)
break
case 'createLink':
this.handleCreateLink(operation, change)
break
case 'deleteLink':
this.handleDeleteLink(operation, change)
break
case 'createReroute':
this.handleCreateReroute(operation, change)
break
@@ -1072,8 +1123,18 @@ class LayoutStoreImpl implements LayoutStore {
// and cleanup is handled by onUnmounted in useSlotElementTracking.
// Remove from spatial index
this.spatialIndex.remove(nodeId)
// Link geometry is cleaned up per-link by LLink.disconnect as the node's
// connections are severed, so nothing to do here.
// Clean up associated links
const linksToDelete = this.findLinksConnectedToNode(nodeId)
// Delete the associated links
for (const linkId of linksToDelete) {
const linkKey = String(linkId)
this.ylinks.delete(linkKey)
this.linkLayouts.delete(linkId)
// Clean up link segment layouts
this.cleanupLinkSegments(linkId)
}
change.type = 'delete'
change.nodeIds.push(nodeId)
@@ -1111,6 +1172,40 @@ class LayoutStoreImpl implements LayoutStore {
}
}
private handleCreateLink(
operation: CreateLinkOperation,
change: LayoutChange
): void {
const linkData = new Y.Map<unknown>()
linkData.set('id', operation.linkId)
linkData.set('sourceNodeId', operation.sourceNodeId)
linkData.set('sourceSlot', operation.sourceSlot)
linkData.set('targetNodeId', operation.targetNodeId)
linkData.set('targetSlot', operation.targetSlot)
const linkKey = String(operation.linkId)
this.ylinks.set(linkKey, linkData)
// Link geometry will be computed separately when nodes move
// This just tracks that the link exists
change.type = 'create'
}
private handleDeleteLink(
operation: DeleteLinkOperation,
change: LayoutChange
): void {
const linkKey = String(operation.linkId)
if (!this.ylinks.has(linkKey)) return
this.ylinks.delete(linkKey)
this.linkLayouts.delete(operation.linkId)
// Clean up any segment layouts for this link
this.cleanupLinkSegments(operation.linkId)
change.type = 'delete'
}
private handleCreateReroute(
operation: CreateRerouteOperation,
change: LayoutChange
@@ -1186,6 +1281,43 @@ class LayoutStoreImpl implements LayoutStore {
})
}
/**
* Find all links connected to a specific node
*/
private findLinksConnectedToNode(nodeId: NodeId): LinkId[] {
const connectedLinks: LinkId[] = []
this.ylinks.forEach((linkData: Y.Map<unknown>, linkIdStr: string) => {
const linkId = asLinkId(linkIdStr)
const sourceNodeId = this.getLinkField(linkData, 'sourceNodeId')
const targetNodeId = this.getLinkField(linkData, 'targetNodeId')
if (sourceNodeId === nodeId || targetNodeId === nodeId) {
connectedLinks.push(linkId)
}
})
return connectedLinks
}
/**
* Handle link change events
*/
private handleLinkChange(change: YEventChange, linkIdStr: string): void {
if (change.action === 'delete') {
const linkId = asLinkId(linkIdStr)
this.cleanupLinkData(linkId)
}
// Link was added or updated - geometry will be computed separately
// This just tracks that the link exists in CRDT
}
/**
* Clean up all data associated with a link
*/
private cleanupLinkData(linkId: LinkId): void {
this.linkLayouts.delete(linkId)
this.cleanupLinkSegments(linkId)
}
/**
* Clean up all segment layouts for a link
*/

View File

@@ -113,6 +113,7 @@ interface OperationMeta {
* Entity-specific base types for proper type discrimination
*/
type NodeOpBase = OperationMeta & { entity: 'node'; nodeId: NodeId }
type LinkOpBase = OperationMeta & { entity: 'link'; linkId: LinkId }
type RerouteOpBase = OperationMeta & {
entity: 'reroute'
rerouteId: RerouteId
@@ -129,6 +130,8 @@ type OperationType =
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdateBounds'
| 'createLink'
| 'deleteLink'
| 'createReroute'
| 'deleteReroute'
| 'moveReroute'
@@ -195,6 +198,24 @@ export interface BatchUpdateBoundsOperation extends OperationMeta {
bounds: Record<NodeId, { bounds: Bounds; previousBounds: Bounds }>
}
/**
* Create link operation
*/
export interface CreateLinkOperation extends LinkOpBase {
type: 'createLink'
sourceNodeId: NodeId
sourceSlot: number
targetNodeId: NodeId
targetSlot: number
}
/**
* Delete link operation
*/
export interface DeleteLinkOperation extends LinkOpBase {
type: 'deleteLink'
}
/**
* Create reroute operation
*/
@@ -232,6 +253,8 @@ export type LayoutOperation =
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateBoundsOperation
| CreateLinkOperation
| DeleteLinkOperation
| CreateRerouteOperation
| DeleteRerouteOperation
| MoveRerouteOperation

View File

@@ -85,6 +85,7 @@ describe('Vue Node - Subgraph Functionality', () => {
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,

View File

@@ -60,7 +60,7 @@ vi.mock(
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { id: 'graph-test', getNodeById: vi.fn() },
rootGraph: { getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
@@ -161,6 +161,7 @@ const mockNodeData: VueNodeData = {
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
@@ -177,7 +178,6 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockExecuting = false
mockData.mockLgraphNode = null
setActivePinia(pinia)
const canvasStore = useCanvasStore()
@@ -274,16 +274,17 @@ describe('LGraphNode', () => {
})
it('should hide advanced footer button while the node is collapsed', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true }
flags: { collapsed: true },
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
}
})
@@ -293,17 +294,18 @@ describe('LGraphNode', () => {
})
it('should show error-only footer for collapsed nodes with advanced widgets', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
hasErrors: true
hasErrors: true,
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
}
})

View File

@@ -60,7 +60,7 @@
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? '-inset-1.75' : '-inset-0.75',
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'
@@ -101,10 +101,10 @@
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data unified />
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@@ -124,7 +124,7 @@
/>
<template v-if="!isCollapsed && isRerouteNode">
<NodeSlots :node-data />
<NodeSlots :node-data="nodeData" />
</template>
<template v-else-if="!isCollapsed">
@@ -151,16 +151,20 @@
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data />
<NodeSlots :node-data="nodeData" />
<NodeWidgets v-if="hasRenderableWidgets" :node-data />
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent v-if="nodeMedia" :node-data :media="nodeMedia" />
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
:node-data
:node-data="nodeData"
:media="preview"
/>
</div>
@@ -293,10 +297,6 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isVideoOutput } from '@/utils/litegraphUtil'
import {
@@ -719,30 +719,18 @@ const { promotedPreviews } = usePromotedPreviews(lgraphNode)
useGLSLPreview(lgraphNode)
const widgetValueStore = useWidgetValueStore()
const widgetIds = computed(() => {
const graphId = app.rootGraph?.id
const bareNodeId = stripGraphPrefix(nodeData.id)
if (!graphId || !bareNodeId) return []
return widgetValueStore.getNodeWidgetIds(graphId, bareNodeId) ?? []
})
const hasRenderableWidgets = computed(() => widgetIds.value.length > 0)
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false
if (isCollapsed.value) return false
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
return hasUnpromotedWidgets(node)
}
const hasAdvancedWidgets = widgetIds.value.some((id) => {
const renderState = widgetValueStore.getWidgetRenderState(id)
const widgetState = widgetValueStore.getWidget(id)
return renderState?.advanced ?? widgetState?.options?.advanced
})
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
const alwaysShowAdvanced = settingStore.get(
'Comfy.Node.AlwaysShowAdvancedWidgets'
)

View File

@@ -1,9 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { computed } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { fromPartial } from '@total-typescript/shoehorn'
@@ -12,20 +9,12 @@ vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ inputIsWidget: () => true })
}))
const WidgetGridProbe = {
props: ['processedWidgets'],
setup(props: { processedWidgets?: WidgetGridItem[] }) {
const widgets = computed(() =>
(props.processedWidgets ?? []).map((widget) => ({
name: widget.simplified.name,
value: widget.simplified.value,
options: { values: widget.simplified.options?.values }
}))
)
return { widgets }
},
template:
'<div data-testid="node-data">{{ JSON.stringify({ widgets }) }}</div>'
// Serializes the nodeData prop so tests can assert on the data contract
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
const NodeWidgetsProbe = {
props: ['nodeData'],
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
}
interface ProbedWidget {
@@ -50,11 +39,10 @@ function renderedWidgets(
render(LGraphNodePreview, {
props: { nodeDef: def, ...props },
global: {
plugins: [createTestingPinia({ stubActions: false })],
stubs: {
NodeHeader: true,
NodeSlots: true,
WidgetGrid: WidgetGridProbe
NodeWidgets: NodeWidgetsProbe
}
}
})
@@ -89,17 +77,6 @@ describe('LGraphNodePreview', () => {
expect(widget?.options?.values).toEqual(['a.safetensors', 'b.safetensors'])
})
it('leads with an explicitly empty provided value', () => {
const widget = renderedComboWidget({ widgetValues: { ckpt_name: '' } })
expect(widget?.value).toBe('')
expect(widget?.options?.values).toEqual([
'',
'a.safetensors',
'b.safetensors'
])
})
it('uses the input default when defined and empty string otherwise', () => {
const widgets = renderedWidgets(
fromPartial<ComfyNodeDefV2>({

View File

@@ -19,11 +19,9 @@
>
<NodeSlots :node-data="nodeData" />
<WidgetGrid
v-if="previewWidgets.length"
:processed-widgets="previewWidgets"
:node-type="nodeData.type"
:node-id="nodeData.id"
<NodeWidgets
v-if="nodeData.widgets?.length"
:node-data="nodeData"
class="pointer-events-none"
/>
</div>
@@ -38,17 +36,14 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { toNodeId } from '@/types/nodeId'
import type { WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
const {
@@ -63,9 +58,39 @@ const {
const widgetStore = useWidgetStore()
// Convert nodeDef into VueNodeData
const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
// Preview nodes have no widget-value store entry, so combo widgets
// render their first option; lead with the requested value to show it.
const leadValue = widgetValues?.[name]
return {
nodeId: toNodeId('-1'),
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''),
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
: comboValues
} satisfies IWidgetOptions
}
})
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
.filter(([, input]) => !widgetStore.inputIsWidget(input))
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
name,
type: input.type,
@@ -94,41 +119,16 @@ const nodeData = computed<VueNodeData>(() => {
id: toNodeId(`preview-${nodeDef.name}`),
title: nodeDef.display_name || nodeDef.name,
type: nodeDef.name,
mode: 0,
mode: 0, // Normal mode
selected: false,
executing: false,
widgets,
inputs,
outputs,
flags: {
collapsed: false
}
}
})
const previewWidgets = computed<WidgetGridItem[]>(() =>
Object.entries(nodeDef.inputs || {})
.filter(([, input]) => widgetStore.inputIsWidget(input) && !input.hidden)
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
const leadValue = widgetValues?.[name]
const value = (leadValue ??
input.default ??
comboValues?.[0] ??
'') as WidgetValue
const type = input.widgetType || input.type
const values =
leadValue !== undefined && comboValues
? [leadValue, ...comboValues.filter((option) => option !== leadValue)]
: comboValues
return {
visible: true,
renderKey: `preview:${nodeDef.name}:${name}`,
vueComponent: getComponent(type) ?? WidgetLegacy,
simplified: { name, type, value, options: { values }, spec: input }
}
})
)
</script>

View File

@@ -26,6 +26,7 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
mode: 0,
selected: false,
executing: false,
widgets: [],
inputs: [],
outputs: [],
flags: { collapsed: false },

View File

@@ -38,6 +38,7 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})

View File

@@ -3,17 +3,20 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { widgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
@@ -33,13 +36,7 @@ const WidgetStub = {
name: 'WidgetStub',
props: ['widget', 'nodeId', 'nodeType', 'modelValue'],
template:
'<div class="widget-stub" :data-node-type="nodeType" :data-name="widget.name">{{ nodeType }}</div>'
}
const AppInputStub = {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
'<div class="widget-stub" :data-node-type="nodeType">{{ nodeType }}</div>'
}
vi.mock(
@@ -53,78 +50,63 @@ vi.mock(
}
)
function createMockNodeData(
nodeType = 'TestNode',
id: NodeId = toNodeId(1)
): VueNodeData {
return {
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: toNodeId('test_node'),
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = [],
id: NodeId = toNodeId(1)
): VueNodeData => ({
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
}
}
function registerWidgetState(
id: WidgetId,
init: {
type?: string
value?: unknown
options?: Record<string, unknown>
} = {}
) {
useWidgetValueStore().registerWidget(id, {
type: init.type ?? 'combo',
value: init.value ?? 'value',
options: init.options ?? {}
})
}
function renderComponent({
nodeData,
widgetIds,
setupStores
}: {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
setupStores?: () => void
}) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
function renderComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData,
widgetIds
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true,
AppInput: AppInputStub
return render(NodeWidgets, {
props: {
nodeData
},
mocks: {
$t: (key: string) => key
global: {
plugins: [pinia],
stubs: {
InputSlot: true
},
mocks: {
$t: (key: string) => key
}
}
}
})
}
})
}
describe('NodeWidgets', () => {
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('CheckpointLoaderSimple')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const widget = createMockWidget()
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
const { container } = renderComponent(nodeData)
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -134,31 +116,15 @@ describe('NodeWidgets', () => {
})
it('renders no widgets when nodeData is undefined', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const { container } = renderComponent({
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('renders no widgets when no widget ids are registered or passed', () => {
const { container } = renderComponent({
nodeData: createMockNodeData('CheckpointLoaderSimple')
})
const { container } = renderComponent(undefined)
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('passes empty string when nodeData.type is empty', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const widget = createMockWidget()
const nodeData = createMockNodeData('', [widget])
const { container } = renderComponent(nodeData)
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -166,18 +132,7 @@ describe('NodeWidgets', () => {
})
})
it('derives widget ids from the store when ids are not passed', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'test_widget')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('deduplicates repeated widget ids while keeping distinct widget ids', () => {
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
const duplicateEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
@@ -188,34 +143,163 @@ describe('NodeWidgets', () => {
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
'string_a'
)
const nodeData = createMockNodeData('SubgraphNode')
const { container } = renderComponent({
nodeData,
widgetIds: [duplicateEntityId, duplicateEntityId, distinctEntityId],
setupStores: () => {
registerWidgetState(duplicateEntityId, { type: 'text' })
registerWidgetState(distinctEntityId, { type: 'text' })
}
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
widgetId: distinctEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
duplicateA,
duplicateB,
distinct
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when store options mark them hidden', () => {
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const id = widgetId(GRAPH_ID, toNodeId('test_node'), 'test_widget')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => {
registerWidgetState(id, {
type: 'combo',
options: { hidden: true }
})
}
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: false }
})
const nodeData = createMockNodeData('SubgraphNode', [
hiddenDuplicate,
visibleDuplicate
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('does not deduplicate entries that share names but have different widget types', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
textWidget,
comboWidget
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(19)])
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
secondTransientEntry
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:1'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:1'), 'text')
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:2'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:2'), 'text')
})
const nodeData = createMockNodeData('SubgraphNode', [
firstPromoted,
secondPromoted
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'test_widget',
options: { hidden: false }
})
])
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(
widgetId('graph-test', toNodeId('test_node'), 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
}
)
await nextTick()
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
})
@@ -223,17 +307,44 @@ describe('NodeWidgets', () => {
it('forwards canonical widgetId to AppInput for selection', () => {
const seedAEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_a')
const seedBEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_b')
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_a',
type: 'text',
widgetId: seedAEntityId
}),
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_b',
type: 'text',
widgetId: seedBEntityId
})
])
const { container } = renderComponent({
nodeData,
widgetIds: [seedAEntityId, seedBEntityId],
setupStores: () => {
registerWidgetState(seedAEntityId, { type: 'text' })
registerWidgetState(seedBEntityId, { type: 'text' })
const { container } = render(NodeWidgets, {
props: { nodeData },
global: {
plugins: [
(() => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
return pinia
})()
],
stubs: {
InputSlot: true,
AppInput: {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
},
mocks: {
$t: (key: string) => key
}
}
})
const appInputElements = container.querySelectorAll('.app-input-stub')
const ids = Array.from(appInputElements).map((el) =>
el.getAttribute('data-entity-id')
@@ -241,35 +352,4 @@ describe('NodeWidgets', () => {
expect(ids).toStrictEqual([seedAEntityId, seedBEntityId])
})
it('marks widgets with host execution errors', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'seed')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
widgetIds: [id],
setupStores: () => {
useExecutionErrorStore().lastNodeErrors = {
[createNodeExecutionId([nodeId])]: {
errors: [
{
type: 'value_not_in_list',
message: 'seed is invalid',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
registerWidgetState(id, { type: 'text' })
}
})
expect(container.querySelector('.widget-stub')?.className).toContain(
'text-node-stroke-error'
)
})
})

View File

@@ -2,43 +2,104 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.widgets', 'Node Widgets Error') }}
</div>
<WidgetGrid
<div
v-else
:processed-widgets
:node-type
:can-select-inputs
:node-id="nodeData?.id"
data-testid="node-widgets"
:class="
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
cn(
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1',
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
@pointerdown.capture="handleBringToFront"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
/>
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch pr-3"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { WidgetId } from '@/types/widgetId'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
}
const { nodeData, widgetIds } = defineProps<NodeWidgetsProps>()
const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -67,10 +128,8 @@ onErrorCaptured((error) => {
return false
})
const { canSelectInputs, nodeType, processedWidgets } = useProcessedWidgets(
() => nodeData,
() => widgetIds
)
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
useProcessedWidgets(() => nodeData)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {

View File

@@ -1,104 +0,0 @@
<template>
<div
data-testid="node-widgets"
class="lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.simplified.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.simplified.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.simplified.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-tooltip.left="widget.tooltipConfig ?? EMPTY_TOOLTIP"
:model-value="widget.simplified.value"
:widget="widget.simplified"
:node-id
:node-type
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import { shouldExpand } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { NodeId } from '@/types/nodeId'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
const EMPTY_TOOLTIP: TooltipOptions = {}
const {
processedWidgets,
nodeType,
canSelectInputs = false,
nodeId
} = defineProps<{
processedWidgets: WidgetGridItem[]
nodeType: string
canSelectInputs?: boolean
nodeId?: NodeId
}>()
const gridTemplateRows = computed(() =>
processedWidgets
.filter((widget) => widget.visible)
.map((widget) =>
shouldExpand(widget.simplified.type) || widget.hasLayoutSize
? 'auto'
: 'min-content'
)
.join(' ')
)
</script>

View File

@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { i18n, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
@@ -18,8 +19,9 @@ const positiveCoordsTooltipKey =
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const positiveCoordsWidget: { name: string; tooltip?: string } = {
name: 'positive_coords'
const positiveCoordsWidget: SafeWidgetData = {
name: 'positive_coords',
type: 'STRING'
}
function mergeOutputTooltipMessage(tooltip: string | null) {

View File

@@ -5,6 +5,7 @@ import type {
import { computed, ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st, stRaw } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -135,11 +136,11 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: { name: string; tooltip?: string }) => {
const getWidgetTooltip = (widget: SafeWidgetData) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
const widgetTooltip = widget.tooltip
const widgetTooltip = (widget as { tooltip?: string }).tooltip
if (widgetTooltip) return widgetTooltip
// Then try input-based tooltip lookup

View File

@@ -1,61 +1,51 @@
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { app } from '@/scripts/app'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import type {
WidgetGridItem,
WidgetSlotMetadata
} from '@/renderer/extensions/vueNodes/types/widgetGrid'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { app } from '@/scripts/app'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import { useLinkStore } from '@/stores/linkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { NodeId } from '@/types/nodeId'
import { getControlWidget } from '@/types/simplifiedWidget'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
SafeControlWidget,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import type { WidgetId } from '@/types/widgetId'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { mapLiveWidgetsById } from '@/utils/litegraphUtil'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
const TOOLTIP_VALUE_TYPES = ['asset', 'combo', 'number', 'text'] as const
type TooltipValueType = (typeof TOOLTIP_VALUE_TYPES)[number]
@@ -63,36 +53,33 @@ function isTooltipValueType(val: unknown): val is TooltipValueType {
return TOOLTIP_VALUE_TYPES.includes(val as TooltipValueType)
}
interface WidgetTooltipSource {
name: string
tooltip?: string
}
interface WidgetErrorTarget {
executionId: NodeExecutionId
widgetName: string
}
export interface ProcessedWidget extends WidgetGridItem {
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
widgetId: WidgetId
hidden: boolean
id?: string
widgetId?: WidgetId
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
visible: boolean
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
interface WidgetUiCallbacks {
getTooltipConfig: (
widget: WidgetTooltipSource,
fullVal?: string
) => TooltipOptions
getTooltipConfig: (widget: SafeWidgetData, fullVal?: string) => TooltipOptions
handleNodeRightClick: (e: PointerEvent, nodeId: NodeId) => void
}
interface ComputeProcessedWidgetsOptions {
nodeData: VueNodeData | undefined
widgetIds?: readonly WidgetId[]
graphId: string | undefined
showAdvanced: boolean
isGraphReady: boolean
@@ -100,53 +87,86 @@ interface ComputeProcessedWidgetsOptions {
ui: WidgetUiCallbacks
}
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === undefined) {
return undefined
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
widgetOptions: IWidgetOptions | Record<string, never>,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (widget.sourceExecutionId) {
const sourceWidgetName = widget.sourceWidgetName ?? widget.name
executionErrorStore.clearWidgetRelatedErrors(
widget.sourceExecutionId,
sourceWidgetName,
sourceWidgetName,
newValue,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widget.name,
widget.name,
newValue,
options
)
}
if (value === null) {
return null
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') return value
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined,
graphId: string | undefined,
nodeId: NodeId
): Map<string, WidgetSlotMetadata> {
const linkStore = useLinkStore()
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
const link = graphId
? linkStore.getInputSlotLink(graphId, nodeId, index)
: undefined
const linked = link !== undefined
const originNode = link ? graphRef?.getNodeById(link.originNodeId) : null
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
return (
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}
const slotInfo: WidgetSlotMetadata = {
index,
linked,
originNodeId: link?.originNodeId,
originOutputName: link
? originNode?.outputs?.[link.originSlot]?.name
: undefined,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
export function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: NodeId | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
if (widget.widgetId) {
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
return { dedupeIdentity, renderKey: dedupeIdentity }
}
const hostNodeIdRoot = nodeId ? stripGraphPrefix(nodeId) : null
const widgetNodeIdRoot = widget.nodeId
? stripGraphPrefix(widget.nodeId)
: null
const stableIdentityRoot = widgetNodeIdRoot
? `node:${widgetNodeIdRoot}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: hostNodeIdRoot
? `node:${hostNodeIdRoot}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${widget.name}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${widget.name}:${widget.type}:${index}`
return { dedupeIdentity, renderKey }
}
function getProcessedNodeExecutionId(
@@ -170,16 +190,7 @@ function getWidgetNodeLocatorId(
)
}
function getHostNode(
rootGraph: LGraph | null,
nodeData: VueNodeData
): LGraphNode | null {
if (!rootGraph) return null
const locatorId = getLocatorIdFromNodeData(nodeData)
return locatorId ? getNodeByLocatorId(rootGraph, locatorId) : null
}
function isWidgetVisible(
export function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean,
linked = false
@@ -189,266 +200,19 @@ function isWidgetVisible(
return !hidden && (!advanced || showAdvanced || linked)
}
function hasWidgetError(
widget: { name: string; errorTarget?: WidgetErrorTarget },
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const hasHostError =
!!nodeErrors?.errors.some(
(e) => e.extra_info?.input_name === widget.name
) || missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
const target = widget.errorTarget
if (!target) return hasHostError
const sourceErrors = executionErrorStore.lastNodeErrors?.[target.executionId]
return (
hasHostError ||
!!sourceErrors?.errors.some(
(e) => e.extra_info?.input_name === target.widgetName
) ||
missingModelStore.isWidgetMissingModel(
target.executionId,
target.widgetName
)
)
}
function createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId,
widgetName,
widgetOptions,
executionErrorStore,
widgetValueStore
}: {
id: WidgetId
live?: { node: LGraphNode; widget: IBaseWidget }
errorTarget?: WidgetErrorTarget
nodeExecId: NodeExecutionId
widgetName: string
widgetOptions: IWidgetOptions
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
widgetValueStore: ReturnType<typeof useWidgetValueStore>
}): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
widgetValueStore.setValue(id, newValue)
if (live) {
const normalized = normalizeWidgetValue(newValue)
live.widget.value = normalized ?? undefined
live.widget.callback?.(normalized, app.canvas, live.node)
live.node.widgets?.forEach((w) => w.triggerDraw?.())
}
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (errorTarget) {
executionErrorStore.clearWidgetRelatedErrors(
errorTarget.executionId,
errorTarget.widgetName,
errorTarget.widgetName,
newValue,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widgetName,
widgetName,
newValue,
options
)
}
}
function resolveWidgetIds(
graphId: string | undefined,
nodeId: NodeId,
explicitWidgetIds: readonly WidgetId[] | undefined,
widgetValueStore: ReturnType<typeof useWidgetValueStore>
): readonly WidgetId[] {
if (explicitWidgetIds) return explicitWidgetIds
const bareNodeId = stripGraphPrefix(nodeId)
return graphId && bareNodeId
? widgetValueStore.getNodeWidgetIds(graphId, bareNodeId)
: []
}
interface LiveWidgetContext {
live?: { node: LGraphNode; widget: IBaseWidget }
errorTarget?: WidgetErrorTarget
controlWidget?: SafeControlWidget
}
/**
* Resolves the live litegraph widget (and, for promoted subgraph inputs, its
* interior source) into the control widget and error target the render path
* needs. Empty when the widget has no live counterpart (e.g. static previews).
*/
function resolveLiveWidgetContext(
rootGraph: LGraph | null,
hostNode: LGraphNode | null,
liveWidget: IBaseWidget | undefined
): LiveWidgetContext {
if (!hostNode || !liveWidget) return {}
const promotedSource = resolvePromotedWidgetSource(
rootGraph,
hostNode,
liveWidget
)
const errorTarget: WidgetErrorTarget | undefined =
promotedSource?.sourceExecutionId
? {
executionId: promotedSource.sourceExecutionId,
widgetName: promotedSource.sourceWidgetName
}
: undefined
const controlWidget =
getControlWidget(liveWidget) ??
(promotedSource?.sourceWidget
? getControlWidget(promotedSource.sourceWidget)
: undefined)
return {
live: { node: hostNode, widget: liveWidget },
errorTarget,
controlWidget
}
}
interface WidgetProcessingContext {
nodeData: VueNodeData
showAdvanced: boolean
rootGraph: LGraph | null
hostNode: LGraphNode | null
liveWidgets: Map<WidgetId, IBaseWidget>
slotMetadata: Map<string, WidgetSlotMetadata>
nodeExecId: NodeExecutionId
nodeErrors: Parameters<typeof hasWidgetError>[2]
widgetValueStore: ReturnType<typeof useWidgetValueStore>
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
missingModelStore: ReturnType<typeof useMissingModelStore>
nodeDefStore: ReturnType<typeof useNodeDefStore>
ui: WidgetUiCallbacks
}
function processWidget(
id: WidgetId,
ctx: WidgetProcessingContext
): ProcessedWidget | null {
const widgetState = ctx.widgetValueStore.getWidget(id)
if (!widgetState) return null
const renderState = ctx.widgetValueStore.getWidgetRenderState(id)
const options: IWidgetOptions = { ...(widgetState.options ?? {}) }
if (options.advanced === undefined) options.advanced = renderState?.advanced
if (!shouldRenderAsVue({ type: widgetState.type, options })) return null
const { live, errorTarget, controlWidget } = resolveLiveWidgetContext(
ctx.rootGraph,
ctx.hostNode,
ctx.liveWidgets.get(id)
)
const slotInfo = ctx.slotMetadata.get(widgetState.name)
const visible = isWidgetVisible(options, ctx.showAdvanced, slotInfo?.linked)
const isDisabled = slotInfo?.linked || widgetState.disabled
const widgetOptions = isDisabled ? { ...options, disabled: true } : options
const value = widgetState.value as WidgetValue
const bareWidgetId = stripGraphPrefix(widgetState.nodeId)
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotInfo?.linked && slotInfo.originNodeId
? { nodeId: slotInfo.originNodeId, outputName: slotInfo.originOutputName }
: undefined
const updateHandler = createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId: ctx.nodeExecId,
widgetName: widgetState.name,
widgetOptions,
executionErrorStore: ctx.executionErrorStore,
widgetValueStore: ctx.widgetValueStore
})
const simplified: SimplifiedWidget = {
name: widgetState.name,
type: widgetState.type,
value,
borderStyle: widgetOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
callback: updateHandler,
controlWidget,
label: widgetState.label,
linkedUpstream,
nodeLocatorId: getWidgetNodeLocatorId(ctx.nodeData, bareWidgetId),
options: widgetOptions,
spec: live
? ctx.nodeDefStore.getInputSpecForWidget(live.node, live.widget.name)
: undefined
}
const valueTooltip =
isTooltipValueType(widgetState.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ctx.ui.getTooltipConfig(
{ name: widgetState.name, tooltip: renderState?.tooltip },
valueTooltip
)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
ctx.ui.handleNodeRightClick(e, ctx.nodeData.id)
showNodeOptions(e, widgetState.name)
}
return {
handleContextMenu,
hasLayoutSize: renderState?.hasLayoutSize ?? false,
hasError: hasWidgetError(
{ name: widgetState.name, errorTarget },
ctx.nodeExecId,
ctx.nodeErrors,
ctx.executionErrorStore,
ctx.missingModelStore
),
widgetId: id,
renderKey: `${id}:${widgetState.type}`,
vueComponent:
getComponent(widgetState.type) ||
(renderState?.isDOMWidget ? WidgetDOM : WidgetLegacy),
simplified,
visible,
updateHandler,
tooltipConfig,
slotMetadata: slotInfo
}
}
export function computeProcessedWidgets({
nodeData,
widgetIds,
graphId,
showAdvanced,
isGraphReady,
rootGraph,
ui
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData) return []
if (!nodeData?.widgets) return []
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
const nodeDefStore = useNodeDefStore()
const nodeExecId = getProcessedNodeExecutionId(
isGraphReady,
@@ -457,51 +221,185 @@ export function computeProcessedWidgets({
)
if (!nodeExecId) return []
const hostNode = getHostNode(rootGraph, nodeData)
const liveWidgets = hostNode
? mapLiveWidgetsById(hostNode)
: new Map<WidgetId, IBaseWidget>()
const orderedIds = resolveWidgetIds(
graphId,
nodeData.id,
widgetIds,
widgetValueStore
)
// Drop ids whose live widget is gone (e.g. removed directly on node.widgets);
// when the host node isn't resolvable yet, fall back to the stored order.
const ids = hostNode
? orderedIds.filter((id) => liveWidgets.has(id))
: orderedIds
const slotMetadata = buildSlotMetadata(
nodeData.inputs ?? hostNode?.inputs,
hostNode?.graph ?? rootGraph,
graphId,
nodeData.id
)
const ctx: WidgetProcessingContext = {
nodeData,
showAdvanced,
rootGraph,
hostNode,
liveWidgets,
slotMetadata,
nodeExecId,
nodeErrors: executionErrorStore.lastNodeErrors?.[nodeExecId],
widgetValueStore,
executionErrorStore,
missingModelStore,
nodeDefStore,
ui
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const widgetNodeId = stripGraphPrefix(widget.nodeId ?? nodeId)
const widgetState = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: graphId && widgetNodeId
? widgetValueStore.getWidget(
widgetId(graphId, widgetNodeId, widget.name)
)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(
mergedOptions,
showAdvanced,
widget.slotMetadata?.linked
)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
return Array.from(new Set(ids))
.map((id) => processWidget(id, ctx))
.filter((widget): widget is ProcessedWidget => widget !== null)
for (const {
widget,
mergedOptions,
widgetState,
isVisible: visible,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle = mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const nodeLocatorId = getWidgetNodeLocatorId(nodeData, bareWidgetId)
const simplified: SimplifiedWidget = {
name: widgetState?.name ?? widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions,
executionErrorStore
)
const valueTooltip =
isTooltipValueType(widget.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? (stripGraphPrefix(widget.nodeId) ?? undefined)
: undefined
)
}
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(
widget,
nodeExecId,
nodeErrors,
executionErrorStore,
missingModelStore
),
hidden: mergedOptions.hidden ?? false,
widgetId: widget.widgetId,
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
visible,
updateHandler,
tooltipConfig,
slotMetadata,
...(bareWidgetId === null ? {} : { id: bareWidgetId })
})
}
return result
}
export function useProcessedWidgets(
nodeDataGetter: () => VueNodeData | undefined,
widgetIdsGetter: () => readonly WidgetId[] | undefined = () => undefined
nodeDataGetter: () => VueNodeData | undefined
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
@@ -538,7 +436,6 @@ export function useProcessedWidgets(
const processedWidgets = computed((): ProcessedWidget[] =>
computeProcessedWidgets({
nodeData: nodeDataGetter(),
widgetIds: widgetIdsGetter(),
graphId: canvasStore.canvas?.graph?.rootGraph.id,
showAdvanced: showAdvanced.value,
isGraphReady: app.isGraphReady,
@@ -547,9 +444,23 @@ export function useProcessedWidgets(
})
)
const visibleWidgets = computed(() =>
processedWidgets.value.filter((w) => w.visible)
)
const gridTemplateRows = computed((): string =>
visibleWidgets.value
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
)
return {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets
processedWidgets,
visibleWidgets
}
}

View File

@@ -530,8 +530,6 @@ export function useSlotLinkInteraction({
raf.flush()
raf.flush()
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true, true)
@@ -579,24 +577,18 @@ export function useSlotLinkInteraction({
const graph = app.canvas?.graph ?? null
const context = { adapter, graph, session: dragContext }
const attemptSnapped = () => tryConnectToCandidate(snappedCandidate)
const domSlotCandidate = resolveSlotTargetCandidate(target, context)
const attemptDomSlot = () => tryConnectToCandidate(domSlotCandidate)
const nodeSurfaceSlotCandidate = resolveNodeSurfaceSlotCandidate(
target,
context
)
const attemptNodeSurface = () =>
tryConnectToCandidate(nodeSurfaceSlotCandidate)
const attemptReroute = () => tryConnectViaRerouteAtPointer()
if (attemptSnapped()) return true
if (attemptDomSlot()) return true
if (attemptNodeSurface()) return true
if (attemptReroute()) return true
return false
return (
tryConnectToCandidate(snappedCandidate) ||
tryConnectToCandidate(domSlotCandidate) ||
tryConnectToCandidate(nodeSurfaceSlotCandidate) ||
tryConnectViaRerouteAtPointer()
)
}
const onPointerDown = (event: PointerEvent) => {

View File

@@ -1,33 +0,0 @@
import type { TooltipOptions } from 'primevue'
import type { Component } from 'vue'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { WidgetId } from '@/types/widgetId'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
/**
* Data a widget row needs to render in {@link WidgetGrid}. Required fields cover
* the static preview path; the optional interactive fields are supplied only by
* the store-backed {@link ProcessedWidget} superset.
*/
export interface WidgetGridItem {
simplified: SimplifiedWidget
vueComponent: Component
visible: boolean
renderKey: string
hasLayoutSize?: boolean
hasError?: boolean
widgetId?: WidgetId
slotMetadata?: WidgetSlotMetadata
tooltipConfig?: TooltipOptions
updateHandler?: (value: WidgetValue) => void
handleContextMenu?: (e: PointerEvent) => void
}

View File

@@ -78,9 +78,7 @@ watch(() => canvasStore.currentGraph, bindWidget)
function draw() {
if (!widgetInstance || !node) return
const width =
canvasEl.value.parentElement.clientWidth ||
canvasEl.value.getBoundingClientRect().width
const width = canvasEl.value.parentElement.clientWidth
// Priority: computedHeight (from litegraph) > computeLayoutSize > computeSize
let height = 20
if (widgetInstance.computedHeight) {
@@ -128,7 +126,7 @@ function handleMove(e: PointerEvent) {
</script>
<template>
<div
class="relative mx-[-12px] w-full min-w-0"
class="relative mx-[-12px] min-w-0 basis-0"
:style="{ minHeight: `${containerHeight}px` }"
>
<canvas

View File

@@ -8,6 +8,7 @@ import {
shouldRenderAsVue,
FOR_TESTING
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const {
WidgetButton,
@@ -133,7 +134,7 @@ describe('widgetRegistry', () => {
})
it('should respect options while checking type', () => {
const widget: { type: string; options: { canvasOnly: boolean } } = {
const widget: Partial<SafeWidgetData> = {
type: 'text',
options: { canvasOnly: false }
}

View File

@@ -4,7 +4,7 @@
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const WidgetButton = defineAsyncComponent(
() => import('../components/WidgetButton.vue')
@@ -268,10 +268,7 @@ export const isEssential = (type: string): boolean => {
return widgets.get(canonicalType)?.essential || false
}
export const shouldRenderAsVue = (widget: {
options?: Pick<IWidgetOptions, 'canvasOnly'>
type?: string
}): boolean => {
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
return !widget.options?.canvasOnly && !!widget.type
}

View File

@@ -1,153 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { toLinkId } from '@/types/linkId'
import type { LinkTopology } from '@/types/linkTopology'
import { toNodeId, UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { UUID } from '@/utils/uuid'
import { useLinkStore } from './linkStore'
const graphA: UUID = 'graph-a'
const graphB: UUID = 'graph-b'
function link(
id: number,
originNode: number,
originSlot: number,
targetNode: number,
targetSlot: number
): LinkTopology {
return {
id: toLinkId(id),
originNodeId: toNodeId(originNode),
originSlot,
targetNodeId: toNodeId(targetNode),
targetSlot,
type: 'INT'
}
}
describe('useLinkStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('answers input-slot connectedness with one lookup', () => {
const store = useLinkStore()
expect(store.registerLink(graphA, link(1, 5, 0, 9, 2))).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 3)).toBe(false)
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(1))
})
it('re-keys the link when its target moves', () => {
const store = useLinkStore()
const topology = link(1, 5, 0, 9, 2)
store.registerLink(graphA, topology)
expect(store.updateEndpoint(graphA, topology, { targetSlot: 4 })).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(false)
expect(store.getInputSlotLink(graphA, toNodeId(9), 4)?.id).toBe(toLinkId(1))
})
it('keeps the first registration for a contested target slot', () => {
const store = useLinkStore()
store.registerLink(graphA, link(1, 5, 0, 9, 2))
expect(store.registerLink(graphA, link(2, 5, 0, 9, 2))).toBe(false)
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(1))
})
it('only the registered link can vacate its slot', () => {
const store = useLinkStore()
const registered = link(1, 5, 0, 9, 2)
const loser = link(2, 5, 0, 9, 2)
store.registerLink(graphA, registered)
store.registerLink(graphA, loser)
expect(store.deleteLink(graphA, loser)).toBe(false)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(true)
expect(store.deleteLink(graphA, registered)).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(false)
})
it('reports a lost placement when a target moves onto an occupied slot', () => {
const store = useLinkStore()
store.registerLink(graphA, link(1, 5, 0, 9, 2))
const mover = link(2, 5, 1, 9, 3)
store.registerLink(graphA, mover)
expect(store.updateEndpoint(graphA, mover, { targetSlot: 2 })).toBe(false)
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(1))
expect(store.isInputSlotConnected(graphA, toNodeId(9), 3)).toBe(false)
expect(mover.targetSlot).toBe(2)
})
it('never answers target queries from floating links', () => {
const store = useLinkStore()
const inputFloating: LinkTopology = {
...link(1, 5, 0, 9, 2),
originNodeId: UNASSIGNED_NODE_ID,
originSlot: -1
}
expect(store.registerLink(graphA, inputFloating)).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(false)
const real = link(2, 5, 0, 9, 2)
expect(store.registerLink(graphA, real)).toBe(true)
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(2))
expect(store.deleteLink(graphA, inputFloating)).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(true)
})
it('re-keys a floating link that gains a real origin', () => {
const store = useLinkStore()
const floating: LinkTopology = {
...link(1, 5, 0, 9, 2),
originNodeId: UNASSIGNED_NODE_ID,
originSlot: -1
}
store.registerLink(graphA, floating)
expect(
store.updateEndpoint(graphA, floating, {
originNodeId: toNodeId(5),
originSlot: 0
})
).toBe(true)
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(1))
})
it('lets sibling subgraphs register output-node links without clobbering', () => {
const store = useLinkStore()
const first = link(1, 5, 0, Number(SUBGRAPH_OUTPUT_ID), 0)
const second = link(1, 7, 0, Number(SUBGRAPH_OUTPUT_ID), 0)
expect(store.registerLink(graphA, first)).toBe(true)
expect(store.registerLink(graphA, second)).toBe(true)
expect(store.deleteLink(graphA, first)).toBe(true)
expect(store.deleteLink(graphA, second)).toBe(true)
})
it('scopes by graph and does not clear on tab switch', () => {
const store = useLinkStore()
store.registerLink(graphA, link(1, 5, 0, 9, 2))
store.registerLink(graphB, link(1, 5, 0, 9, 2))
store.clearGraph(graphB)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(true)
expect(store.isInputSlotConnected(graphB, toNodeId(9), 2)).toBe(false)
})
})

View File

@@ -1,142 +0,0 @@
import { defineStore } from 'pinia'
import { reactive, ref, toRaw } from 'vue'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { LinkTopology } from '@/types/linkTopology'
import type { NodeId } from '@/types/nodeId'
import type { UUID } from '@/utils/uuid'
export type EndpointPatch = Partial<
Pick<
LinkTopology,
'originNodeId' | 'originSlot' | 'targetNodeId' | 'targetSlot'
>
>
type TargetIndex = Map<UUID, Map<string, LinkTopology>>
type UnkeyedLinks = Map<UUID, Set<LinkTopology>>
/** Slot is numeric so the last separator is unambiguous for any node id. */
function targetKey(nodeId: NodeId, slot: number): string {
return `${nodeId}:${slot}`
}
/**
* A link is keyed by its target input slot only when that slot uniquely
* identifies it: floating links (either endpoint unassigned) can share an
* input slot with a real link, and SUBGRAPH_OUTPUT_ID is a constant shared by
* every subgraph in a root bucket. Neither is queried by target.
*/
function hasUniqueTarget(topology: LinkTopology): boolean {
return (
topology.originNodeId !== UNASSIGNED_NODE_ID &&
topology.targetNodeId !== UNASSIGNED_NODE_ID &&
topology.targetNodeId !== SUBGRAPH_OUTPUT_ID
)
}
/**
* Link topology store, keyed by link target. At most one live link can target
* a given input slot — litegraph disconnects the previous link before
* connecting a new one — so the target is the natural primary key and the
* dominant query ("is this input connected, and by what?") is one lookup.
* Links without a unique target live in a per-graph side collection.
*/
export const useLinkStore = defineStore('link', () => {
const targetIndex = ref<TargetIndex>(new Map())
const unkeyedLinks = ref<UnkeyedLinks>(new Map())
function graphTargets(graphId: UUID): Map<string, LinkTopology> {
const existing = targetIndex.value.get(graphId)
if (existing) return existing
const next = reactive(new Map<string, LinkTopology>())
targetIndex.value.set(graphId, next)
return next
}
function graphUnkeyed(graphId: UUID): Set<LinkTopology> {
const existing = unkeyedLinks.value.get(graphId)
if (existing) return existing
const next = reactive(new Set<LinkTopology>())
unkeyedLinks.value.set(graphId, next)
return next
}
/**
* Places a link under its current endpoints. The first registration for a
* target slot wins; re-placing the already-registered topology is a no-op.
* @returns `true` if `topology` holds a registration afterwards.
*/
function place(graphId: UUID, topology: LinkTopology): boolean {
if (!hasUniqueTarget(topology)) {
graphUnkeyed(graphId).add(topology)
return true
}
const targets = graphTargets(graphId)
const key = targetKey(topology.targetNodeId, topology.targetSlot)
const existing = targets.get(key)
if (existing && toRaw(existing) !== toRaw(topology)) return false
targets.set(key, topology)
return true
}
/** Removes a link's placement; only the registered topology may vacate it. */
function displace(graphId: UUID, topology: LinkTopology): boolean {
if (unkeyedLinks.value.get(graphId)?.delete(topology)) return true
const targets = targetIndex.value.get(graphId)
if (!targets) return false
const key = targetKey(topology.targetNodeId, topology.targetSlot)
if (toRaw(targets.get(key)) !== toRaw(topology)) return false
return targets.delete(key)
}
/**
* Applies an endpoint patch and re-places the link under its new target.
* @returns `true` if the link holds a registration afterwards.
*/
function updateEndpoint(
graphId: UUID,
topology: LinkTopology,
patch: EndpointPatch
): boolean {
displace(graphId, topology)
const live = reactive(topology)
if (patch.originNodeId !== undefined) live.originNodeId = patch.originNodeId
if (patch.originSlot !== undefined) live.originSlot = patch.originSlot
if (patch.targetNodeId !== undefined) live.targetNodeId = patch.targetNodeId
if (patch.targetSlot !== undefined) live.targetSlot = patch.targetSlot
return place(graphId, topology)
}
function isInputSlotConnected(
graphId: UUID,
nodeId: NodeId,
slot: number
): boolean {
return targetIndex.value.get(graphId)?.has(targetKey(nodeId, slot)) ?? false
}
function getInputSlotLink(
graphId: UUID,
nodeId: NodeId,
slot: number
): LinkTopology | undefined {
return targetIndex.value.get(graphId)?.get(targetKey(nodeId, slot))
}
function clearGraph(graphId: UUID): void {
targetIndex.value.delete(graphId)
unkeyedLinks.value.delete(graphId)
}
return {
registerLink: place,
updateEndpoint,
deleteLink: displace,
isInputSlotConnected,
getInputSlotLink,
clearGraph
}
})

View File

@@ -141,7 +141,7 @@ describe('useWidgetValueStore', () => {
expect(registered?.value).toBe(100)
})
it('getNodeWidgets returns widgets in registration order', () => {
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
store.registerWidget(
widgetId(graphA, toNodeId('node-1'), 'seed'),
@@ -157,40 +157,8 @@ describe('useWidgetValueStore', () => {
)
const widgets = store.getNodeWidgets(graphA, toNodeId('node-1'))
expect(widgets.map((w) => w.name)).toEqual(['seed', 'steps'])
})
it('getNodeWidgetIds returns the explicit node widget order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
const cfg = widgetId(graphA, toNodeId('node-1'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(steps, state('number', 20))
store.registerWidget(cfg, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [cfg, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
cfg,
seed,
steps
])
expect(
store.getNodeWidgets(graphA, toNodeId('node-1')).map((w) => w.name)
).toEqual(['cfg', 'seed', 'steps'])
})
it('ignores widget IDs from other nodes when setting order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const other = widgetId(graphA, toNodeId('node-2'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(other, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [other, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([seed])
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
})
@@ -206,33 +174,14 @@ describe('useWidgetValueStore', () => {
).toBe(false)
})
it('deleteWidget removes registered widgets from node order', () => {
it('deleteWidget removes registered widgets', () => {
const store = useWidgetValueStore()
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
store.registerWidget(seedA, state('number', 100))
store.registerWidget(steps, state('number', 20))
expect(store.deleteWidget(seedA)).toBe(true)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
steps
])
expect(store.deleteWidget(seedA)).toBe(false)
})
it('removeNodeWidgetOrder drops the id from order but keeps its value', () => {
const store = useWidgetValueStore()
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
store.registerWidget(seedA, state('number', 100))
store.registerWidget(steps, state('number', 20))
store.removeNodeWidgetOrder(seedA)
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
steps
])
expect(store.getWidget(seedA)?.value).toBe(100)
})
})
describe('direct property mutation', () => {

View File

@@ -8,23 +8,12 @@ import { parseWidgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetState, WidgetStateInit } from '@/types/widgetState'
export interface WidgetRenderState {
advanced?: boolean
hasLayoutSize?: boolean
isDOMWidget?: boolean
tooltip?: string
}
export function stripGraphPrefix(scopedId: SerializedNodeId): NodeId | null {
return parseNodeId(String(scopedId).replace(/^(.*:)+/, ''))
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetId, WidgetState>>())
const graphWidgetRenderStates = ref(
new Map<UUID, Map<WidgetId, WidgetRenderState>>()
)
const graphNodeWidgetOrders = ref(new Map<UUID, Map<NodeId, WidgetId[]>>())
function getGraphWidgetStates(graphId: UUID): Map<WidgetId, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
@@ -35,67 +24,12 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return nextWidgetStates
}
function getGraphWidgetRenderStates(
graphId: UUID
): Map<WidgetId, WidgetRenderState> {
const widgetRenderStates = graphWidgetRenderStates.value.get(graphId)
if (widgetRenderStates) return widgetRenderStates
const nextWidgetRenderStates = reactive(
new Map<WidgetId, WidgetRenderState>()
)
graphWidgetRenderStates.value.set(graphId, nextWidgetRenderStates)
return nextWidgetRenderStates
}
function getGraphNodeWidgetOrders(graphId: UUID): Map<NodeId, WidgetId[]> {
const widgetOrders = graphNodeWidgetOrders.value.get(graphId)
if (widgetOrders) return widgetOrders
const nextWidgetOrders = reactive(new Map<NodeId, WidgetId[]>())
graphNodeWidgetOrders.value.set(graphId, nextWidgetOrders)
return nextWidgetOrders
}
function getNodeWidgetOrder(graphId: UUID, nodeId: NodeId): WidgetId[] {
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (order) return order
const nextOrder = reactive<WidgetId[]>([])
graphOrders.set(nodeId, nextOrder)
return nextOrder
}
function appendNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const order = getNodeWidgetOrder(graphId, nodeId)
if (!order.includes(widgetId)) order.push(widgetId)
}
function removeNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (!order) return
const index = order.indexOf(widgetId)
if (index !== -1) order.splice(index, 1)
if (order.length === 0) graphOrders.delete(nodeId)
}
function registerWidget<TValue = unknown>(
widgetId: WidgetId,
init: WidgetStateInit<TValue>,
renderState: WidgetRenderState = {}
init: WidgetStateInit<TValue>
): WidgetState<TValue> {
registerWidgetRenderState(widgetId, renderState)
const existing = getWidget(widgetId)
if (existing) {
appendNodeWidgetOrder(widgetId)
return existing as WidgetState<TValue>
}
if (existing) return existing as WidgetState<TValue>
const { graphId, nodeId, name } = parseWidgetId(widgetId)
const state: WidgetState<TValue> = {
@@ -106,39 +40,14 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
}
const widgetStates = getGraphWidgetStates(graphId)
widgetStates.set(widgetId, state)
appendNodeWidgetOrder(widgetId)
return widgetStates.get(widgetId) as WidgetState<TValue>
}
function registerWidgetRenderState(
widgetId: WidgetId,
init: WidgetRenderState
): WidgetRenderState {
const { graphId } = parseWidgetId(widgetId)
const widgetRenderStates = getGraphWidgetRenderStates(graphId)
const existing = widgetRenderStates.get(widgetId)
if (existing) {
Object.assign(existing, init)
return existing
}
const state: WidgetRenderState = { ...init }
widgetRenderStates.set(widgetId, state)
return widgetRenderStates.get(widgetId) as WidgetRenderState
}
function getWidget(widgetId: WidgetId): WidgetState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetStates(graphId).get(widgetId)
}
function getWidgetRenderState(
widgetId: WidgetId
): WidgetRenderState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetRenderStates(graphId).get(widgetId)
}
function setValue(widgetId: WidgetId, value: WidgetState['value']): boolean {
const state = getWidget(widgetId)
if (!state) return false
@@ -148,70 +57,25 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
function deleteWidget(widgetId: WidgetId): boolean {
const { graphId } = parseWidgetId(widgetId)
getGraphWidgetRenderStates(graphId).delete(widgetId)
removeNodeWidgetOrder(widgetId)
return getGraphWidgetStates(graphId).delete(widgetId)
}
function getNodeWidgets(graphId: UUID, localNodeId: NodeId): WidgetState[] {
return getNodeWidgetIds(graphId, localNodeId).flatMap((id) => {
const state = getWidget(id)
return state ? [state] : []
})
}
/**
* Merges a requested widget order against the ids already tracked for the
* node: the request is filtered to tracked ids, then any tracked id the
* request omitted is appended. Tracked ids are never dropped here — only
* {@link removeNodeWidgetOrder} removes an id from the order.
*/
function reconcileNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): WidgetId[] {
const currentOrder = getNodeWidgetIds(graphId, localNodeId)
const currentIds = new Set(currentOrder)
const nextOrder = orderedWidgetIds.filter((id) => currentIds.has(id))
const nextIds = new Set(nextOrder)
return [...nextOrder, ...currentOrder.filter((id) => !nextIds.has(id))]
}
function getNodeWidgetIds(graphId: UUID, localNodeId: NodeId): WidgetId[] {
return [...getNodeWidgetOrder(graphId, localNodeId)]
}
function setNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): void {
const nextOrder = reconcileNodeWidgetOrder(
graphId,
localNodeId,
orderedWidgetIds
return [...getGraphWidgetStates(graphId).values()].filter(
(state) => state.nodeId === localNodeId
)
const order = getNodeWidgetOrder(graphId, localNodeId)
order.splice(0, order.length, ...nextOrder)
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
graphWidgetRenderStates.value.delete(graphId)
graphNodeWidgetOrders.value.delete(graphId)
}
return {
registerWidget,
getWidget,
getWidgetRenderState,
setValue,
deleteWidget,
getNodeWidgets,
getNodeWidgetIds,
setNodeWidgetOrder,
removeNodeWidgetOrder,
clearGraph
}
})

View File

@@ -1,17 +0,0 @@
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
import type { LinkId } from '@/types/linkId'
import type { NodeId } from '@/types/nodeId'
import type { RerouteId } from '@/types/rerouteId'
export interface LinkTopology {
id: LinkId
/** Output node; UNASSIGNED_NODE_ID when the output end is floating. */
originNodeId: NodeId
originSlot: number
/** Input node; UNASSIGNED_NODE_ID when the input end is floating. */
targetNodeId: NodeId
targetSlot: number
type: ISlotType
/** Terminal reroute of the segment chain, when the link routes through one. */
parentId?: RerouteId
}

View File

@@ -3,11 +3,7 @@
* Removes all DOM manipulation and positioning concerns
*/
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { NodeId } from '@/types/nodeId'
import type { NodeLocatorId } from '@/types/nodeIdentification'
@@ -34,7 +30,7 @@ function isControlOption(val: WidgetValue): val is ControlOptions {
return CONTROL_OPTIONS.includes(val as ControlOptions)
}
function normalizeControlOption(val: WidgetValue): ControlOptions {
export function normalizeControlOption(val: WidgetValue): ControlOptions {
if (isControlOption(val)) return val
return 'randomize'
}
@@ -44,17 +40,6 @@ export type SafeControlWidget = {
update: (value: WidgetValue) => void
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const controlWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!controlWidget) return
return {
value: normalizeControlOption(controlWidget.value),
update: (value) => (controlWidget.value = normalizeControlOption(value))
}
}
export interface LinkedUpstreamInfo {
nodeId: NodeId
outputName?: string

View File

@@ -19,6 +19,7 @@ export interface WidgetState<
| 'disabled'
| 'y'
> {
isDOMWidget?: boolean
nodeId: NodeId
}

View File

@@ -1,10 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { useLinkStore } from '@/stores/linkStore'
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import type {
ISerialisedGraph,
@@ -282,7 +279,6 @@ describe('fixBadLinks', () => {
const links = new Map([[linkId, link]])
const graph = fromAny<LGraph, unknown>({
links,
_removeLink: vi.fn((id) => links.delete(id)),
getNodeById: vi.fn((nodeId) =>
nodeId === originNode.id ? originNode : null
)
@@ -322,30 +318,3 @@ describe('fixBadLinks', () => {
expect(logger.log).not.toHaveBeenCalled()
})
})
describe('fixBadLinks ↔ linkStore integration', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('removes deleted zombie links from the link store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', '*')
b.addInput('in', '*')
graph.add(a)
graph.add(b)
const zombie = new LLink(toLinkId(9), '*', a.id, 0, b.id, 0)
graph._addLink(zombie)
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(true)
const result = fixBadLinks(graph, { fix: true, silent: true })
expect(result).toMatchObject({ fixed: true, deleted: 1 })
expect(graph._links.has(zombie.id)).toBe(false)
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(false)
})
})

View File

@@ -427,7 +427,7 @@ export function fixBadLinks(
logger.log(`Deleting link #${data.deletedLinks[i]}.`)
if ((graph as LGraph).getNodeById) {
const liveGraph = graph as LGraph
liveGraph._removeLink(toLinkId(data.deletedLinks[i]!))
liveGraph.links.delete(toLinkId(data.deletedLinks[i]!))
} else {
graph = graph as ISerialisedGraph
// Sometimes we got objects for links if passed after ComfyUI's loadGraphData modifies the

View File

@@ -379,26 +379,6 @@ export function getWidgetIdForNode(
return widgetId(graphId, nodeId, name)
}
/**
* Maps a node's live widgets to their {@link WidgetId}, replicating the
* duplicate-name disambiguation used when the ids were minted. Building the map
* once lets callers resolve widgets by id in O(1) instead of rescanning.
*/
export function mapLiveWidgetsById(
node: LGraphNode
): Map<WidgetId, IBaseWidget> {
const byId = new Map<WidgetId, IBaseWidget>()
const duplicateIndexByKey = new Map<string, number>()
for (const widget of node.widgets ?? []) {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const id = getWidgetIdForNode(node, widget, duplicateIndex)
if (id) byId.set(id, widget)
}
return byId
}
export function isLoad3dNode(node: LGraphNode) {
return (
node &&

Some files were not shown because too many files have changed in this diff Show More