Compare commits
16 Commits
austin/flo
...
codex/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae6d29f4e8 | ||
|
|
676c3c5f1d | ||
|
|
02f7a27215 | ||
|
|
f4526fdd8f | ||
|
|
7ef845bda7 | ||
|
|
3e043acfdb | ||
|
|
0b66a8124d | ||
|
|
bba941b112 | ||
|
|
747f76db76 | ||
|
|
386460afef | ||
|
|
5cf647d183 | ||
|
|
fe1fc8baa6 | ||
|
|
3e4dd59e5f | ||
|
|
e25e0f2e16 | ||
|
|
2ee91c30ee | ||
|
|
854770d305 |
@@ -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.
|
||||
|
||||
197
.github/workflows/backport-auto-merge.yaml
vendored
Normal 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
|
||||
42
.github/workflows/cla.yml
vendored
@@ -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: |
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 938 B |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 1.2 KiB |
10
apps/website/public/icons/ai-models/openai.svg
Normal 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 |
28
apps/website/src/components/common/CardArrow.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': '探索最新模型工作流'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -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)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
@@ -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')
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ??= []
|
||||
|
||||
@@ -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 definition’s 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 definition’s 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 graph’s 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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
196
src/platform/navigation/preservedQueryTracker.test.ts
Normal 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('/')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('Vue Node - Subgraph Functionality', () => {
|
||||
selected: false,
|
||||
executing: false,
|
||||
subgraphId,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
hasErrors: false,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: { collapsed: false },
|
||||
|
||||
@@ -38,6 +38,7 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
flags: { collapsed: false },
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface WidgetState<
|
||||
| 'disabled'
|
||||
| 'y'
|
||||
> {
|
||||
isDOMWidget?: boolean
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||