Compare commits
18 Commits
feature/ec
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe5568d07 | ||
|
|
a5dfaf4462 | ||
|
|
010389903d | ||
|
|
684b0b08b0 | ||
|
|
95b121bed9 | ||
|
|
baeb6df662 | ||
|
|
e58b231664 | ||
|
|
545b48ee5b | ||
|
|
22ea53fb56 | ||
|
|
9fe5dd51b8 | ||
|
|
747f76db76 | ||
|
|
386460afef | ||
|
|
5cf647d183 | ||
|
|
fe1fc8baa6 | ||
|
|
3e4dd59e5f | ||
|
|
e25e0f2e16 | ||
|
|
2ee91c30ee | ||
|
|
854770d305 |
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: |
|
||||
|
||||
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
|
||||
|
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: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 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>
|
||||
@@ -33,7 +33,7 @@ const ctaButtons = [
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -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,14 @@ 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 h-auto whitespace-normal"
|
||||
>
|
||||
{{ 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>
|
||||
|
||||
38
apps/website/src/components/ui/icon-button/IconButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { IconButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconButtonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: IconButtonVariants['variant']
|
||||
size?: IconButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="icon-button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(iconButtonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
28
apps/website/src/components/ui/icon-button/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const iconButtonVariants = cva(
|
||||
[
|
||||
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost:
|
||||
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
default: 'size-10',
|
||||
lg: 'size-14'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'ghost',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>
|
||||
75
apps/website/src/composables/useBannerDismissal.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
|
||||
|
||||
type ClosedBanners = Record<string, boolean>
|
||||
|
||||
function readClosedBanners(): ClosedBanners {
|
||||
try {
|
||||
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeClosedBanners(value: ClosedBanners): void {
|
||||
try {
|
||||
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Storage unavailable (private mode / quota) — dismissal just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The stable part of a version key (everything before `_v<hash>`). */
|
||||
function versionPrefix(version: string): string {
|
||||
const idx = version.lastIndexOf('_v')
|
||||
return idx === -1 ? version : version.slice(0, idx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side dismissal persisted in localStorage, keyed by a content-aware
|
||||
* `version`. The banner renders visible in the static HTML (so non-dismissers
|
||||
* see no pop-in); an inline pre-hydration script hides an already-dismissed
|
||||
* banner before paint, and this composable then removes it from the DOM on mount.
|
||||
*/
|
||||
export function useBannerDismissal(version: string) {
|
||||
const isVisible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const stored = readClosedBanners()
|
||||
const prefix = versionPrefix(version)
|
||||
|
||||
// Prune stale versions of THIS banner+locale; keep other banners/locales
|
||||
// and the current version.
|
||||
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
|
||||
let pruned = false
|
||||
for (const key of Object.keys(stored)) {
|
||||
if (versionPrefix(key) !== prefix || key === version) {
|
||||
cleaned[key] = stored[key]
|
||||
} else {
|
||||
pruned = true
|
||||
}
|
||||
}
|
||||
if (pruned) writeClosedBanners(cleaned)
|
||||
|
||||
isVisible.value = !cleaned[version]
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
isVisible.value = false
|
||||
const stored = readClosedBanners()
|
||||
stored[version] = true
|
||||
writeClosedBanners(stored)
|
||||
}
|
||||
|
||||
// Call once the close transition has finished. Sets the pre-paint hide signal
|
||||
// so the banner doesn't flash back in on a ClientRouter (view-transition)
|
||||
// navigation — where the inline <head> script does not re-run but <html>
|
||||
// persists. Deferred to after the animation so the leave transition can play.
|
||||
function persistHidden(): void {
|
||||
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
|
||||
}
|
||||
|
||||
return { isVisible, close, persistHidden }
|
||||
}
|
||||
84
apps/website/src/config/banner.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ButtonVariants } from '../components/ui/button'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
|
||||
// window gates on the last deploy, not the visitor's exact clock.
|
||||
|
||||
interface BannerLinkConfig {
|
||||
readonly href: string
|
||||
readonly titleKey: TranslationKey
|
||||
readonly target?: boolean
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
readonly id: string
|
||||
readonly isActive: boolean
|
||||
readonly startsAt?: string
|
||||
readonly endsAt?: string
|
||||
/** Empty/undefined = all locales. */
|
||||
readonly targetLocales?: readonly Locale[]
|
||||
/** v1 only supports 'sitewide'. */
|
||||
readonly targetSections?: readonly string[]
|
||||
readonly titleKey: TranslationKey
|
||||
readonly descriptionKey?: TranslationKey
|
||||
readonly link?: BannerLinkConfig
|
||||
}
|
||||
|
||||
interface BannerLinkData {
|
||||
readonly href: string
|
||||
readonly title: string
|
||||
readonly target?: '_blank'
|
||||
readonly rel?: string
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerData {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly link?: BannerLinkData
|
||||
}
|
||||
|
||||
export const bannerConfig: BannerConfig = {
|
||||
id: 'announcement',
|
||||
isActive: true,
|
||||
targetSections: ['sitewide'],
|
||||
titleKey: 'launches.banner.text',
|
||||
link: {
|
||||
href: '/mcp',
|
||||
titleKey: 'launches.banner.cta',
|
||||
buttonVariant: 'underlineLink'
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a config's i18n keys into display strings for the given locale. */
|
||||
export function getBannerData(
|
||||
config: BannerConfig,
|
||||
locale: Locale
|
||||
): BannerData {
|
||||
const { link } = config
|
||||
const target = link?.target ? '_blank' : undefined
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
title: t(config.titleKey, locale),
|
||||
description: config.descriptionKey
|
||||
? t(config.descriptionKey, locale)
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: link.href,
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
buttonVariant: link.buttonVariant
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -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': '探索最新模型工作流'
|
||||
@@ -3979,12 +3983,12 @@ const translations = {
|
||||
// Launches page (/launches) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.banner.text': {
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
en: 'Now turn your agent into a creative technologist.',
|
||||
'zh-CN': '现在,让你的智能体成为创意技术专家。'
|
||||
},
|
||||
'launches.banner.cta': {
|
||||
en: 'Join livestream',
|
||||
'zh-CN': '加入直播'
|
||||
en: 'Start Comfy MCP',
|
||||
'zh-CN': '启动 Comfy MCP'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — closing CTA
|
||||
|
||||
@@ -5,6 +5,14 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -124,6 +141,25 @@ const websiteJsonLd = {
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
bannerVersion,
|
||||
storageKey: BANNER_STORAGE_KEY,
|
||||
dismissAttr: BANNER_DISMISS_ATTR
|
||||
}}
|
||||
>
|
||||
try {
|
||||
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
|
||||
if (dismissed[bannerVersion]) {
|
||||
document.documentElement.setAttribute(dismissAttr, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
)}
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
@@ -137,8 +173,16 @@ const websiteJsonLd = {
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
{bannerVisible && (
|
||||
<AnnouncementBanner
|
||||
data={bannerData}
|
||||
version={bannerVersion}
|
||||
locale={locale}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter locale={locale} client:load />
|
||||
|
||||
@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
@@ -13,7 +12,6 @@ const locale = 'en' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale = 'zh-CN' as const
|
||||
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
--color-secondary-mauve: #4d3762;
|
||||
--color-destructive: #f44336;
|
||||
--color-primary-comfy-plum: #49378b;
|
||||
--color-secondary-deep-plum: #2b2040;
|
||||
--color-secondary-cool-gray: #3c3c3c;
|
||||
--color-illustration-forest: #20464c;
|
||||
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
|
||||
@@ -93,6 +94,14 @@
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
|
||||
script in BaseLayout head) — prevents any flash before Vue hydrates.
|
||||
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
|
||||
keep them in sync. */
|
||||
[data-banner-dismissed] [data-slot='announcement-banner'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes border-angle-spin {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
@@ -248,7 +257,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
|
||||
|
||||
107
apps/website/src/templates/drops/AnnouncementBanner.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, X } from '@lucide/vue'
|
||||
|
||||
import type { BannerData } from '../../config/banner'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import IconButton from '@/components/ui/icon-button/IconButton.vue'
|
||||
import { useBannerDismissal } from '../../composables/useBannerDismissal'
|
||||
|
||||
const {
|
||||
data,
|
||||
version,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
data: BannerData
|
||||
version: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const { isVisible, close, persistHidden } = useBannerDismissal(version)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="banner-collapse" @after-leave="persistHidden">
|
||||
<div v-if="isVisible" class="banner-collapse grid">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div
|
||||
data-slot="announcement-banner"
|
||||
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-primary-comfy-plum) 0%,
|
||||
var(--color-secondary-deep-plum) 53.85%,
|
||||
var(--color-secondary-mauve) 100%
|
||||
);
|
||||
"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
<p
|
||||
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
|
||||
>
|
||||
{{ data.title }}
|
||||
<span v-if="data.description" class="text-primary-warm-white/80">
|
||||
{{ data.description }}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="data.link"
|
||||
as="a"
|
||||
:href="data.link.href"
|
||||
:target="data.link.target"
|
||||
:rel="data.link.rel"
|
||||
:variant="data.link.buttonVariant ?? 'underlineLink'"
|
||||
size="sm"
|
||||
>
|
||||
{{ data.link.title }}
|
||||
<template #append>
|
||||
<ArrowRight class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-1 justify-end">
|
||||
<IconButton
|
||||
type="button"
|
||||
:aria-label="t('nav.close', locale)"
|
||||
@click="close"
|
||||
>
|
||||
<X class="size-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
|
||||
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
|
||||
leave (dismiss) runs, since the banner renders present in the static HTML. */
|
||||
.banner-collapse {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-from,
|
||||
.banner-collapse-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
|
||||
const signUpRel = resolveRel({ target: '_blank' })
|
||||
|
||||
// Hide once the livestream window closes — both for visitors arriving after
|
||||
// the event and for visitors whose tab is open when it ends.
|
||||
const endMs = new Date(livestream.endDateTime).getTime()
|
||||
const visible = ref(true)
|
||||
|
||||
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
|
||||
// schedules a long-lived server timer.
|
||||
const { start } = useTimeoutFn(
|
||||
() => {
|
||||
visible.value = false
|
||||
},
|
||||
() => Math.max(0, endMs - Date.now()),
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (endMs - Date.now() <= 0) {
|
||||
visible.value = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="px-4">
|
||||
<div
|
||||
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p class="ppformula-text-center">
|
||||
{{ t('launches.banner.text', locale) }}
|
||||
</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="underlineLink"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
:rel="signUpRel"
|
||||
>
|
||||
{{ t('launches.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
109
apps/website/src/utils/banner.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { EvaluableBanner } from './banner'
|
||||
|
||||
import { createBannerVersion, evaluateBannerVisibility } from './banner'
|
||||
|
||||
const base: EvaluableBanner = {
|
||||
isActive: true,
|
||||
targetSections: ['sitewide']
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
currentLocale: 'en',
|
||||
currentSection: 'sitewide',
|
||||
now: new Date('2026-07-06T00:00:00Z')
|
||||
}
|
||||
|
||||
describe('evaluateBannerVisibility', () => {
|
||||
it('shows an active, untargeted, sitewide banner', () => {
|
||||
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when inactive', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides before startsAt and shows within the window', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides after endsAt', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an empty targetLocales as "all locales"', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides when targetLocales excludes the current locale', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when targetSections does not include the current section', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when targetSections is absent (nothing to match)', () => {
|
||||
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBannerVersion', () => {
|
||||
const content = {
|
||||
id: 'announcement',
|
||||
title: 'Join the live stream',
|
||||
link: { href: 'https://x', title: 'Join' }
|
||||
}
|
||||
|
||||
it('is deterministic for identical content', () => {
|
||||
expect(createBannerVersion(content, 'en')).toBe(
|
||||
createBannerVersion(content, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes the banner id and locale in the key', () => {
|
||||
expect(createBannerVersion(content, 'en')).toMatch(
|
||||
/^announcement_en_v-?\d+$/
|
||||
)
|
||||
})
|
||||
|
||||
it('changes when the copy changes', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion({ ...content, title: 'New copy' }, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('differs per locale so one locale edit does not re-show another', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion(content, 'zh-CN')
|
||||
)
|
||||
})
|
||||
})
|
||||
87
apps/website/src/utils/banner.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
|
||||
// trivially unit-testable. Locale/section are plain strings on purpose.
|
||||
|
||||
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
|
||||
// the useBannerDismissal composable, and the CSS selector in global.css must all
|
||||
// agree on these literals — keep them here as the single source of truth.
|
||||
export const BANNER_STORAGE_KEY = 'closedBanners'
|
||||
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
|
||||
|
||||
export interface BannerVisibilityContext {
|
||||
currentLocale: string
|
||||
currentSection: string
|
||||
now: Date
|
||||
}
|
||||
|
||||
export interface EvaluableBanner {
|
||||
isActive: boolean
|
||||
startsAt?: string
|
||||
endsAt?: string
|
||||
targetLocales?: readonly string[]
|
||||
targetSections?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Server/build-time visibility gate. Returns false on the FIRST failing check,
|
||||
* in order: active flag → start window → end window → locale targeting →
|
||||
* section targeting. An empty/absent `targetLocales` means "all locales".
|
||||
*/
|
||||
export function evaluateBannerVisibility(
|
||||
banner: EvaluableBanner,
|
||||
ctx: BannerVisibilityContext
|
||||
): boolean {
|
||||
if (!banner.isActive) return false
|
||||
if (
|
||||
banner.startsAt &&
|
||||
ctx.now.getTime() < new Date(banner.startsAt).getTime()
|
||||
)
|
||||
return false
|
||||
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
|
||||
return false
|
||||
|
||||
const targetLocales = banner.targetLocales ?? []
|
||||
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
|
||||
return false
|
||||
|
||||
const targetSections = banner.targetSections ?? []
|
||||
if (!targetSections.includes(ctx.currentSection)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface BannerLinkContent {
|
||||
href: string
|
||||
title: string
|
||||
target?: string
|
||||
rel?: string
|
||||
buttonVariant?: string
|
||||
}
|
||||
|
||||
export interface BannerVersionContent {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
link?: BannerLinkContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-aware version key. Editing the copy changes the hash, so a previously
|
||||
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
|
||||
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
|
||||
*/
|
||||
export function createBannerVersion(
|
||||
content: BannerVersionContent,
|
||||
locale: string
|
||||
): string {
|
||||
const contentString = JSON.stringify({
|
||||
locale,
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
link: content.link
|
||||
})
|
||||
let hash = 0
|
||||
for (const char of contentString) {
|
||||
hash = Math.imul(hash, 31) + char.charCodeAt(0)
|
||||
}
|
||||
return `${content.id}_${locale}_v${hash}`
|
||||
}
|
||||
@@ -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('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 }) => {
|
||||
|
||||
@@ -424,3 +424,35 @@ test.describe('Unserialized widgets', { tag: '@widget' }, () => {
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Widget value isolation', { tag: '@widget' }, () => {
|
||||
test('re-added node does not inherit a cleared node widget value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
|
||||
const firstNode = await comfyPage.nodeOps.addNode('EmptyLatentImage')
|
||||
const firstWidth = await firstNode.getWidgetByName('width')
|
||||
const defaultWidth = await firstWidth.getValue()
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ id, name, value }) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found`)
|
||||
const widget = node.widgets?.find((w) => w.name === name)
|
||||
if (!widget) throw new Error(`Widget ${name} not found`)
|
||||
widget.value = value
|
||||
},
|
||||
{ id: firstNode.id, name: 'width', value: 128 }
|
||||
)
|
||||
expect(await firstWidth.getValue()).toBe(128)
|
||||
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
|
||||
const secondNode = await comfyPage.nodeOps.addNode('EmptyLatentImage')
|
||||
expect(secondNode.id).toBe(firstNode.id)
|
||||
|
||||
const secondWidth = await secondNode.getWidgetByName('width')
|
||||
expect(await secondWidth.getValue()).toBe(defaultWidth)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,31 +21,6 @@ text below says "the World," read "the set of dedicated stores"; where it shows
|
||||
`world.getComponent(id, Component)`, read the matching store getter (for
|
||||
example `widgetValueStore.getWidget(widgetId)`).
|
||||
|
||||
### Amendment (2026-07-05, PRs 13436/13449)
|
||||
|
||||
Two stores joined the dedicated-store set: `linkStore` (link topology,
|
||||
keyed by target input slot in root-graph-scoped buckets — see
|
||||
[Link Topology Store](../architecture/link-topology-store.md)) and
|
||||
`rerouteStore` (reroute chain state with link membership derived from
|
||||
the links' `parentId` chains — see
|
||||
[Reroute Chain Store](../architecture/reroute-chain-store.md)). Both
|
||||
follow the proxy-returning registration pattern established by
|
||||
`BaseWidget`/`widgetValueStore`: the store bucket is a `reactive(Map)`,
|
||||
registration inserts the class's state object by reference and the class
|
||||
adopts the reactive proxy read back from the bucket, so class field
|
||||
writes are tracked without an action chokepoint. The `layoutStore` link
|
||||
connectivity mirror and the `slot._floatingLinks` sets were deleted in
|
||||
the same work; the layout store now holds geometry only.
|
||||
|
||||
### Amendment (2026-07-14, PR 13458)
|
||||
|
||||
`nodeBadgeStore` joined the dedicated-store set: plain `BadgeData` rows
|
||||
keyed by `NodeId` in root-graph-scoped buckets. Unlike the
|
||||
proxy-adoption stores above, rows are written by a reactive badge
|
||||
system (`src/systems/badgeSystem.ts`) — a pure `computeBadges` inside a
|
||||
per-node effect scope — see
|
||||
[Node Badge Store](../architecture/node-badge-store.md).
|
||||
|
||||
## Context
|
||||
|
||||
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
|
||||
@@ -140,15 +115,6 @@ Components are plain data objects — no methods, no back-references to parent e
|
||||
| `LinkVisual` | `color`, `path`, `_pos` (center point) |
|
||||
| `LinkState` | `_dragging`, `data` |
|
||||
|
||||
> **Amended (2026-07-05):** `LinkEndpoints` shipped as
|
||||
> `LinkTopology { id, originNodeId, originSlot, targetNodeId,
|
||||
targetSlot, type, parentId? }` in a dedicated `linkStore`, keyed by
|
||||
> **target input slot** (not link id) in root-graph-scoped buckets, with
|
||||
> floating and subgraph-output links in an unkeyed side set. `LLink`
|
||||
> reads through the store's reactive proxy (`_state`). See
|
||||
> [Link Topology Store](../architecture/link-topology-store.md).
|
||||
> `LinkVisual` and `LinkState` remain unextracted.
|
||||
|
||||
#### Subgraph (Node Components)
|
||||
|
||||
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
|
||||
@@ -174,13 +140,6 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
|
||||
| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator |
|
||||
| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` |
|
||||
|
||||
> **Amended (2026-07-05):** the input side of `SlotConnection` is
|
||||
> subsumed by the `linkStore` key — the input-slot→link mapping _is_ the
|
||||
> store's primary index (`isInputSlotConnected` / `getInputSlotLink`).
|
||||
> The `slot._floatingLinks` sets were deleted; floating-link attachment
|
||||
> is derived from the links' own endpoints (`slotFloatingLinks`). The
|
||||
> `input.link` / `output.links` class mirrors remain un-migrated.
|
||||
|
||||
#### Reroute
|
||||
|
||||
| Component | Data (from `Reroute`) |
|
||||
@@ -189,13 +148,6 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
|
||||
| `RerouteLinks` | `parentId`, input/output link IDs |
|
||||
| `RerouteVisual` | `color`, badge config |
|
||||
|
||||
> **Amended (2026-07-04):** `RerouteLinks` was superseded during design
|
||||
> review. The stored component is chain state only —
|
||||
> `RerouteChain { parentId, floating? }` — and link membership
|
||||
> (`linkIds` / `floatingLinkIds`) is derived from the links' `parentId`
|
||||
> chains rather than stored. See
|
||||
> [Reroute Chain Store](../architecture/reroute-chain-store.md).
|
||||
|
||||
#### Group
|
||||
|
||||
| Component | Data (from `LGraphGroup`) |
|
||||
@@ -319,9 +271,6 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Link Topology Store](../architecture/link-topology-store.md) | Design record for the `linkStore` — target-input-slot keying, root-scoped buckets, registration protocol |
|
||||
| [Reroute Chain Store](../architecture/reroute-chain-store.md) | Design record for the `rerouteStore` — chain state, derived link membership, load-time id dedup |
|
||||
| [Domain Glossary](../architecture/domain-glossary.md) | Canonical vocabulary for links, reroutes, chains, and membership |
|
||||
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, Thyseus, and Bevy — patterns adopted, departed, when to revisit |
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Domain Glossary
|
||||
|
||||
Canonical vocabulary for the graph domain. Terms are added as they are
|
||||
resolved during design work; keep entries implementation-free. Intended
|
||||
to grow into a proper reference document.
|
||||
|
||||
Design records that rely on this vocabulary:
|
||||
[Link Topology Store](link-topology-store.md),
|
||||
[Reroute Chain Store](reroute-chain-store.md),
|
||||
[Node Badge Store](node-badge-store.md),
|
||||
[ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
## Badges
|
||||
|
||||
- **Badge** — a small visual annotation rendered on a node: its numeric
|
||||
id, lifecycle state, source pack, execution price, or an
|
||||
extension-provided marker. Badges are presentation state; they never
|
||||
affect execution and are never persisted with the workflow.
|
||||
- **Badge kind** — the category a badge belongs to: **core** (identity /
|
||||
lifecycle / source, projected from the node's definition and user
|
||||
settings), **credits** (price of executing an API node, including
|
||||
aggregated prices of nodes inside a subgraph), or **extension**
|
||||
(provided by third-party code).
|
||||
- **Badge source** — the domain state a badge's content is computed
|
||||
from (settings, node definition, palette, pricing, widget values,
|
||||
input connectivity). A badge is always a projection of its sources;
|
||||
it is never authored directly by a user.
|
||||
|
||||
## Links & Reroutes
|
||||
|
||||
- **Link** — a directed data connection from one node's output slot to
|
||||
another node's input slot. At most one live link targets a given input
|
||||
slot.
|
||||
- **Floating link** — a link with exactly one attached endpoint, kept
|
||||
alive so a reroute chain survives disconnection. The unattached end is
|
||||
unassigned.
|
||||
- **Reroute** — a visual waypoint that a link's rendered path travels
|
||||
through. Purely organisational; never affects data flow. A reroute's
|
||||
identity is unique within a workflow, subgraphs included.
|
||||
- **Reroute chain** — the ordered sequence of reroutes a link passes
|
||||
through, from the node output toward the input. Each reroute names its
|
||||
upstream neighbour via _parent_; the link names the chain's most
|
||||
downstream reroute (the **terminal reroute**).
|
||||
- **Link membership (of a reroute)** — the set of links whose chains pass
|
||||
through that reroute. Membership is _defined by_ the chains: a link is a
|
||||
member of exactly the reroutes on the chain walked from its terminal
|
||||
reroute upstream. It is never authored independently of the chain.
|
||||
- **Floating slot marker** — the annotation on the last reroute of a
|
||||
floating chain recording which side (input or output) the chain still
|
||||
faces.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Link topology and reroute chain state shipped later into `linkStore` (PR 13436, keyed by target input slot in root-graph-scoped buckets — see [link-topology-store.md](link-topology-store.md)) and `rerouteStore` (PR 13449, membership derived from the links' `parentId` chains — see [reroute-chain-store.md](reroute-chain-store.md)); `layoutStore` keeps link/reroute geometry only. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
|
||||
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
|
||||
|
||||
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
|
||||
|
||||
@@ -66,7 +66,6 @@ sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant LM as useLayoutMutations()
|
||||
participant LKS as linkStore
|
||||
participant LS as layoutStore
|
||||
participant WVS as widgetValueStore
|
||||
participant NOS as nodeOutputStore
|
||||
@@ -74,14 +73,12 @@ sequenceDiagram
|
||||
|
||||
Caller->>CS: removeNode(nodeId)
|
||||
|
||||
CS->>LKS: read node links (incoming + outgoing)
|
||||
LKS-->>CS: link topologies
|
||||
CS->>LS: read node links (incoming + outgoing)
|
||||
LS-->>CS: linkIds
|
||||
|
||||
loop each link
|
||||
CS->>LKS: unregister link topology
|
||||
Note over CS,LKS: via the LGraph._removeLink chokepoint —<br/>map delete + store unregistration
|
||||
CS->>LS: drop link geometry
|
||||
Note over LKS,LS: linkStore owns topology;<br/>layoutStore only drops geometry
|
||||
loop each linkId
|
||||
CS->>LM: deleteLink(linkId)
|
||||
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
|
||||
end
|
||||
|
||||
loop each widget on node
|
||||
@@ -96,15 +93,15 @@ sequenceDiagram
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Lines of code | ~107 in one method | ~30 in system function |
|
||||
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates linkStore + layout/widget/output stores |
|
||||
| Cleanup | Manual per-slot, per-link, per-reroute | linkStore unregistration per link (via `_removeLink`) + geometry drop per layout entry |
|
||||
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
|
||||
| Store cleanup | WidgetValueStore not cleaned up; link geometry still removed from LayoutStore | Coordinated: `deleteWidget`, linkStore unregister + `deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
|
||||
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
|
||||
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| Lines of code | ~107 in one method | ~30 in system function |
|
||||
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates layoutStore + widget/output stores |
|
||||
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteLink()`/`deleteNode()` mutations per layout entry |
|
||||
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
|
||||
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | Coordinated: `deleteWidget`, `deleteLink`/`deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
|
||||
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
|
||||
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
|
||||
|
||||
## 2. Serialization
|
||||
|
||||
@@ -226,7 +223,7 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
opt has subgraph definitions
|
||||
G->>G: deduplicateSubgraphNodeIds() + deduplicateSubgraphRerouteIds()
|
||||
G->>G: deduplicateSubgraphNodeIds()
|
||||
loop each subgraph (topological order)
|
||||
G->>G: createSubgraph(data)
|
||||
end
|
||||
@@ -253,7 +250,7 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
G->>G: add floating links
|
||||
G->>G: prune reroutes with derived totalLinks === 0
|
||||
G->>G: validate reroutes
|
||||
G->>G: _removeDuplicateLinks()
|
||||
|
||||
loop each serialized group
|
||||
@@ -265,8 +262,6 @@ sequenceDiagram
|
||||
|
||||
Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside `node.configure()`. Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs.
|
||||
|
||||
Note on load-time id hygiene: root `configure()` deduplicates **node ids** and **reroute ids** across sibling subgraph definitions before configuring them (`deduplicateSubgraphNodeIds` / `deduplicateSubgraphRerouteIds`), because both share root-graph-scoped store buckets. Link-id dedup is deliberately absent — the linkStore key is the target input slot, not the link id, so duplicate link ids across definitions cannot collide. Orphaned reroutes are pruned by the derived `totalLinks === 0` check; the old `validateLinks` set-repair is gone (membership is derived, so there is no stored set to drift).
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
@@ -537,7 +532,7 @@ sequenceDiagram
|
||||
participant G as LGraph
|
||||
participant L as LLink
|
||||
participant R as Reroute
|
||||
participant LKS as linkStore
|
||||
participant LS as LayoutStore
|
||||
|
||||
Caller->>N1: connectSlots(output, targetNode, input)
|
||||
|
||||
@@ -550,24 +545,24 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
N1->>L: new LLink(++lastLinkId, type, ...)
|
||||
N1->>G: graph._addLink(link)
|
||||
G->>LKS: register topology (keyed by target input slot)
|
||||
Note over G,LKS: chokepoint: _links.set + linkStore registration
|
||||
N1->>G: _links.set(link.id, link)
|
||||
N1->>LS: layoutMutations.createLink()
|
||||
|
||||
N1->>N1: output.links.push(link.id)
|
||||
N1->>N2: input.link = link.id
|
||||
|
||||
N1->>R: anchorRerouteChain(graph, link)
|
||||
Note over N1,R: clears floating markers on the chain —<br/>membership is derived from link.parentId,<br/>no per-reroute linkIds writes
|
||||
loop each reroute in path
|
||||
N1->>R: reroute.linkIds.add(link.id)
|
||||
end
|
||||
|
||||
N1->>G: incrementVersion()
|
||||
N1->>G: _version++
|
||||
N1->>N1: onConnectionsChange?(OUTPUT, ...)
|
||||
N1->>N2: onConnectionsChange?(INPUT, ...)
|
||||
N1->>G: setDirtyCanvas()
|
||||
N1->>G: afterChange()
|
||||
```
|
||||
|
||||
Problems: the source node orchestrates everything — it reaches into the graph's link map (via the `_addLink` chokepoint), the target node's slot, and the version counter. Reroute membership no longer needs writes (derived via rerouteStore), but the slot mirrors (`output.links`, `input.link`) are still mutated by hand.
|
||||
Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
@@ -575,28 +570,29 @@ Problems: the source node orchestrates everything — it reaches into the graph'
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant LKS as linkStore
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
|
||||
Caller->>CS: connect(outputSlot, inputSlot)
|
||||
|
||||
CS->>LKS: getInputSlotLink(graphId, targetNodeId, targetSlot)
|
||||
CS->>LS: read input slot link
|
||||
opt already connected
|
||||
CS->>LKS: unregister existing topology
|
||||
CS->>LM: deleteLink(existingLinkId)
|
||||
end
|
||||
|
||||
CS->>LKS: register LinkTopology {<br/> id, originNodeId, originSlot,<br/> targetNodeId, targetSlot, type<br/>}
|
||||
Note over CS,LKS: the target-slot key IS the input-side<br/>slot connection — no separate endpoint update.<br/>Reroute membership derives from parentId;<br/>rerouteStore needs no write
|
||||
CS->>LM: createLink(linkId, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
|
||||
Note over LM,LS: createLink updates both slot endpoints<br/>and emits a command record
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Orchestrator | Source node (reaches into graph, target slots) | ConnectivitySystem (reads linkStore) |
|
||||
| Side effects | `incrementVersion()`, `setDirtyCanvas()`, `afterChange()`, callbacks | topology registration — endpoints + change tracking included |
|
||||
| Reroute handling | None needed — membership derived from `parentId` chains (rerouteStore); `anchorRerouteChain` only clears floating markers | Same — derived membership is already the target shape |
|
||||
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Input side subsumed by the linkStore key; output side pending SlotConnection extraction |
|
||||
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
|
||||
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (reads layoutStore) |
|
||||
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `createLink()` command — endpoints + change tracking included |
|
||||
| Reroute handling | Manual: iterate chain, add linkId to each | Reroute entries updated via layout mutations |
|
||||
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | `createLink(linkId, ...)` updates both endpoints |
|
||||
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
|
||||
|
||||
## 7. Copy / Paste
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ For verified accuracy of these documents, see
|
||||
|
||||
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
|
||||
> string IDs, one store per concern (widget values, DOM widgets, layout, node
|
||||
> outputs, subgraph navigation, preview exposure, link topology, reroute
|
||||
> chains). The earlier "single unified
|
||||
> outputs, subgraph navigation, preview exposure). The earlier "single unified
|
||||
> World with branded numeric entity IDs and `getComponent`/`setComponent`" model
|
||||
> was rejected. PR 12617 shipped the first stores against composite
|
||||
> `graphId:nodeId:name` string keys (`WidgetId`). Phases below are reframed
|
||||
@@ -114,26 +113,14 @@ narrow accessor surface. There is no single container that fronts all entities.
|
||||
|
||||
Shipped stores:
|
||||
|
||||
| Store | File |
|
||||
| ---------------------------- | ----------------------------------------------- |
|
||||
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
|
||||
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
|
||||
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
|
||||
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
|
||||
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
|
||||
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
|
||||
| `linkStore` ✅ PR 13436 | `src/stores/linkStore.ts` |
|
||||
| `rerouteStore` ✅ PR 13449 | `src/stores/rerouteStore.ts` |
|
||||
| `nodeBadgeStore` ✅ PR 13458 | `src/stores/nodeBadgeStore.ts` |
|
||||
|
||||
`linkStore` holds `LinkTopology` records (`src/types/linkTopology.ts`) keyed by
|
||||
target input slot (`` `${targetNodeId}:${targetSlot}` ``) in root-graph-scoped
|
||||
buckets — subgraphs share their root's bucket; floating links and links
|
||||
targeting subgraph outputs live in a per-graph unkeyed side set. `rerouteStore`
|
||||
holds `RerouteChain` records keyed by `RerouteId` in root-graph-scoped buckets;
|
||||
link membership is not stored but derived from the links' `parentId` chains.
|
||||
Design records: [Link Topology Store](link-topology-store.md),
|
||||
[Reroute Chain Store](reroute-chain-store.md).
|
||||
| Store | File |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
|
||||
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
|
||||
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
|
||||
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
|
||||
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
|
||||
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
|
||||
|
||||
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
|
||||
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
|
||||
@@ -273,14 +260,6 @@ link-endpoint records from the relevant stores:
|
||||
Does not perform mutations yet — just queries. Validates that store connectivity
|
||||
data is complete and consistent with the class-based graph.
|
||||
|
||||
> **Status (2026-07-05):** The reroute-membership query shipped as `linkStore` +
|
||||
> `rerouteStore` (PRs 13436, 13449): "what links pass through this reroute" is
|
||||
> derived per root graph by a cached reverse index over the links' `parentId`
|
||||
> chains, and input-side connectivity is one lookup via
|
||||
> `linkStore.isInputSlotConnected()` / `getInputSlotLink()`. Remaining:
|
||||
> slot mirrors (`input.link` / `output.links`), output-side queries, and
|
||||
> execution order.
|
||||
|
||||
**Risk:** Low. Read-only system with equivalence tests.
|
||||
|
||||
---
|
||||
@@ -332,12 +311,6 @@ the system knowing about the callback API.
|
||||
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
|
||||
compatibility API during Phase 4.
|
||||
|
||||
> **Status (2026-07-05):** Link and reroute store registration now funnels
|
||||
> through canonical `LGraph` mutation chokepoints: `_addLink`/`_removeLink` and
|
||||
> `_addReroute`/`_removeReroute` pair every map mutation with store
|
||||
> (un)registration, and `clear()` / subgraph-definition GC unregister whole
|
||||
> graphs. The callback contract above and slot-mirror extraction remain.
|
||||
|
||||
**Risk:** High. Extensions depend on callback ordering and timing. Must be
|
||||
validated against real-world extensions.
|
||||
|
||||
@@ -623,16 +596,13 @@ state between these calls.
|
||||
|
||||
The dedicated stores use per-concern keying strategies:
|
||||
|
||||
| Store | Key Format |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
|
||||
| `domWidgetStore` | Widget UUID |
|
||||
| `layoutStore` | Raw nodeId/linkId/rerouteId |
|
||||
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
|
||||
| `subgraphNavigationStore` | subgraphId or `'root'` |
|
||||
| `linkStore` | `` `${targetNodeId}:${targetSlot}` `` (target input slot), root-graph-scoped buckets |
|
||||
| `rerouteStore` | `RerouteId`, root-graph-scoped buckets |
|
||||
| `nodeBadgeStore` | `NodeId`, root-graph-scoped buckets |
|
||||
| Store | Key Format |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
|
||||
| `domWidgetStore` | Widget UUID |
|
||||
| `layoutStore` | Raw nodeId/linkId/rerouteId |
|
||||
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
|
||||
| `subgraphNavigationStore` | subgraphId or `'root'` |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
@@ -659,8 +629,7 @@ Phase 0c (doc fixes) ─────────┤── no dependencies betwe
|
||||
|
||||
Phase 1a (branded WidgetId) ── ✅ shipped (PR 12617)
|
||||
Phase 1b (store state shapes) ─┐── depends on 1a
|
||||
Phase 1c (dedicated stores) ──┘── widgetValueStore + 7 others shipped
|
||||
(PR 12617; linkStore PR 13436; rerouteStore PR 13449)
|
||||
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
|
||||
|
||||
Phase 2a (Position via layoutStore) ─┐── depends on 1c
|
||||
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c
|
||||
|
||||
@@ -18,12 +18,7 @@ Map<WidgetId, WidgetValue>"]
|
||||
DomWidgetStore["domWidgetStore
|
||||
Map<WidgetId, DomWidgetState>"]
|
||||
LayoutStore["layoutStore (Y.js CRDT)
|
||||
nodeId / linkId / rerouteId → geometry"]
|
||||
LinkStore["linkStore
|
||||
rootGraphId → targetNodeId:targetSlot
|
||||
→ LinkTopology"]
|
||||
RerouteStore["rerouteStore
|
||||
rootGraphId → RerouteId → RerouteChain"]
|
||||
nodeId / linkId / rerouteId → layout"]
|
||||
NodeOutputStore["nodeOutputStore
|
||||
Map<nodeLocatorId, outputs>"]
|
||||
SubgraphNavStore["subgraphNavigationStore
|
||||
@@ -44,8 +39,7 @@ preview exposure state"]
|
||||
|
||||
RS -->|reads| Stores
|
||||
SS -->|reads/writes| Stores
|
||||
CS -->|reads/writes| LinkStore
|
||||
CS -->|reads/writes| RerouteStore
|
||||
CS -->|reads/writes| LayoutStore
|
||||
LS -->|reads/writes| LayoutStore
|
||||
ES -->|reads| NodeOutputStore
|
||||
VS -->|reads/writes| LayoutStore
|
||||
@@ -69,31 +63,20 @@ subgraphId:nodeId"]
|
||||
NID["nodeId (raw)"]
|
||||
LID["linkId (raw)"]
|
||||
RID["rerouteId (raw)"]
|
||||
TIS["targetNodeId:targetSlot
|
||||
(root-graph-scoped bucket)"]
|
||||
end
|
||||
|
||||
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
|
||||
NLID -->|nodeOutputStore| W
|
||||
NID -->|layoutStore| W
|
||||
LID -->|layoutStore| W
|
||||
RID -->|layoutStore, rerouteStore| W
|
||||
TIS -->|linkStore| W
|
||||
RID -->|layoutStore| W
|
||||
```
|
||||
|
||||
`WidgetId = graphId:nodeId:name` is itself a branded string (see
|
||||
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
|
||||
outputs. `layoutStore` keys geometry records by raw `nodeId` / `linkId` /
|
||||
`rerouteId`. `linkStore` keys `LinkTopology` by **target input slot**
|
||||
(`targetNodeId:targetSlot`) inside root-graph-scoped buckets — the link id is
|
||||
NOT the key; at most one live link can target an input slot, so the target is
|
||||
the natural primary key (see
|
||||
[link-topology-store.md](link-topology-store.md)). Links without a unique
|
||||
target (floating links, `SUBGRAPH_OUTPUT_ID` targets) live in a per-graph
|
||||
unkeyed side set. `rerouteStore` keys `RerouteChain` by raw `rerouteId` in
|
||||
root-graph-scoped buckets (see
|
||||
[reroute-chain-store.md](reroute-chain-store.md)). Each store enforces its own
|
||||
key shape; there is no single shared entity-ID type across stores.
|
||||
outputs. `layoutStore` keys layout records by raw `nodeId` / `linkId` /
|
||||
`rerouteId`. Each store enforces its own key shape; there is no single shared
|
||||
entity-ID type across stores.
|
||||
|
||||
Note: `graphId` is a scope identifier. It identifies which graph an entity
|
||||
belongs to and forms the prefix of `WidgetId`. Subgraphs are nodes with a
|
||||
@@ -194,15 +177,14 @@ target_id, target_slot, type"]
|
||||
B5["resolve()"]
|
||||
end
|
||||
|
||||
subgraph After["target-slot-keyed topology (linkStore) + unextracted state"]
|
||||
subgraph After["linkId-keyed components (layoutStore)"]
|
||||
direction TB
|
||||
A1["LinkTopology — SHIPPED
|
||||
{ id, originNodeId, originSlot,
|
||||
targetNodeId, targetSlot, type, parentId? }
|
||||
keyed by targetNodeId:targetSlot"]
|
||||
A2["LinkVisual — not yet extracted
|
||||
A1["LinkEndpoints
|
||||
{ originId, originSlot,
|
||||
targetId, targetSlot, type }"]
|
||||
A2["LinkVisual
|
||||
{ color, path, centerPos }"]
|
||||
A3["LinkState — not yet extracted
|
||||
A3["LinkState
|
||||
{ dragging, data }"]
|
||||
end
|
||||
|
||||
@@ -216,26 +198,6 @@ keyed by targetNodeId:targetSlot"]
|
||||
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
`LinkTopology` has shipped in `src/stores/linkStore.ts`: `LLink._state` IS the
|
||||
store entry — the class fields are accessors over the store's reactive proxy,
|
||||
so the store and the instance cannot disagree. Registration is first-wins with
|
||||
identity-checked delete/update. See
|
||||
[link-topology-store.md](link-topology-store.md) for the full design record.
|
||||
`LinkVisual` and `LinkState` remain on the `LLink` class.
|
||||
|
||||
### Reroute: RerouteChain (shipped)
|
||||
|
||||
Reroutes follow the same pattern. `RerouteChain { id, parentId?, floating? }`
|
||||
lives in `src/stores/rerouteStore.ts`, keyed by `RerouteId` in
|
||||
root-graph-scoped buckets; `Reroute._chain` is the store entry. Link
|
||||
membership (`Reroute.linkIds` / `floatingLinkIds`) is **not stored** — it is
|
||||
derived per root graph by a cached computed reverse index walking the links'
|
||||
`parentId` chains, replacing ~10 hand-maintained write sites and the
|
||||
`validateLinks` set-repair. See
|
||||
[reroute-chain-store.md](reroute-chain-store.md). Reroute _position_ is not
|
||||
yet migrated: `Reroute.posInternal` remains the source of truth, with the
|
||||
layout store holding a partial `{ id, position }` mirror.
|
||||
|
||||
### Widget: Before vs After
|
||||
|
||||
```mermaid
|
||||
@@ -286,8 +248,8 @@ graph TD
|
||||
direction TB
|
||||
CS["ConnectivitySystem
|
||||
Manages link/slot mutations.
|
||||
Writes: LinkTopology (shipped),
|
||||
SlotConnection (future), Connectivity"]
|
||||
Writes: LinkEndpoints, SlotConnection,
|
||||
Connectivity"]
|
||||
VS["VersionSystem
|
||||
Centralizes change tracking.
|
||||
Replaces 15+ scattered _version++.
|
||||
@@ -350,11 +312,9 @@ graph LR
|
||||
Exe["Execution"]
|
||||
Props["Properties"]
|
||||
WC["WidgetContainer"]
|
||||
LE["LinkTopology
|
||||
(linkStore — shipped)"]
|
||||
LE["LinkEndpoints"]
|
||||
LV["LinkVisual"]
|
||||
SC["SlotConnection
|
||||
(output side — future)"]
|
||||
SC["SlotConnection"]
|
||||
SV["SlotVisual"]
|
||||
WVal["WidgetValue"]
|
||||
WL["WidgetLayout"]
|
||||
@@ -373,7 +333,7 @@ graph LR
|
||||
LS -.->|read| WC
|
||||
|
||||
CS -->|write| LE
|
||||
CS -.->|future write| SC
|
||||
CS -->|write| SC
|
||||
CS -->|write| Con
|
||||
|
||||
ES -.->|read| Con
|
||||
@@ -389,14 +349,6 @@ graph LR
|
||||
VS -.->|read| Con
|
||||
```
|
||||
|
||||
ConnectivitySystem's `LinkEndpoints` write target is realized as
|
||||
`LinkTopology` in `linkStore`. The input side of `SlotConnection`
|
||||
(`input.link`) is subsumed by the linkStore key itself — "which link targets
|
||||
this input slot" is the store's primary index
|
||||
(`isInputSlotConnected` / `getInputSlotLink`) — though the `input.link` slot
|
||||
mirror still exists on the class. The output side (`output.links[]`) remains
|
||||
future extraction work.
|
||||
|
||||
## 4. Dependency Flow
|
||||
|
||||
### Before: Tangled References
|
||||
|
||||
@@ -66,7 +66,7 @@ graph TD
|
||||
Link -.->|"origin_id, target_id"| Node
|
||||
Link -.->|"parentId"| Reroute
|
||||
Slot -.->|"link / links[]"| Link
|
||||
Reroute -.->|"linkIds (derived, rerouteStore)"| Link
|
||||
Reroute -.->|"linkIds"| Link
|
||||
Reroute -.->|"parentId"| Reroute
|
||||
Group -.->|"_children Set"| Node
|
||||
Group -.->|"_children Set"| Reroute
|
||||
@@ -103,14 +103,10 @@ type: ISlotType"]
|
||||
Link -.->|"parentId"| R1["Reroute A"]
|
||||
R1 -.->|"parentId"| R2["Reroute B"]
|
||||
|
||||
R1 -.-|"linkIds (derived)"| Link
|
||||
R2 -.-|"linkIds (derived)"| Link
|
||||
R1 -.-|"linkIds Set"| Link
|
||||
R2 -.-|"linkIds Set"| Link
|
||||
```
|
||||
|
||||
`Reroute.linkIds` / `floatingLinkIds` are read-only accessors derived from the
|
||||
links' own `parentId` chains by `rerouteStore` — membership is never stored
|
||||
(see [reroute-chain-store.md](reroute-chain-store.md)).
|
||||
|
||||
### Subgraph Boundary Connections
|
||||
|
||||
```mermaid
|
||||
@@ -146,20 +142,14 @@ graph TD
|
||||
```mermaid
|
||||
graph LR
|
||||
Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink
|
||||
origin or target = UNASSIGNED_NODE_ID"]
|
||||
origin_id=-1 or target_id=-1"]
|
||||
FL -->|"stored in"| FLMap["graph.floatingLinks Map"]
|
||||
FL -->|"registered in"| SideSet["linkStore unkeyed side set
|
||||
(no unique target slot)"]
|
||||
FL -.->|"may pass through"| Reroute
|
||||
Reroute -.-|"floatingLinkIds (derived)"| FL
|
||||
Reroute -.-|"floatingLinkIds Set"| FL
|
||||
FL -->|"on drop"| Permanent["Permanent LLink
|
||||
(graph._links + linkStore target index)"]
|
||||
(registered in graph._links)"]
|
||||
```
|
||||
|
||||
A floating link's slot attachment is fully encoded in its own endpoints —
|
||||
slots hold no floating-link sets (`slotFloatingLinks()` in `LLink.ts` derives
|
||||
attachment by scanning `graph.floatingLinks`).
|
||||
|
||||
## 3. Rendering
|
||||
|
||||
How LGraphCanvas draws each entity type.
|
||||
@@ -264,7 +254,7 @@ stateDiagram-v2
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: node.connect() or connectSlots()
|
||||
Created --> Registered: graph._addLink(link)
|
||||
Created --> Registered: graph._links.set(id, link)
|
||||
|
||||
state Registered {
|
||||
[*] --> Active
|
||||
@@ -278,18 +268,12 @@ stateDiagram-v2
|
||||
|
||||
note right of Created
|
||||
new LLink(id, type, origin, slot, target, slot)
|
||||
_addLink sets graph._links entry and
|
||||
registers topology in linkStore
|
||||
(keyed by target input slot).
|
||||
Output slot.links[] updated.
|
||||
Input slot.link set.
|
||||
end note
|
||||
|
||||
note right of Removed
|
||||
Removed from graph._links and
|
||||
unregistered from linkStore
|
||||
(via _removeLink or link.disconnect).
|
||||
Link geometry dropped from layoutStore.
|
||||
Removed from graph._links.
|
||||
Orphaned reroutes cleaned up.
|
||||
graph._version incremented.
|
||||
end note
|
||||
@@ -378,10 +362,6 @@ graph TD
|
||||
WVS["WidgetValueStore
|
||||
(Pinia)"]
|
||||
PES["PreviewExposureStore
|
||||
(Pinia)"]
|
||||
LKS["LinkStore
|
||||
(Pinia)"]
|
||||
RRS["RerouteStore
|
||||
(Pinia)"]
|
||||
LM["LayoutMutations
|
||||
(composable)"]
|
||||
@@ -403,20 +383,10 @@ lastRerouteId, lastGroupId)"]
|
||||
SGNode -->|"host-scoped preview exposures"| PES
|
||||
PES -.->|"keyed by host node locator"| SGNode
|
||||
|
||||
%% LinkStore
|
||||
Graph -->|"_addLink()/_removeLink() register/unregister"| LKS
|
||||
Link <-->|"_state IS the store entry (endpoint accessors)"| LKS
|
||||
LKS -.->|"keyed by rootGraphId + targetNodeId:targetSlot"| Link
|
||||
|
||||
%% RerouteStore
|
||||
Graph -->|"_addReroute()/_removeReroute()"| RRS
|
||||
Reroute <-->|"_chain (parentId, floating)"| RRS
|
||||
RRS -.->|"derived linkIds membership"| Reroute
|
||||
|
||||
%% LayoutMutations (geometry only)
|
||||
%% LayoutMutations
|
||||
Node -->|"pos/size setter"| LM
|
||||
Reroute -->|"move()"| LM
|
||||
Link -->|"disconnect(): drop link geometry"| LM
|
||||
Link -->|"connectSlots()/disconnect()"| LM
|
||||
Graph -->|"add()/remove()"| LM
|
||||
|
||||
%% Graph state
|
||||
|
||||
@@ -168,24 +168,12 @@ No central mechanism exists. It's easy to forget an increment (stale render) or
|
||||
|
||||
Domain objects call Pinia composables at the module level or in methods, creating implicit dependencies on the Vue runtime:
|
||||
|
||||
- `Reroute.ts:31` — `const layoutMutations = useLayoutMutations()` (module scope)
|
||||
- `LLink.ts:7` — imports the `layoutStore` singleton at module scope; `useLinkStore()` is called inside methods and helpers (needs an active Pinia, but not eagerly at import time)
|
||||
- `Reroute.ts` — `useRerouteStore()` called inside methods for derived membership
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()` (module scope)
|
||||
- `Reroute.ts` — same pattern at module scope
|
||||
- `BaseWidget.ts` — imports `useWidgetValueStore`
|
||||
|
||||
These make the domain objects untestable without a Vue app context.
|
||||
|
||||
### Reroute Membership Dual-Writes (solved)
|
||||
|
||||
`Reroute.linkIds` / `floatingLinkIds` were hand-maintained `Set`s written from
|
||||
~10 scattered call sites (connect, disconnect, paste, configure, subgraph
|
||||
pack/unpack, ...), with `Reroute.validateLinks()` repairing the inevitable
|
||||
drift on load. Membership is now derived from the links' own `parentId`
|
||||
chains via `rerouteStore` — the accessors are read-only, the write sites and
|
||||
`validateLinks` are deleted, and orphaned reroutes are pruned by the derived
|
||||
`totalLinks === 0` instead of set-repair. See
|
||||
[reroute-chain-store.md](reroute-chain-store.md) (Decision 1).
|
||||
|
||||
### Change Notification Sprawl
|
||||
|
||||
`beforeChange()` and `afterChange()` (undo/redo checkpoints) are called from
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# Link Topology Store
|
||||
|
||||
Date: 2026-07-05 (retroactive design record; implemented in PR #13436)
|
||||
Status: Accepted
|
||||
|
||||
Design record for extracting link topology into a dedicated store per
|
||||
[ADR 0008](../adr/0008-entity-component-system.md). Amends the
|
||||
`LinkEndpoints` component described there. The
|
||||
[Reroute Chain Store](reroute-chain-store.md) builds directly on this
|
||||
store; shared vocabulary lives in the
|
||||
[Domain Glossary](domain-glossary.md).
|
||||
|
||||
## Decision 1: One state object, class reads through it
|
||||
|
||||
`LLink` no longer owns copies of its topology fields. A single plain
|
||||
object,
|
||||
|
||||
```
|
||||
LinkTopology { id, originNodeId, originSlot, targetNodeId, targetSlot,
|
||||
type, parentId? }
|
||||
```
|
||||
|
||||
backs the link: `LLink._state` holds it, and `id`, `type`, `origin_id`,
|
||||
`origin_slot`, `target_id`, `target_slot`, and `parentId` are accessors
|
||||
over it. Registration inserts that same object into the store by
|
||||
reference and re-assigns `_state` to the reactive proxy read back from
|
||||
the bucket, so subsequent class writes are Vue-tracked (the `BaseWidget`
|
||||
pattern — see Decision 4 of the reroute chain store record). There is no
|
||||
store-side copy to drift from the class: the store entry _is_ the
|
||||
class's state.
|
||||
|
||||
The store is runtime state only; `LLink.asSerialisable` reads the same
|
||||
fields it always did, and serialization goldens (key order plus
|
||||
byte-identical round-trips) pin the wire format.
|
||||
|
||||
## Decision 2: Keyed by target input slot, not link id
|
||||
|
||||
The primary index is keyed by `` `${targetNodeId}:${targetSlot}` ``.
|
||||
Two facts make this the right key:
|
||||
|
||||
- **The domain invariant**: at most one live link targets a given input
|
||||
slot. The key is unique by construction for live links.
|
||||
- **The dominant query**: consumers ask "is this input slot connected,
|
||||
and by what?" (`isInputSlotConnected`, `getInputSlotLink`). The key
|
||||
answers it in one lookup with no scan.
|
||||
|
||||
Link _ids_ are only unique per owning graph, not per root graph, so an
|
||||
id-keyed root bucket needed a load-time link-id dedup pass and a
|
||||
first-wins registration protocol to survive collisions across sibling
|
||||
subgraph definitions. Re-keying by target slot deleted both: colliding
|
||||
link ids never touch the index, so workflows load without link-id
|
||||
rewrites.
|
||||
|
||||
Rejected: keeping the id key plus dedup/first-wins. That machinery
|
||||
existed only to compensate for a key the queries never used.
|
||||
|
||||
## Decision 3: Root-graph-scoped buckets, unkeyed side set
|
||||
|
||||
Buckets are scoped by `rootGraph.id` — subgraphs share their root's
|
||||
bucket — matching `widgetValueStore` and the later `rerouteStore`.
|
||||
Re-keying entries to their owning graph was evaluated and rejected: it
|
||||
reintroduces per-graph lifecycle bookkeeping the root scope avoids, and
|
||||
no query wants owning-graph granularity that `graphTopologies` filtering
|
||||
doesn't already provide.
|
||||
|
||||
Links without a unique live target go in a per-graph side `Set` instead
|
||||
of the primary index:
|
||||
|
||||
- **Floating links** — exactly one assigned endpoint; the other is
|
||||
`UNASSIGNED_NODE_ID`. A floating link attached to an input slot does
|
||||
not answer `isInputSlotConnected`, preserving `input.link` semantics.
|
||||
- **Links targeting `SUBGRAPH_OUTPUT_ID`** — the id is a shared
|
||||
constant, so `targetNodeId:targetSlot` is not unique across the
|
||||
subgraphs sharing a root bucket.
|
||||
|
||||
## Decision 4: Registration protocol
|
||||
|
||||
- `registerLink` is **first-wins**: if a different topology already
|
||||
holds the target key, the call returns `undefined` and the loser
|
||||
stays detached. `link._graphId` records a won registration; it is the
|
||||
ownership marker that lets `unregisterLink` and re-registration no-op
|
||||
safely for losers.
|
||||
- `deleteLink` and `updateEndpoint` are **identity-checked** (`toRaw`
|
||||
comparison): only the registered topology can vacate or re-key its
|
||||
slot.
|
||||
- `updateEndpoint` re-keys atomically — displace, patch fields through
|
||||
a reactive wrapper, re-place under the new key — and returns
|
||||
`undefined` when the new target is already occupied. The `reactive()`
|
||||
wrap stays even though registered links already hold the proxy: the
|
||||
store is public API and may be handed a raw topology object.
|
||||
|
||||
## Decision 5: Mutation chokepoints
|
||||
|
||||
All `graph._links` map mutation funnels through `LGraph._addLink` /
|
||||
`_removeLink`, which pair the map write with store
|
||||
registration/unregistration (and link-layout cleanup on removal).
|
||||
`addFloatingLink` / `removeFloatingLink` do the same for the floating
|
||||
map. `LLink.disconnect` performs the equivalent effects inline because
|
||||
it only holds a `LinkNetwork`, and unregisters before reroute pruning so
|
||||
derived reroute counts exclude the dying link. `clear()` and
|
||||
subgraph-definition GC unregister whole graphs
|
||||
(`unregisterAllLinkTopologies` / `clearGraph`).
|
||||
|
||||
## Scope
|
||||
|
||||
This design covers link topology (endpoints, type, chain terminus).
|
||||
Link visual state (`color`, path caches) and the layout store's link
|
||||
_geometry_ records are out of scope. The `input.link` / `output.links`
|
||||
slot mirrors remain the litegraph-native representation un-migrated
|
||||
consumers read; extracting them is the `SlotConnection` component work
|
||||
in the [ECS migration plan](ecs-migration-plan.md), not part of this
|
||||
store.
|
||||
@@ -1,168 +0,0 @@
|
||||
# Node Badge Store
|
||||
|
||||
Date: 2026-07-05 (updated 2026-07-06)
|
||||
Status: Partially implemented — decisions 1–4 shipped as
|
||||
`src/stores/nodeBadgeStore.ts` + `src/systems/badgeSystem.ts` (slice A);
|
||||
decision 5 and the open decisions below await the consumer-cutover PR
|
||||
(slice B). Follow-up to the
|
||||
[link topology store](link-topology-store.md),
|
||||
[reroute chain store](reroute-chain-store.md), and the
|
||||
[node data store draft](node-data-store.md)
|
||||
|
||||
Design record for extracting node badges off `LGraphNode` instances into
|
||||
a dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
|
||||
going straight to plain-data components — no interim closure storage.
|
||||
Vocabulary: [domain glossary § Badges](domain-glossary.md#badges).
|
||||
|
||||
## Current state (what this replaces)
|
||||
|
||||
`LGraphNode.badges: (LGraphBadge | (() => LGraphBadge))[]` mixes three
|
||||
things: a **core badge closure** (id / lifecycle / source, re-derived
|
||||
independently by the Vue renderer, which skips it positionally via
|
||||
`slice(1)`), **credits badge closures** (`creditsBadgeGetter`,
|
||||
`buildWrapperAwarePriceBadge` — hand-rolled reactive computeds the
|
||||
legacy canvas polls per frame), and a public push surface for
|
||||
extensions. Badge changes are announced by a single manual
|
||||
`node:property:changed` trigger in `usePriceBadge`; plain pushes are
|
||||
invisible. Credits badges are classified by icon identity
|
||||
(`icon.image === componentIconSvg`). Badges never serialize (verified:
|
||||
no `badges` key in `serialize()`/`configure`).
|
||||
|
||||
Every closure is a reactive computed feeding an unreactive array, and
|
||||
the Vue renderer has already re-implemented all of their reactivity as
|
||||
store-tracked dependencies. The design deletes the closures and makes
|
||||
the store rows the single truth both renderers read.
|
||||
|
||||
## Decision 1: Plain `BadgeData` rows, no interim shape
|
||||
|
||||
The store holds only plain data (ADR 0008 component rule):
|
||||
|
||||
```
|
||||
BadgeData {
|
||||
kind: 'core' | 'credits' | 'extension'
|
||||
text: string
|
||||
fgColor?: string
|
||||
bgColor?: string
|
||||
iconKey?: string // resolved via a small icon registry ('credits' → SVG)
|
||||
}
|
||||
```
|
||||
|
||||
No `onClick` (no producer exists; `LGraphButton`/`title_buttons` are a
|
||||
separate surface and out of scope). No `Image` objects — icons are
|
||||
referenced by key. A `commandId` field can be added if a clickable badge
|
||||
requirement ever materialises.
|
||||
|
||||
Rejected: an interim `BadgeEntry { kind, source: LGraphBadge | thunk }`
|
||||
phase. The public element type of `node.badges` breaks either way; one
|
||||
break that lands on the end-state shape beats two.
|
||||
|
||||
## Decision 2: Store shape and keying
|
||||
|
||||
`nodeBadgeStore`: root-graph-scoped buckets (`rootGraph.id`) keyed by
|
||||
`NodeId`, each holding an ordered reactive `BadgeData[]`. Register /
|
||||
unregister / unregister-all trio at the `LGraph.add` / `LGraph.remove` /
|
||||
`clear()` chokepoints, identity-checked deletes — the shipped store
|
||||
conventions.
|
||||
|
||||
Rows are partitioned by `kind` at read time; the positional `slice(1)`
|
||||
convention and icon-identity credits detection are deleted. Display
|
||||
order is kind order (core, credits, extension), not insertion order.
|
||||
|
||||
## Decision 3: A reactive BadgeSystem writes the rows
|
||||
|
||||
One system module owns the recomputation: per registered node, an
|
||||
`effectScope` runs a pure `computeBadges(sources) → BadgeData[]`
|
||||
function inside a thin watch shell and writes the node's rows. Sources
|
||||
are the existing stores: `nodeDefStore`, `settingStore`,
|
||||
`colorPaletteStore`, `useNodePricing` revision refs, `widgetValueStore`,
|
||||
`linkStore` input connectivity. The pure function is the future
|
||||
command-pipeline phase body (ADR 0003 systems ADR); only the scheduler
|
||||
shell changes when that lands.
|
||||
|
||||
`useNodeBadge` / `usePriceBadge` stop pushing closures; their derivation
|
||||
logic moves into `computeBadges`. The manual `'badges'`
|
||||
`node:property:changed` trigger, the `badges` case in
|
||||
`useGraphNodeManager`, and `VueNodeData.badges` are deleted.
|
||||
`usePartitionedBadges` collapses to a store query partitioned by kind;
|
||||
its manual dependency-touching (`trackNodePrice`,
|
||||
`trackSubgraphInnerNodePrices`) moves inside the system.
|
||||
|
||||
## Decision 4: Core badges are system-written rows too
|
||||
|
||||
Core (#id / lifecycle / source) badges are materialized by the system
|
||||
like every other kind, so both renderers consume one uniform row set.
|
||||
This kills the current dual derivation (legacy closure + Vue-side
|
||||
re-derivation). Materialized-by-system is the ECS write path, not a
|
||||
mirror: no other component stores this projection.
|
||||
|
||||
## Decision 5: Legacy canvas consumes rows via a draw cache
|
||||
|
||||
`drawBadges` renders from `BadgeData`, constructing and caching
|
||||
`LGraphBadge` draw objects keyed by row content (the `Reroute` id-badge
|
||||
pattern, memoized). `_boundingRect` hit-test state stays renderer-side.
|
||||
Frame-budget parity per ADR 0008's render mitigations applies.
|
||||
|
||||
## Implementation notes (slice A)
|
||||
|
||||
- Registration is bucket-key presence: the `LGraph.add`/`remove`/`clear`
|
||||
chokepoints call the import-light store trio
|
||||
(`registerNode`/`unregisterNode`/`clearGraph`), and the system watches
|
||||
`registeredNodeIds` to attach/detach per-node effect scopes. Litegraph
|
||||
never imports the system, so the pricing/nodeDef dependency graph
|
||||
(which runtime-imports the litegraph barrel) stays acyclic. The system
|
||||
takes a `resolveNode` seam and will be bootstrapped at the app layer
|
||||
in slice B, when the `Comfy.NodeBadge` extension stops pushing
|
||||
closures; until then rows are written only under test. Row writes are
|
||||
refused for unregistered nodes so a late effect flush cannot
|
||||
resurrect a bucket key the chokepoints deleted.
|
||||
- Two write paths: `setBadgesOfKind` is the system's bulk
|
||||
replace-one-kind recompute path (`@internal`); extension rows go
|
||||
through `registerBadge`/`deleteBadge` per row, identity-checked, so
|
||||
independent writers cannot stomp each other.
|
||||
- The shell still reads `node.constructor.nodeData` and `node.inputs`
|
||||
(untracked instance state) to map pricing input names to slot
|
||||
indices — parity with the legacy closures. Those reads become store
|
||||
lookups when slot data is store-backed (node data store draft).
|
||||
- Core rows are fine-grained — one row per part in lifecycle, id, source
|
||||
order — with one visibility rule (`badgeTextVisible`, the legacy
|
||||
semantics: `HideBuiltIn` respected for every part). Joining,
|
||||
bracket decoration, and truncation are renderer presentation and move
|
||||
to the slice-B legacy draw cache. Rows store lifecycle text with the
|
||||
`[]` brackets trimmed. Known unification effects: the Vue renderer
|
||||
gains `HideBuiltIn` handling for id badges, and core nodes' `🦊`
|
||||
source row becomes data the Vue partition may substitute with its
|
||||
Comfy-logo chip.
|
||||
- A credits row is emitted only when the display price label is
|
||||
non-empty; async pricing fills it via the per-node revision ref.
|
||||
- Subgraph credits aggregation is not yet in the system — it stays on
|
||||
the `updateSubgraphCredits` closure path until open decision 3 below
|
||||
is resolved.
|
||||
|
||||
## Open decisions (interview pending)
|
||||
|
||||
1. **Legacy `node.badges` surface** — recommended: a converting
|
||||
accessor exposing the node's `BadgeData[]` proxy; bare
|
||||
`LGraphBadge`/thunk pushes auto-convert (thunk evaluated once,
|
||||
`Image` icons dropped) with a one-time deprecation warning pointing
|
||||
at the store API. Alternatives: delete the property outright, or a
|
||||
read-only view. Ecosystem scan found zero genuine third-party
|
||||
writers; ADR 0008 extension-impact guidance applies regardless.
|
||||
2. **`node.badgePosition`** — recommended: delete (single writer sets a
|
||||
constant `TopRight` on every node; single reader; renderer policy,
|
||||
not badge data). Deprecated accessor warns on write.
|
||||
3. **Subgraph credits aggregation triggers** — recommended: the three
|
||||
existing events (`litegraph:set-graph`, `subgraph-converted`,
|
||||
`afterConfigureGraph`) bump a revision the system watches; swap to
|
||||
reactive `SubgraphStructure` dependencies when that state is
|
||||
store-backed.
|
||||
|
||||
## Scope and sequencing
|
||||
|
||||
Slices: (A) store + `BadgeData` + system for core and credits +
|
||||
chokepoint registration; (B) consumer cutover — Vue partition query,
|
||||
legacy draw cache, trigger/`VueNodeData.badges` deletions, legacy
|
||||
surface shim; extension-facing deprecation notes. Independent of the
|
||||
pending `nodeDataStore` extraction and lands before it, shrinking its
|
||||
Decision 4/6 scope (one less `VueNodeData` field, one less property
|
||||
handler). `PartnerNodesList`'s `find(isCreditsBadge)` migrates to a
|
||||
store query by kind.
|
||||
@@ -1,145 +0,0 @@
|
||||
# Node Data Store
|
||||
|
||||
Date: 2026-07-05
|
||||
Status: Draft (design interview in progress; follow-up to the
|
||||
[link topology store](link-topology-store.md) and
|
||||
[reroute chain store](reroute-chain-store.md))
|
||||
|
||||
Design record for extracting the remaining Node-owned components into a
|
||||
dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
|
||||
eliminating the `VueNodeData` mirror and most of
|
||||
`src/composables/graph/useGraphNodeManager.ts`.
|
||||
|
||||
## Decision 1: One store, one plain state object per node
|
||||
|
||||
`nodeDataStore` holds a single plain `NodeState` object per node,
|
||||
registered by reference with proxy-returning registration (the
|
||||
`BaseWidget` pattern, [reroute store Decision 4](reroute-chain-store.md)).
|
||||
ADR 0008's Node component rows (`NodeVisual`, `Execution`, ...) become
|
||||
field groupings inside `NodeState`, not separate records or stores.
|
||||
|
||||
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `NodeId`.
|
||||
Node-id uniqueness across sibling subgraph definitions is already
|
||||
guaranteed by the load-time dedup pass
|
||||
(`src/lib/litegraph/src/subgraph/subgraphDeduplication.ts`).
|
||||
|
||||
## Decision 2: Field set — what is NodeState, what is elsewhere
|
||||
|
||||
```
|
||||
NodeState {
|
||||
id: NodeId
|
||||
graphId: UUID // owning (sub)graph — partitioning + locator ids
|
||||
type: string // identity, with apiNode?: boolean
|
||||
title: string
|
||||
titleMode?: TitleMode
|
||||
mode: number
|
||||
flags: { collapsed?, pinned?, ghost? }
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
shape?: number
|
||||
resizable?: boolean
|
||||
showAdvanced?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Excluded — owned or derived elsewhere; referencing them here would be a
|
||||
mirror (the hard constraint of this phase):
|
||||
|
||||
| Field | Owner |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `selected` | `canvasStore.selectedNodeIds` (already what `LGraphNode.vue` reads) |
|
||||
| `executing` | `executionStore` via `useNodeExecutionState` (already what Vue reads) |
|
||||
| `hasErrors` | derived from `executionErrorStore` / missing-model/media stores; `node.has_errors` stays a legacy-canvas class field written by `useNodeErrorFlagSync` |
|
||||
| position / size / z | `layoutStore` |
|
||||
| widget values / order | `widgetValueStore` |
|
||||
| input link connectivity | `linkStore` (`getInputSlotLink` / `isInputSlotConnected`) |
|
||||
| `badges` | `nodeBadgeStore` — plain `BadgeData` rows written by a badge system; see [Node Badge Store](node-badge-store.md) |
|
||||
| `inputs` / `outputs` | deferred — see Decision 3 |
|
||||
|
||||
`VueNodeData.selected` and `.executing` are dead fields today (no
|
||||
production consumer reads them); they are deleted, not migrated.
|
||||
|
||||
## Decision 3: Slot arrays deferred; `inputs[].link` readers migrate now
|
||||
|
||||
`NodeInputSlot` / `NodeOutputSlot` are class instances with methods —
|
||||
Slot entity extraction (ADR 0008 `SlotIdentity` etc.) is its own future
|
||||
phase. The slot arrays stay class-side, keeping the `shallowReactive`
|
||||
graft for renderer reactivity.
|
||||
|
||||
What this phase does remove is the last `inputs[].link` dependency: the
|
||||
three remaining readers (`nodeDataUtils.linkedWidgetedInputs` used by
|
||||
`NodeSlots`, and `usePartitionedBadges`' badge computed plus its
|
||||
exported `trackNodePrice`) move to `linkStore.isInputSlotConnected`
|
||||
queries — presence is all any of them needed — which deletes the
|
||||
`node:slot-links:changed` → `refreshNodeInputs` reprojection in
|
||||
`useGraphNodeManager`, the dead `node:slot-errors:changed` handler
|
||||
(zero emitters repo-wide), and the node-removal refresh-all loop.
|
||||
With their last listeners gone, both trigger actions are deleted
|
||||
outright — emitters, event-map entries, and types (the badge system
|
||||
sources connectivity from `linkStore`, not events; resurrect from git
|
||||
if a consumer ever materialises). Readers get the root graph id from
|
||||
`canvasStore.rootGraphId`, the shared tracked accessor, rather than
|
||||
per-site `canvas?.graph?.rootGraph.id` chains.
|
||||
Shipped ahead of the store itself (2026-07-05).
|
||||
|
||||
## Decision 4: Renderer consumes the NodeState proxy, `VueNodeData` dies
|
||||
|
||||
`GraphCanvas` iterates the store's bucket for the active graph (filtered
|
||||
by `NodeState.graphId`) and passes the reactive `NodeState` proxy down
|
||||
the existing prop-drilling path (`LGraphNode` → `NodeHeader` /
|
||||
`NodeSlots` / `NodeContent` / `NodeWidgets`). Children read proxy fields
|
||||
directly; Vue tracks the store state, so the per-property
|
||||
`node:property:changed` → snapshot-rewrite handlers in
|
||||
`useGraphNodeManager` are deleted wholesale.
|
||||
|
||||
Slot arrays reach `NodeSlots` via the existing live-node access
|
||||
(`getNodeByLocatorId`), not through `NodeState`.
|
||||
|
||||
`LGraphNodePreview` constructs a synthetic `NodeState` (as it does a
|
||||
synthetic `VueNodeData` today). `AppModeWidgetList` stops calling
|
||||
`extractVueNodeData` and reads the registered `NodeState` + live node.
|
||||
|
||||
## Decision 5: Registration lifecycle and class adoption
|
||||
|
||||
Follows the shipped trio convention (`LLink` / `Reroute`):
|
||||
|
||||
- `LGraphNode` constructs its `_state: NodeState` at instantiation;
|
||||
`registerNodeState(graph, node)` inserts it by reference and the class
|
||||
adopts the returned reactive proxy; `node._graphId` (root id) is the
|
||||
registration-ownership marker.
|
||||
- Chokepoints: `LGraph.add` / `LGraph.remove` (the canonical sites),
|
||||
`unregisterAllNodeStates(graph)` on graph `clear()`, identity-checked
|
||||
delete (`toRaw` compare) so only the registered state vacates its key.
|
||||
- Class fields become accessors reading through `_state`.
|
||||
`LGraphNodeProperties`' instrumented descriptors keep their
|
||||
get/set + `node:property:changed` emission but store the value in
|
||||
`_state` instead of a closure — trigger consumers (minimap,
|
||||
`useErrorClearingHooks`) keep working unchanged.
|
||||
- Serialization is unaffected: `serialize()` reads the same properties
|
||||
through the accessors.
|
||||
|
||||
## Decision 6: What remains of useGraphNodeManager
|
||||
|
||||
Deleted: `extractVueNodeData`, the `vueNodeData` map, all
|
||||
`node:property:changed` handlers, `syncWithGraph`, `getNode()`
|
||||
(consumers use `graph.getNodeById`), the `node:slot-links:changed`
|
||||
handler (Decision 3).
|
||||
|
||||
Remaining renderer-side lifecycle, slimmed into `useVueNodeLifecycle`
|
||||
(or a small successor):
|
||||
|
||||
- layoutStore seeding on node add/remove (`createNode`/`deleteNode`
|
||||
layout mutations, including the `onAfterGraphConfigured` deferral) —
|
||||
layout is renderer policy, not entity data.
|
||||
- `node:slot-label:changed` slot-array reprojection — dies with the
|
||||
Slot extraction phase.
|
||||
|
||||
## Scope
|
||||
|
||||
Covers node shell state, the `VueNodeData` deletion, and the
|
||||
`inputs[].link` reader migration. Out of scope: Slot entity extraction,
|
||||
`Properties` (`properties` / `properties_info`) and `NodeType` metadata
|
||||
beyond `type`/`apiNode` (`category`, `nodeData`, `description` remain on
|
||||
the class/constructor), badges, `WidgetContainer` (already owned by
|
||||
`widgetValueStore`), and command-pattern mutators (future work per
|
||||
ADR 0003/0008).
|
||||
@@ -6,36 +6,20 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Nine dedicated stores extract entity state out of class instances into focused,
|
||||
Six dedicated stores extract entity state out of class instances into focused,
|
||||
queryable registries, each owning one concern. Promoted value-widget topology is
|
||||
no longer a store; ADR 0009 represents it as ordinary linked `SubgraphInput`
|
||||
state, and promoted value data lives in `WidgetValueStore` keyed by the input's
|
||||
`WidgetId`.
|
||||
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ---------------------------- | ----------------- | --------------------------------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link geometry, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
|
||||
| LinkStore | `LLink` | Root graph | `` `${targetNodeId}:${targetSlot}` `` (target input slot) | Plain `LinkTopology` object |
|
||||
| RerouteStore | `Reroute` | Root graph | `RerouteId` | Plain `RerouteChain` object |
|
||||
| NodeBadgeStore | `LGraphNode.badges` closures | Root graph | `NodeId` | Plain `BadgeData` rows |
|
||||
|
||||
**Update (2026-07-05):** `LinkStore` (`src/stores/linkStore.ts`, PR #13436) and
|
||||
`RerouteStore` (`src/stores/rerouteStore.ts`, PR #13449) hold plain-data records
|
||||
in reactive `Map` buckets — not Y.js — scoped by root graph (subgraphs share
|
||||
their root's bucket). Floating links and links targeting subgraph outputs live
|
||||
in a per-graph unkeyed side set. Design records:
|
||||
[Link Topology Store](link-topology-store.md),
|
||||
[Reroute Chain Store](reroute-chain-store.md).
|
||||
|
||||
**Update (2026-07-14):** `NodeBadgeStore` (`src/stores/nodeBadgeStore.ts`,
|
||||
PR #13458) holds plain `BadgeData` rows keyed by `NodeId` in root-graph-scoped
|
||||
buckets, written by a reactive badge system rather than adopted class state.
|
||||
Design record: [Node Badge Store](node-badge-store.md).
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------- | ---------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
@@ -165,37 +149,28 @@ The most architecturally advanced extraction — uses Y.js CRDTs for collaborati
|
||||
|
||||
```
|
||||
ynodes: Y.Map<NodeLayoutMap> // nodeId → { pos, size, zIndex, bounds }
|
||||
yreroutes: Y.Map<Y.Map<...>> // rerouteId → { id, position }
|
||||
ylinks: Y.Map<Y.Map<...>> // linkId → link layout data
|
||||
yreroutes: Y.Map<Y.Map<...>> // rerouteId → reroute layout data
|
||||
```
|
||||
|
||||
**Update (2026-07-05):** The link-connectivity mirror (`ylinks`, `LinkData`,
|
||||
`createLink`/`removeLink` mutations, `findLinksConnectedToNode`) was deleted
|
||||
when link topology moved to `LinkStore` (PR #13436). LayoutStore now owns only
|
||||
link/segment _geometry_ caches, and `RerouteData` carries `{ id, position }`
|
||||
only — the write-only `parentId`/`linkIds` fields were removed.
|
||||
|
||||
### Write API
|
||||
|
||||
`useLayoutMutations()` (`src/renderer/core/layout/operations/layoutMutations.ts`) provides the mutation API:
|
||||
|
||||
- `moveNode(nodeId, pos)` / `batchMoveNodes(...)`
|
||||
- `resizeNode(nodeId, size)`
|
||||
- `setNodeZIndex(nodeId, zIndex)` / `bringNodeToFront(nodeId)`
|
||||
- `createNode(nodeId, layout)` / `deleteNode(nodeId)`
|
||||
- `createReroute(rerouteId, pos)` / `deleteReroute(rerouteId)` /
|
||||
`moveReroute(rerouteId, pos, prevPos)`
|
||||
|
||||
(`createLink`/`removeLink` are gone — link topology is `LinkStore`'s concern.)
|
||||
- `moveNode(graphId, nodeId, pos)`
|
||||
- `resizeNode(graphId, nodeId, size)`
|
||||
- `setNodeZIndex(graphId, nodeId, zIndex)`
|
||||
- `createLink(graphId, linkId, ...)`
|
||||
- `removeLink(graphId, linkId)`
|
||||
- `moveReroute(graphId, rerouteId, pos)`
|
||||
|
||||
### The Scattered Access Problem
|
||||
|
||||
This composable is called at **module scope** in domain objects:
|
||||
|
||||
- `Reroute.ts:31` — `const layoutMutations = useLayoutMutations()`
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()`
|
||||
- `Reroute.ts` — same pattern
|
||||
- `LGraphNode.ts` — imported and called in methods
|
||||
- `LLink.ts` no longer uses `useLayoutMutations` (its layout writes went away
|
||||
with the `ylinks` mirror), but it still imports `layoutStore` and
|
||||
`useLinkStore` at module scope
|
||||
|
||||
These module-scope calls create implicit dependencies on the Vue runtime and make the domain objects untestable without a full app context.
|
||||
|
||||
@@ -259,14 +234,12 @@ graph TD
|
||||
|
||||
Each store owns the identity scheme that fits its concern:
|
||||
|
||||
| Store | Key Format | Key Type | Type-Safe? |
|
||||
| ---------------- | ----------------------------------------------------------- | ------------------ | ----------------- |
|
||||
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| LinkStore | `` `${targetNodeId}:${targetSlot}` `` (root-scoped buckets) | Composite string | No |
|
||||
| RerouteStore | `RerouteId` (root-scoped buckets) | branded number | Yes (`RerouteId`) |
|
||||
| Store | Key Format | Key Type | Type-Safe? |
|
||||
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
|
||||
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
`WidgetValueStore` already keys on a branded `WidgetId` string (`src/types/widgetId.ts`),
|
||||
which carries its scope and survives renames at the store layer. The remaining
|
||||
@@ -313,26 +286,22 @@ graph TD
|
||||
|
||||
subgraph Link["LLink"]
|
||||
L_ext["Extracted:
|
||||
- id, endpoints, type, parentId → LinkStore
|
||||
(LLink._state IS the store entry;
|
||||
fields are accessors over it)
|
||||
- segment geometry → LayoutStore"]
|
||||
- layout data → LayoutStore"]
|
||||
L_rem["Remains on class:
|
||||
- color, path, _pos, _centreAngle
|
||||
- origin_id, target_id
|
||||
- origin_slot, target_slot
|
||||
- type, color, path
|
||||
- data, _dragging
|
||||
- disconnect(), resolve()"]
|
||||
end
|
||||
|
||||
subgraph Reroute["Reroute"]
|
||||
R_ext["Extracted:
|
||||
- pos → LayoutStore (partial mirror;
|
||||
posInternal still truth)
|
||||
- parentId, floating → RerouteStore
|
||||
- linkIds, floatingLinkIds → derived
|
||||
from links' parentId chains"]
|
||||
- pos → LayoutStore"]
|
||||
R_rem["Remains on class:
|
||||
- posInternal (position truth)
|
||||
- colour, draw()
|
||||
- parentId, linkIds
|
||||
- floatingLinkIds
|
||||
- color, draw()
|
||||
- findSourceOutput()"]
|
||||
end
|
||||
|
||||
@@ -377,19 +346,15 @@ graph TD
|
||||
|
||||
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
|
||||
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | endpoints, type, parentId (LinkStore, via `_state` proxy); segment geometry (LayoutStore) | visual (color, path), drag state, connectivity methods | LinkEndpoints ✅, LinkVisual, LinkState | Small — topology shipped (PR #13436); visual state and slot mirrors remain |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | parentId, floating (RerouteStore); pos (LayoutStore, partial mirror) | position truth (posInternal), visual, chain traversal | Position, RerouteChain ✅, RerouteVisual | Small — chain shipped (PR #13449); position ownership and visual remain |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
|
||||
`RerouteChain` supersedes the earlier `RerouteLinks` component (ADR 0008
|
||||
amendment, 2026-07-04): link membership is never stored — it is derived from
|
||||
the links' `parentId` chains over `LinkStore`.
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
|
||||
### Priority Order for Extraction
|
||||
|
||||
@@ -397,11 +362,8 @@ Based on existing progress and problem severity:
|
||||
|
||||
1. **Widget** — closest to done (value extraction complete, needs rendering/layout extraction)
|
||||
2. **Node Position** — already in LayoutStore, needs branded ID and formal component type
|
||||
3. **Link** — ✅ topology shipped (LinkStore, PR #13436); slot mirrors
|
||||
(`input.link`/`output.links`) and visual state remain
|
||||
3. **Link** — small component set, high coupling pain
|
||||
4. **Slot** — no extraction yet, but small and self-contained
|
||||
(`SlotConnection` input side now answerable via LinkStore)
|
||||
5. **Reroute** — ✅ chain shipped (RerouteStore, PR #13449); position
|
||||
ownership and visual remain
|
||||
5. **Reroute** — partially extracted, moderate complexity
|
||||
6. **Group** — no extraction, but least coupled to other entities
|
||||
7. **Subgraph** — not a separate entity kind; SubgraphStructure and SubgraphMeta become node components. Depends on Node and Link extraction first. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Reroute Chain Store
|
||||
|
||||
Date: 2026-07-04
|
||||
Status: Accepted (design review; follow-up to the
|
||||
[link topology store](link-topology-store.md), PR #13436)
|
||||
|
||||
Design record for extracting reroute connectivity state into a dedicated
|
||||
store per [ADR 0008](../adr/0008-entity-component-system.md). Amends the
|
||||
`RerouteLinks` component described there.
|
||||
|
||||
## Decision 1: The chain is the single source of truth for membership
|
||||
|
||||
"Which links pass through reroute R" is encoded twice today: each link's
|
||||
`parentId` chain (`link.parentId` names the terminal reroute;
|
||||
`reroute.parentId` walks upstream), and per-reroute `linkIds` /
|
||||
`floatingLinkIds` Sets maintained by hand at roughly ten write sites
|
||||
(`LGraphNode.connect`, `LLink.disconnect`, `LGraph.addFloatingLink` /
|
||||
`removeFloatingLink` / `createReroute`, subgraph unpack,
|
||||
`SubgraphInput/Output.connect`, `LinkConnector`), with
|
||||
`Reroute.validateLinks` repairing drift at configure time.
|
||||
|
||||
The chain is primary. Membership becomes a derived reverse index:
|
||||
|
||||
```
|
||||
linksThrough(R) = { L : R ∈ chain(L.parentId) }
|
||||
```
|
||||
|
||||
computed over the link store's topologies plus the reroute chain states,
|
||||
cached, and invalidated on chain mutation. The `Reroute` class exposes
|
||||
`linkIds` / `floatingLinkIds` as derived accessors. The membership write
|
||||
sites and `validateLinks` are deleted.
|
||||
|
||||
Rejected: storing membership Sets in the store (the `LLink._state`
|
||||
pattern applied to whole membership). It keeps one _stored_ copy but
|
||||
preserves the domain-level redundancy — chain and membership can still
|
||||
disagree, and every dual write site survives.
|
||||
|
||||
## Decision 2: Store shape and keying
|
||||
|
||||
`rerouteStore` holds per-reroute chain state objects, registered by
|
||||
reference (the class reads through them, mirroring `LLink._state`):
|
||||
|
||||
```
|
||||
RerouteChain { id, parentId?, floating? }
|
||||
```
|
||||
|
||||
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `RerouteId`,
|
||||
matching `widgetValueStore` and `linkStore` scoping. Owning-graph buckets
|
||||
were evaluated and rejected for the link store (2026-07-04) and are
|
||||
rejected here for the same reasons.
|
||||
|
||||
`RerouteId` is the domain key because runtime allocation is already
|
||||
per-root-unique: `Subgraph.state` delegates to the root graph's state, so
|
||||
all graphs increment one shared `lastRerouteId` counter.
|
||||
|
||||
## Decision 3: Load-time reroute-id dedup
|
||||
|
||||
Serialized workflows from older frontends or external tools can carry
|
||||
colliding reroute ids across sibling subgraph definitions. Today this is
|
||||
tolerated only because `graph.reroutes` is a per-graph map; a root-scoped
|
||||
bucket would break on it, and the layout store's bare-`rerouteId` keying
|
||||
already collides latently.
|
||||
|
||||
On configure, colliding subgraph reroute ids are rewritten to fresh ids
|
||||
from the shared counter, patching that subgraph's `link.parentId` and
|
||||
`reroute.parentId` references — the same repair the node-id and link-id
|
||||
dedup passes already perform. Rewritten ids serialize back changed.
|
||||
|
||||
Rejected: a first-wins registration protocol (losers detached via an
|
||||
ownership flag). That is the apparatus the target-keyed link store
|
||||
redesign existed to delete.
|
||||
|
||||
## Decision 4: Registration returns the reactive proxy
|
||||
|
||||
The derived membership index requires observable chain mutation.
|
||||
`BaseWidget` already establishes the pattern: the store bucket is a
|
||||
`reactive(Map)`, registration inserts the raw state and returns the
|
||||
value read back from the map — the reactive proxy — and the class holds
|
||||
that proxy as `_state` (`BaseWidget.setNodeId`,
|
||||
`widgetValueStore.registerWidget`). Every subsequent class write goes
|
||||
through the proxy and is tracked.
|
||||
|
||||
`rerouteStore.registerReroute` follows this: it returns the proxy and
|
||||
the `Reroute` class reads and writes chain state through it, so
|
||||
`reroute.parentId` mutations are tracked with no action chokepoint.
|
||||
|
||||
`LLink` previously deviated — `registerLinkTopology` left `link._state`
|
||||
raw, which is why `linkStore.updateEndpoint` must re-wrap with
|
||||
`reactive()` before patching, and why bare `link.parentId` writes were
|
||||
invisible to effects. This migration aligns it with the `BaseWidget`
|
||||
pattern: link registration re-assigns `_state` to the store proxy,
|
||||
making `link.parentId` writes tracked. `updateEndpoint` keeps its
|
||||
`reactive()` wrap as a guard — the store is public API and may be
|
||||
handed a raw topology object.
|
||||
|
||||
## Decision 5: Serialization
|
||||
|
||||
`SerialisableReroute.linkIds` remains in the wire format, emitted from
|
||||
the derived membership in ascending link-id order. All runtime producers
|
||||
append links in ascending id order and the serialization goldens hold
|
||||
ascending arrays, so chain-consistent workflows round-trip byte-identical.
|
||||
|
||||
Workflows whose stored `linkIds` contradict their chains are repaired on
|
||||
the next save (membership re-derived, stale ids dropped, order
|
||||
normalized). A reroute that no chain reaches at all is dropped at load —
|
||||
where `validateLinks` used to preserve it if its stored ids named live
|
||||
links — because the chain is primary. No compatibility shim for such
|
||||
files. `floatingLinkIds` stays unserialized, rebuilt at runtime as
|
||||
before.
|
||||
|
||||
## Scope
|
||||
|
||||
This design covers chain state, derived membership, and the `LLink`
|
||||
proxy retrofit from Decision 4 (a small self-contained change, done
|
||||
first). Reroute visual state (`_colour`, badge) is out of scope. The
|
||||
`Reroute.pos` class field still mirrors the layout store's position;
|
||||
that pre-existing duplication is a separate concern, not addressed
|
||||
here.
|
||||
@@ -12,6 +12,11 @@ export type {
|
||||
AddAssetTagsErrors,
|
||||
AddAssetTagsResponse,
|
||||
AddAssetTagsResponses,
|
||||
AdminDeleteHubWorkflowData,
|
||||
AdminDeleteHubWorkflowError,
|
||||
AdminDeleteHubWorkflowErrors,
|
||||
AdminDeleteHubWorkflowResponse,
|
||||
AdminDeleteHubWorkflowResponses,
|
||||
Asset,
|
||||
AssetCreated,
|
||||
AssetCreatedWritable,
|
||||
@@ -42,6 +47,11 @@ export type {
|
||||
CancelJobErrors,
|
||||
CancelJobResponse,
|
||||
CancelJobResponses,
|
||||
CancelJobsData,
|
||||
CancelJobsError,
|
||||
CancelJobsErrors,
|
||||
CancelJobsResponse,
|
||||
CancelJobsResponses,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -84,6 +94,11 @@ export type {
|
||||
CreateDeletionRequestErrors,
|
||||
CreateDeletionRequestResponse,
|
||||
CreateDeletionRequestResponses,
|
||||
CreateDesktopLoginCodeData,
|
||||
CreateDesktopLoginCodeError,
|
||||
CreateDesktopLoginCodeErrors,
|
||||
CreateDesktopLoginCodeResponse,
|
||||
CreateDesktopLoginCodeResponses,
|
||||
CreateHubAssetUploadUrlData,
|
||||
CreateHubAssetUploadUrlError,
|
||||
CreateHubAssetUploadUrlErrors,
|
||||
@@ -186,12 +201,31 @@ export type {
|
||||
DeleteWorkspaceResponses,
|
||||
DeletionRequest,
|
||||
DeletionStatus,
|
||||
DesktopLoginCodeCreateRequest,
|
||||
DesktopLoginCodeCreateResponse,
|
||||
DesktopLoginCodeExchangeRequest,
|
||||
DesktopLoginCodeExchangeResponse,
|
||||
DesktopLoginCodeRedeemRequest,
|
||||
DesktopLoginCodeRedeemResponse,
|
||||
DownloadExportData,
|
||||
DownloadExportError,
|
||||
DownloadExportErrors,
|
||||
DownloadExportResponse,
|
||||
DownloadExportResponses,
|
||||
EnsureWorkspaceBillingLegacySnapshot,
|
||||
EnsureWorkspaceBillingProvisionedData,
|
||||
EnsureWorkspaceBillingProvisionedError,
|
||||
EnsureWorkspaceBillingProvisionedErrors,
|
||||
EnsureWorkspaceBillingProvisionedRequest,
|
||||
EnsureWorkspaceBillingProvisionedResponse,
|
||||
EnsureWorkspaceBillingProvisionedResponse2,
|
||||
EnsureWorkspaceBillingProvisionedResponses,
|
||||
ErrorResponse,
|
||||
ExchangeDesktopLoginCodeData,
|
||||
ExchangeDesktopLoginCodeError,
|
||||
ExchangeDesktopLoginCodeErrors,
|
||||
ExchangeDesktopLoginCodeResponse,
|
||||
ExchangeDesktopLoginCodeResponses,
|
||||
ExchangeTokenData,
|
||||
ExchangeTokenError,
|
||||
ExchangeTokenErrors,
|
||||
@@ -230,6 +264,11 @@ export type {
|
||||
GetAssetByIdErrors,
|
||||
GetAssetByIdResponse,
|
||||
GetAssetByIdResponses,
|
||||
GetAssetContentData,
|
||||
GetAssetContentError,
|
||||
GetAssetContentErrors,
|
||||
GetAssetContentResponse,
|
||||
GetAssetContentResponses,
|
||||
GetAssetSeedStatusData,
|
||||
GetAssetSeedStatusResponse,
|
||||
GetAssetSeedStatusResponses,
|
||||
@@ -303,6 +342,11 @@ export type {
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
GetHistoryErrors,
|
||||
GetHistoryEventsData,
|
||||
GetHistoryEventsError,
|
||||
GetHistoryEventsErrors,
|
||||
GetHistoryEventsResponse,
|
||||
GetHistoryEventsResponses,
|
||||
GetHistoryForPromptData,
|
||||
GetHistoryForPromptError,
|
||||
GetHistoryForPromptErrors,
|
||||
@@ -345,8 +389,6 @@ export type {
|
||||
GetJwksData,
|
||||
GetJwksResponse,
|
||||
GetJwksResponses,
|
||||
GetLegacyAssetContentData,
|
||||
GetLegacyAssetContentErrors,
|
||||
GetLegacyHistoryByIdData,
|
||||
GetLegacyHistoryByIdErrors,
|
||||
GetLegacyHistoryData,
|
||||
@@ -556,6 +598,7 @@ export type {
|
||||
HistoryDetailEntry,
|
||||
HistoryDetailResponse,
|
||||
HistoryEntry,
|
||||
HistoryEventRequest,
|
||||
HistoryManageRequest,
|
||||
HistoryResponse,
|
||||
HubAssetUploadUrlRequest,
|
||||
@@ -589,6 +632,8 @@ export type {
|
||||
JobCancelResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsCancelRequest,
|
||||
JobsCancelResponse,
|
||||
JobsListResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
@@ -627,7 +672,19 @@ export type {
|
||||
ListJobsErrors,
|
||||
ListJobsResponse,
|
||||
ListJobsResponses,
|
||||
ListLinkedFirebaseUidsData,
|
||||
ListLinkedFirebaseUidsError,
|
||||
ListLinkedFirebaseUidsErrors,
|
||||
ListLinkedFirebaseUidsRequest,
|
||||
ListLinkedFirebaseUidsResponse,
|
||||
ListLinkedFirebaseUidsResponse2,
|
||||
ListLinkedFirebaseUidsResponses,
|
||||
ListMembersResponse,
|
||||
ListSecretProvidersData,
|
||||
ListSecretProvidersError,
|
||||
ListSecretProvidersErrors,
|
||||
ListSecretProvidersResponse,
|
||||
ListSecretProvidersResponses,
|
||||
ListSecretsData,
|
||||
ListSecretsError,
|
||||
ListSecretsErrors,
|
||||
@@ -775,6 +832,17 @@ export type {
|
||||
QueueInfo,
|
||||
QueueManageRequest,
|
||||
QueueManageResponse,
|
||||
RedeemDesktopLoginCodeData,
|
||||
RedeemDesktopLoginCodeError,
|
||||
RedeemDesktopLoginCodeErrors,
|
||||
RedeemDesktopLoginCodeResponse,
|
||||
RedeemDesktopLoginCodeResponses,
|
||||
ReleaseDeletionHoldData,
|
||||
ReleaseDeletionHoldError,
|
||||
ReleaseDeletionHoldErrors,
|
||||
ReleaseDeletionHoldResponse,
|
||||
ReleaseDeletionHoldResponses,
|
||||
ReleaseHoldResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
RemoveAssetTagsErrors,
|
||||
@@ -785,6 +853,11 @@ export type {
|
||||
RemoveWorkspaceMemberErrors,
|
||||
RemoveWorkspaceMemberResponse,
|
||||
RemoveWorkspaceMemberResponses,
|
||||
ReportHistoryEventData,
|
||||
ReportHistoryEventError,
|
||||
ReportHistoryEventErrors,
|
||||
ReportHistoryEventResponse,
|
||||
ReportHistoryEventResponses,
|
||||
ReportPartnerUsageData,
|
||||
ReportPartnerUsageError,
|
||||
ReportPartnerUsageErrors,
|
||||
@@ -808,6 +881,8 @@ export type {
|
||||
RevokeWorkspaceInviteResponse,
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretProvider,
|
||||
SecretProvidersResponse,
|
||||
SecretResponse,
|
||||
SeedAssetsData,
|
||||
SeedAssetsResponse,
|
||||
@@ -819,6 +894,8 @@ export type {
|
||||
SetReviewStatusResponse,
|
||||
SetReviewStatusResponse2,
|
||||
SetReviewStatusResponses,
|
||||
ShortLinkRedirectData,
|
||||
ShortLinkRedirectErrors,
|
||||
SubmitFeedbackData,
|
||||
SubmitFeedbackError,
|
||||
SubmitFeedbackErrors,
|
||||
@@ -848,6 +925,10 @@ export type {
|
||||
TaskEntry,
|
||||
TaskResponse,
|
||||
TasksListResponse,
|
||||
TeamCreditStop,
|
||||
TeamCreditStopPrice,
|
||||
TeamCreditStops,
|
||||
TeamCreditStopSummary,
|
||||
UpdateAssetData,
|
||||
UpdateAssetError,
|
||||
UpdateAssetErrors,
|
||||
@@ -865,6 +946,7 @@ export type {
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMemberRoleRequest,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -895,6 +977,11 @@ export type {
|
||||
UpdateWorkspaceData,
|
||||
UpdateWorkspaceError,
|
||||
UpdateWorkspaceErrors,
|
||||
UpdateWorkspaceMemberRoleData,
|
||||
UpdateWorkspaceMemberRoleError,
|
||||
UpdateWorkspaceMemberRoleErrors,
|
||||
UpdateWorkspaceMemberRoleResponse,
|
||||
UpdateWorkspaceMemberRoleResponses,
|
||||
UpdateWorkspaceRequest,
|
||||
UpdateWorkspaceResponse,
|
||||
UpdateWorkspaceResponses,
|
||||
|
||||
1031
packages/ingest-types/src/types.gen.ts
generated
452
packages/ingest-types/src/zod.gen.ts
generated
@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
|
||||
forked_from_workflow_version_id: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
|
||||
*/
|
||||
export const zHistoryEventRequest = z.object({
|
||||
user_id: z.string().optional(),
|
||||
workspace_id: z.string().optional(),
|
||||
event_type: z.string().min(1),
|
||||
event_id: z.string().min(1),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
|
||||
customer_ref: z.string().optional(),
|
||||
timestamp: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after recording partner usage data.
|
||||
*/
|
||||
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after successfully resubscribing to a billing plan.
|
||||
* Response after accepting a resubscribe request.
|
||||
*/
|
||||
export const zResubscribeResponse = z.object({
|
||||
billing_op_id: z.string(),
|
||||
status: z.enum(['active']),
|
||||
status: z.enum(['active', 'pending']),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
|
||||
*/
|
||||
export const zSubscribeRequest = z.object({
|
||||
plan_slug: z.string(),
|
||||
team_credit_stop_id: z.string().optional(),
|
||||
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
|
||||
idempotency_key: z.string().optional(),
|
||||
return_url: z.string().optional(),
|
||||
cancel_url: z.string().optional()
|
||||
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
|
||||
'STANDARD',
|
||||
'CREATOR',
|
||||
'PRO',
|
||||
'FOUNDERS_EDITION'
|
||||
'FOUNDERS_EDITION',
|
||||
'TEAM'
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
|
||||
plan_slug: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Pre/post-discount price for a team credit stop, in cents.
|
||||
*/
|
||||
export const zTeamCreditStopPrice = z.object({
|
||||
list_price_cents: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
price_cents: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A selectable preset on the team pricing slider. Echoed on subscribe via
|
||||
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
|
||||
* RAW monthly credit count (not cents). Save% is derived by the FE as
|
||||
* (list_price_cents - price_cents) / list_price_cents.
|
||||
*
|
||||
*/
|
||||
export const zTeamCreditStop = z.object({
|
||||
id: z.string(),
|
||||
credits: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
monthly: zTeamCreditStopPrice,
|
||||
yearly: zTeamCreditStopPrice
|
||||
})
|
||||
|
||||
/**
|
||||
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
|
||||
*/
|
||||
export const zTeamCreditStops = z.object({
|
||||
default_stop_index: z.number().int(),
|
||||
stops: z.array(zTeamCreditStop)
|
||||
})
|
||||
|
||||
/**
|
||||
* Reason why a plan is unavailable
|
||||
*/
|
||||
@@ -773,7 +841,50 @@ export const zPlan = z.object({
|
||||
*/
|
||||
export const zBillingPlansResponse = z.object({
|
||||
current_plan_slug: z.string().optional(),
|
||||
plans: z.array(zPlan)
|
||||
plans: z.array(zPlan),
|
||||
team_credit_stops: zTeamCreditStops.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The team credit stop a workspace is currently subscribed to: the
|
||||
* per-workspace slider choice recorded at subscribe time
|
||||
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
|
||||
* catalog, not the subscription row. Returned on GET /api/billing/status
|
||||
* for per-credit Team plans (BE-1254).
|
||||
*
|
||||
*/
|
||||
export const zTeamCreditStopSummary = z.object({
|
||||
id: z.string(),
|
||||
credits_monthly: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
stop_usd: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
|
||||
*/
|
||||
export const zSecretProvider = z.object({
|
||||
id: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* The providers available to the authenticated user in the current workspace.
|
||||
*/
|
||||
export const zSecretProvidersResponse = z.object({
|
||||
data: z.array(zSecretProvider)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -813,7 +924,7 @@ export const zCreateSecretRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* A single billing event such as a charge, credit, or adjustment.
|
||||
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
|
||||
*/
|
||||
export const zBillingEvent = z.object({
|
||||
event_type: z.string(),
|
||||
@@ -868,7 +979,8 @@ export const zBillingStatusResponse = z.object({
|
||||
billing_status: zBillingStatus.optional(),
|
||||
has_funds: z.boolean(),
|
||||
cancel_at: z.string().datetime().optional(),
|
||||
renewal_date: z.string().datetime().optional()
|
||||
renewal_date: z.string().datetime().optional(),
|
||||
team_credit_stop: zTeamCreditStopSummary.nullable()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -930,6 +1042,7 @@ export const zOAuthConsentChallenge = z.object({
|
||||
csrf_token: z.string(),
|
||||
client_display_name: z.string(),
|
||||
resource_display_name: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
scopes: z.array(z.string()),
|
||||
workspaces: z.array(zOAuthConsentChallengeWorkspace)
|
||||
})
|
||||
@@ -1056,6 +1169,66 @@ export const zSyncApiKeyRequest = z.object({
|
||||
customer_id: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* The personal workspace's provisioned billing identity.
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
|
||||
workspace_id: z.string(),
|
||||
stripe_customer_id: z.string(),
|
||||
metronome_customer_id: z.string(),
|
||||
metronome_contract_id: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's already-resolved legacy (comfy-api) customer identity. When
|
||||
* present and carrying provider IDs, provisioning ATTACHES this identity to
|
||||
* the personal workspace (sharing the existing balance and subscription)
|
||||
* instead of minting a net-new empty customer. Omit (or send with no
|
||||
* provider IDs) for a free user with nothing to attach — provisioning then
|
||||
* creates net-new. This closes the create-new-before-attach gap: a caller
|
||||
* that already knows the legacy identity hands it over so the very first
|
||||
* provisioning is an attach.
|
||||
*
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
|
||||
stripe_customer_id: z.string().optional(),
|
||||
metronome_customer_id: z.string().optional(),
|
||||
metronome_contract_id: z.string().optional(),
|
||||
has_funds: z.boolean().optional(),
|
||||
subscription_tier: z.string().optional(),
|
||||
legacy_stripe_subscription_id: z.string().optional(),
|
||||
legacy_comfy_user_id: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for ensuring a user's personal workspace carries a fully
|
||||
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
|
||||
* with the already canonical-resolved user identity.
|
||||
*
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
|
||||
user_id: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
|
||||
* no mappings exist (not an error — callers can treat empty as "unknown
|
||||
* canonical").
|
||||
*
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsResponse = z.object({
|
||||
firebase_uids: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsRequest = z.object({
|
||||
comfy_user_id: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Response confirming the validity and scope of a workspace API key.
|
||||
*/
|
||||
@@ -1172,7 +1345,8 @@ export const zMember = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['owner', 'member']),
|
||||
joined_at: z.string().datetime()
|
||||
joined_at: z.string().datetime(),
|
||||
is_original_owner: z.boolean()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1183,6 +1357,13 @@ export const zListMembersResponse = z.object({
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for changing a workspace member's role.
|
||||
*/
|
||||
export const zUpdateMemberRoleRequest = z.object({
|
||||
role: z.enum(['owner', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for updating an existing workspace's settings.
|
||||
*/
|
||||
@@ -1227,6 +1408,60 @@ export const zWorkspace = z.object({
|
||||
created_at: z.string().datetime()
|
||||
})
|
||||
|
||||
/**
|
||||
* Exchange poll result. Pending until the code is redeemed in the browser.
|
||||
*/
|
||||
export const zDesktopLoginCodeExchangeResponse = z.object({
|
||||
status: z.enum(['pending', 'complete']),
|
||||
custom_token: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to exchange a redeemed login code for a custom token.
|
||||
*/
|
||||
export const zDesktopLoginCodeExchangeRequest = z.object({
|
||||
code: z.string(),
|
||||
code_verifier: z.string().min(43).max(128)
|
||||
})
|
||||
|
||||
/**
|
||||
* Result of redeeming a desktop login code.
|
||||
*/
|
||||
export const zDesktopLoginCodeRedeemResponse = z.object({
|
||||
status: z.enum(['redeemed'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to claim a desktop login code for the authenticated user.
|
||||
*/
|
||||
export const zDesktopLoginCodeRedeemRequest = z.object({
|
||||
code: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* A freshly minted desktop login code and its polling parameters.
|
||||
*/
|
||||
export const zDesktopLoginCodeCreateResponse = z.object({
|
||||
code: z.string(),
|
||||
expires_in: z.number().int(),
|
||||
poll_interval: z.number().int()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to mint a desktop login code.
|
||||
*/
|
||||
export const zDesktopLoginCodeCreateRequest = z.object({
|
||||
installation_id: z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9._-]+$/)
|
||||
.optional(),
|
||||
platform: z.string().min(1).max(32),
|
||||
app_version: z.string().min(1).max(64),
|
||||
code_challenge: z.string().min(43).max(128)
|
||||
})
|
||||
|
||||
/**
|
||||
* Abbreviated workspace metadata used in list responses.
|
||||
*/
|
||||
@@ -1294,6 +1529,15 @@ export const zTasksListResponse = z.object({
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
/**
|
||||
* Result of authorizing a legal-hold release on a user's deletion.
|
||||
*/
|
||||
export const zReleaseHoldResponse = z.object({
|
||||
firebase_id: z.string(),
|
||||
released: z.boolean(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Current status of a user data deletion request.
|
||||
*/
|
||||
@@ -1363,6 +1607,20 @@ export const zJobDetailResponse = z.object({
|
||||
execution_meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response for POST /api/jobs/cancel.
|
||||
*/
|
||||
export const zJobsCancelResponse = z.object({
|
||||
cancelled: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to cancel multiple jobs by ID.
|
||||
*/
|
||||
export const zJobsCancelRequest = z.object({
|
||||
job_ids: z.array(z.string().uuid()).min(1).max(100)
|
||||
})
|
||||
|
||||
/**
|
||||
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
|
||||
*/
|
||||
@@ -1529,6 +1787,7 @@ export const zAsset = z.object({
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).readonly().optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
short_url: z.string().nullish(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
@@ -1624,6 +1883,7 @@ export const zSystemStatsResponse = z.object({
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
deploy_environment: z.string().optional(),
|
||||
comfyui_frontend_version: z.string().optional(),
|
||||
workflow_templates_version: z.string().optional(),
|
||||
cloud_version: z.string().optional(),
|
||||
@@ -1962,6 +2222,7 @@ export const zAssetWritable = z.object({
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
short_url: z.string().nullish(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
@@ -2180,7 +2441,11 @@ export const zGetJobDetailData = z.object({
|
||||
path: z.object({
|
||||
job_id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -2201,6 +2466,17 @@ export const zCancelJobData = z.object({
|
||||
*/
|
||||
export const zCancelJobResponse = zJobCancelResponse
|
||||
|
||||
export const zCancelJobsData = z.object({
|
||||
body: zJobsCancelRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - cancel requests dispatched (or jobs were already terminal)
|
||||
*/
|
||||
export const zCancelJobsResponse = zJobsCancelResponse
|
||||
|
||||
export const zViewFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2580,6 +2856,17 @@ export const zCreateSecretData = z.object({
|
||||
*/
|
||||
export const zCreateSecretResponse = zSecretResponse
|
||||
|
||||
export const zListSecretProvidersData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zListSecretProvidersResponse = zSecretProvidersResponse
|
||||
|
||||
export const zDeleteSecretData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -2881,6 +3168,40 @@ export const zExchangeTokenData = z.object({
|
||||
*/
|
||||
export const zExchangeTokenResponse2 = zExchangeTokenResponse
|
||||
|
||||
export const zCreateDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeCreateRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Login code created
|
||||
*/
|
||||
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
|
||||
|
||||
export const zRedeemDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeRedeemRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Code redeemed (or already redeemed by the same user)
|
||||
*/
|
||||
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
|
||||
|
||||
export const zExchangeDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeExchangeRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Pending (not yet redeemed) or complete with a custom token
|
||||
*/
|
||||
export const zExchangeDesktopLoginCodeResponse =
|
||||
zDesktopLoginCodeExchangeResponse
|
||||
|
||||
export const zGetJwksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3150,6 +3471,19 @@ export const zRemoveWorkspaceMemberData = z.object({
|
||||
*/
|
||||
export const zRemoveWorkspaceMemberResponse = z.void()
|
||||
|
||||
export const zUpdateWorkspaceMemberRoleData = z.object({
|
||||
body: zUpdateMemberRoleRequest,
|
||||
path: z.object({
|
||||
userId: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Member role updated
|
||||
*/
|
||||
export const zUpdateWorkspaceMemberRoleResponse = zMember
|
||||
|
||||
export const zListWorkspaceApiKeysData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3236,6 +3570,19 @@ export const zSetReviewStatusData = z.object({
|
||||
*/
|
||||
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
|
||||
|
||||
export const zAdminDeleteHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully deleted
|
||||
*/
|
||||
export const zAdminDeleteHubWorkflowResponse = z.void()
|
||||
|
||||
export const zUpdateHubWorkflowData = z.object({
|
||||
body: zUpdateHubWorkflowRequest,
|
||||
path: z.object({
|
||||
@@ -3277,6 +3624,19 @@ export const zCreateDeletionRequestResponse = z.object({
|
||||
user_found_in_cloud: z.boolean()
|
||||
})
|
||||
|
||||
export const zReleaseDeletionHoldData = z.object({
|
||||
body: z.object({
|
||||
firebase_id: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Release authorized; the deletion workflow will proceed
|
||||
*/
|
||||
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
|
||||
|
||||
export const zReportPartnerUsageData = z.object({
|
||||
body: zPartnerUsageRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -3288,6 +3648,38 @@ export const zReportPartnerUsageData = z.object({
|
||||
*/
|
||||
export const zReportPartnerUsageResponse = zPartnerUsageResponse
|
||||
|
||||
export const zGetHistoryEventsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
workspace_id: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
event_type: z.string().optional(),
|
||||
start_date: z.string().datetime().optional(),
|
||||
end_date: z.string().datetime().optional(),
|
||||
page: z.number().int().optional(),
|
||||
limit: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated cloud history events for the workspace
|
||||
*/
|
||||
export const zGetHistoryEventsResponse = zBillingEventsResponse
|
||||
|
||||
export const zReportHistoryEventData = z.object({
|
||||
body: zHistoryEventRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* History event recorded successfully
|
||||
*/
|
||||
export const zReportHistoryEventResponse = zPartnerUsageResponse
|
||||
|
||||
export const zUpdateSubscriptionCacheData = z.object({
|
||||
body: z.object({
|
||||
user_id: z.string(),
|
||||
@@ -3305,6 +3697,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zListLinkedFirebaseUidsData = z.object({
|
||||
body: zListLinkedFirebaseUidsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Linked Firebase UIDs (possibly empty list)
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
|
||||
|
||||
export const zEnsureWorkspaceBillingProvisionedData = z.object({
|
||||
body: zEnsureWorkspaceBillingProvisionedRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The workspace's provisioned billing identity
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedResponse2 =
|
||||
zEnsureWorkspaceBillingProvisionedResponse
|
||||
|
||||
export const zInsertDynamicConfigData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
@@ -4010,6 +4425,14 @@ export const zGetModelPreviewData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zShortLinkRedirectData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyPromptByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -4070,14 +4493,23 @@ export const zGetLegacyUserdataV2Data = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyAssetContentData = z.object({
|
||||
export const zGetAssetContentData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
disposition: z.enum(['inline', 'attachment']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset content stream (local runtime streams the bytes directly)
|
||||
*/
|
||||
export const zGetAssetContentResponse = z.string()
|
||||
|
||||
export const zGetLegacyViewMetadataData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "*.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "vitest.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
@@ -82,7 +82,7 @@
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
@@ -95,10 +95,10 @@
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -7,7 +7,7 @@ import Password from 'primevue/password'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
|
||||
const mockTurnstileEnabled = ref(false)
|
||||
const mockTurnstileEnforced = ref(false)
|
||||
const mockTurnstileToken = ref('')
|
||||
const mockTurnstileUnavailable = ref(false)
|
||||
const mockReset = vi.fn()
|
||||
let emitTurnstileToken: ((token: string) => void) | undefined
|
||||
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
|
||||
|
||||
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
|
||||
// useTurnstile.test.ts); this fake just wires token/unavailable through to
|
||||
// `waiting` the same way so SignUpForm's submit gating can be exercised.
|
||||
vi.mock('@/composables/auth/useTurnstile', () => ({
|
||||
useTurnstile: () => ({
|
||||
enabled: mockTurnstileEnabled,
|
||||
enforced: mockTurnstileEnforced
|
||||
enabled: mockTurnstileEnabled
|
||||
}),
|
||||
useTurnstileGate: () => ({
|
||||
token: mockTurnstileToken,
|
||||
unavailable: mockTurnstileUnavailable,
|
||||
waiting: computed(
|
||||
() =>
|
||||
mockTurnstileEnabled.value &&
|
||||
!mockTurnstileToken.value &&
|
||||
!mockTurnstileUnavailable.value
|
||||
)
|
||||
})
|
||||
}))
|
||||
|
||||
// Stub the real widget (which loads the external Turnstile script) with one that
|
||||
// exposes a spyable reset() and lets a test drive the v-model token the way a
|
||||
// solved challenge would.
|
||||
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
|
||||
// the way a solved challenge (or a broken/slow widget) would.
|
||||
vi.mock('./TurnstileWidget.vue', async () => {
|
||||
const { defineComponent: defineMock } = await import('vue')
|
||||
return {
|
||||
default: defineMock({
|
||||
name: 'TurnstileWidget',
|
||||
emits: ['update:token'],
|
||||
emits: ['update:token', 'update:unavailable'],
|
||||
setup(_, { expose, emit }) {
|
||||
expose({ reset: mockReset })
|
||||
emitTurnstileToken = (token: string) => emit('update:token', token)
|
||||
emitTurnstileUnavailable = (unavailable: boolean) =>
|
||||
emit('update:unavailable', unavailable)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
mockTurnstileEnabled.value = false
|
||||
mockTurnstileEnforced.value = false
|
||||
mockTurnstileToken.value = ''
|
||||
mockTurnstileUnavailable.value = false
|
||||
mockReset.mockClear()
|
||||
emitTurnstileToken = undefined
|
||||
emitTurnstileUnavailable = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile token hygiene', () => {
|
||||
it('clears the stale token when Turnstile becomes disabled', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const { user } = renderComponent()
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('stale-token')
|
||||
await nextTick()
|
||||
expect(
|
||||
screen.getByRole('button', { name: signUpButton })
|
||||
).not.toBeDisabled()
|
||||
|
||||
mockTurnstileEnabled.value = false
|
||||
await nextTick()
|
||||
|
||||
// re-enable: the stale token must have been cleared so submit is blocked again
|
||||
mockTurnstileEnabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for the shadow-mode race: previously submit was only
|
||||
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
|
||||
// ahead of the async Cloudflare challenge and reached the backend with an
|
||||
// empty token. Gating now depends only on whether the widget is enabled
|
||||
// (shadow or enforce both render it), so both modes behave identically here.
|
||||
describe('Turnstile submit gating', () => {
|
||||
it('disables the submit button in enforce mode until a token is present', async () => {
|
||||
it('disables the submit button until a token is present', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not emit submit in enforce mode while the token is empty', async () => {
|
||||
it('does not emit submit while the token is empty', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
|
||||
it('emits submit with the token once the challenge is solved', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
|
||||
})
|
||||
|
||||
it('emits submit without a token in shadow mode (never blocks)', async () => {
|
||||
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = false
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileUnavailable!(true)
|
||||
await nextTick()
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
v-if="turnstileEnabled"
|
||||
ref="turnstileWidget"
|
||||
v-model:token="turnstileToken"
|
||||
v-model:unavailable="turnstileUnavailable"
|
||||
/>
|
||||
|
||||
<small
|
||||
v-show="submitBlockedByTurnstile"
|
||||
v-show="waitingForTurnstile"
|
||||
id="comfy-org-sign-up-turnstile-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -51,11 +52,9 @@
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:disabled="!$form.valid || submitBlockedByTurnstile"
|
||||
:disabled="!$form.valid || waitingForTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
|
||||
"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTurnstile } from '@/composables/auth/useTurnstile'
|
||||
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -86,25 +85,21 @@ const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
|
||||
useTurnstile()
|
||||
const turnstileToken = ref('')
|
||||
const { enabled: turnstileEnabled } = useTurnstile()
|
||||
const {
|
||||
token: turnstileToken,
|
||||
unavailable: turnstileUnavailable,
|
||||
waiting: waitingForTurnstile
|
||||
} = useTurnstileGate(turnstileEnabled)
|
||||
const turnstileWidget =
|
||||
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
|
||||
const submitBlockedByTurnstile = computed(
|
||||
() => turnstileEnforced.value && !turnstileToken.value
|
||||
)
|
||||
|
||||
watch(turnstileEnabled, (on) => {
|
||||
if (!on) turnstileToken.value = ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData, turnstileToken?: string]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
if (event.valid && !waitingForTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
|
||||
@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
|
||||
|
||||
expect(api.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
|
||||
// hung challenge) must eventually tell the parent it cannot be relied on,
|
||||
// so submission can fall back instead of blocking a legitimate signup
|
||||
// forever.
|
||||
describe('unavailable fallback', () => {
|
||||
it('reports unavailable when the Turnstile script fails to load', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('reports unavailable on a challenge error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('clears the unavailable fallback once a token is solved', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('falls back once the widget fails to resolve within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
// Let the onMounted hook's `await loadTurnstile()` microtask settle
|
||||
// and render() run, without yet advancing to the timeout itself.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(options()).toBeDefined()
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fall back once a token arrives before the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['expired-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Establish a solved, available widget: an initial error marks it
|
||||
// unavailable, then solving a challenge clears that (the same
|
||||
// transition the existing "clears the unavailable fallback" test
|
||||
// verifies), so the expiry below is the only thing driving fallback.
|
||||
options()!['error-callback']!()
|
||||
options()!.callback!('token-abc')
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
|
||||
// The token later expires (e.g. tab backgrounded past its ~300s
|
||||
// lifetime) without the widget itself erroring.
|
||||
options()!['expired-callback']!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// A fresh challenge was requested, but nothing solves it before the
|
||||
// re-armed load timeout elapses, so submission must eventually be
|
||||
// unblocked rather than staying stuck forever.
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const token = defineModel<string>('token', { default: '' })
|
||||
/**
|
||||
* Set true whenever the widget cannot be relied on to ever produce a token:
|
||||
* the Cloudflare script failed to load, the rendered challenge errored out,
|
||||
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
|
||||
* uses this to stop waiting on a token so a broken/slow widget (network
|
||||
* issue, ad-blocker, CDN outage) can never permanently block signup.
|
||||
*/
|
||||
const unavailable = defineModel<boolean>('unavailable', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
|
||||
const errorMessage = ref('')
|
||||
let widgetId: string | undefined
|
||||
|
||||
/** How long to wait for the widget to resolve before falling back. */
|
||||
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
|
||||
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
|
||||
() => {
|
||||
unavailable.value = true
|
||||
},
|
||||
TURNSTILE_LOAD_TIMEOUT_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const clearToken = () => {
|
||||
token.value = ''
|
||||
}
|
||||
@@ -46,12 +65,18 @@ const reset = () => {
|
||||
errorMessage.value = ''
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A widget that renders can request a fresh challenge, so give it
|
||||
// another chance before falling back again.
|
||||
unavailable.value = false
|
||||
armTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
armTimeout()
|
||||
|
||||
try {
|
||||
const turnstile = await loadTurnstile()
|
||||
if (!containerRef.value) return
|
||||
@@ -64,23 +89,37 @@ onMounted(async () => {
|
||||
sitekey: getTurnstileSiteKey(),
|
||||
theme,
|
||||
callback: (newToken: string) => {
|
||||
clearLoadTimeout()
|
||||
errorMessage.value = ''
|
||||
unavailable.value = false
|
||||
token.value = newToken
|
||||
},
|
||||
'expired-callback': () => {
|
||||
clearToken()
|
||||
errorMessage.value = t('auth.turnstile.expired')
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A solved token can expire on its own (e.g. the tab was
|
||||
// backgrounded past the token's ~300s lifetime) without the widget
|
||||
// ever erroring, so proactively request a fresh challenge and
|
||||
// re-arm the load timeout in case it doesn't resolve in time.
|
||||
armTimeout()
|
||||
}
|
||||
},
|
||||
'error-callback': () => {
|
||||
clearToken()
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile challenge failed')
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile failed to load', error)
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
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 } from '@/lib/litegraph/src/litegraph'
|
||||
import { getDomWidgetZIndex } from './domWidgetZIndex'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
describe('getDomWidgetZIndex', () => {
|
||||
it('follows graph node ordering when node.order is stale', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
isTurnstileEnabled,
|
||||
normalizeTurnstileMode,
|
||||
useTurnstile
|
||||
useTurnstile,
|
||||
useTurnstileGate
|
||||
} from '@/composables/auth/useTurnstile'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTurnstileGate', () => {
|
||||
it('waits while enabled with no token yet', () => {
|
||||
const { waiting } = useTurnstileGate(ref(true))
|
||||
expect(waiting.value).toBe(true)
|
||||
})
|
||||
|
||||
it('never waits while disabled', () => {
|
||||
const { waiting } = useTurnstileGate(ref(false))
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once a token arrives', () => {
|
||||
const { token, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
token.value = 'token-abc'
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once the widget reports itself unavailable', () => {
|
||||
const { unavailable, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
unavailable.value = true
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stale token/unavailable state when enabled turns off', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
|
||||
// Regression coverage: the reset used to only run on the enabled->disabled
|
||||
// transition, so state written while the widget was briefly disabled could
|
||||
// survive into the next enabled widget instance.
|
||||
it('clears stale token/unavailable state when enabled turns back on', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
@@ -42,3 +43,31 @@ export function useTurnstile() {
|
||||
|
||||
return { mode, siteKey, enabled, enforced }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit-gating state for the signup form's Turnstile widget: a token/
|
||||
* unavailable pair, plus `waiting`, which is true while a real token is still
|
||||
* needed. Waits in both shadow and enforce mode (`enabled`), not just
|
||||
* `enforced`, so shadow mode's token can't race the async Cloudflare
|
||||
* challenge; falls back open once the widget reports `unavailable` so a
|
||||
* broken/slow load can never permanently block signup.
|
||||
*
|
||||
* `token`/`unavailable` reset on every `enabled` transition, in either
|
||||
* direction, so state from a previous widget instance can never leak into a
|
||||
* freshly (re-)rendered one.
|
||||
*/
|
||||
export function useTurnstileGate(enabled: Ref<boolean>) {
|
||||
const token = ref('')
|
||||
const unavailable = ref(false)
|
||||
|
||||
const waiting = computed(
|
||||
() => enabled.value && !token.value && !unavailable.value
|
||||
)
|
||||
|
||||
watch(enabled, () => {
|
||||
token.value = ''
|
||||
unavailable.value = false
|
||||
})
|
||||
|
||||
return { token, unavailable, waiting }
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import {
|
||||
extractVueNodeData,
|
||||
useGraphNodeManager
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
@@ -17,7 +14,6 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { linkedWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
@@ -66,29 +62,15 @@ describe('Node Reactivity', () => {
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not re-wrap node.widgets on repeated extraction', () => {
|
||||
const { node } = createTestGraph()
|
||||
const widgetsGetter = () =>
|
||||
Object.getOwnPropertyDescriptor(node, 'widgets')?.get
|
||||
|
||||
extractVueNodeData(node)
|
||||
const firstGetter = widgetsGetter()
|
||||
|
||||
extractVueNodeData(node)
|
||||
extractVueNodeData(node)
|
||||
|
||||
expect(widgetsGetter()).toBe(firstGetter)
|
||||
})
|
||||
|
||||
it('widget values remain reactive after a connection is made', async () => {
|
||||
const { node, graph } = createTestGraph()
|
||||
const store = useWidgetValueStore()
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
const upstream = new LGraphNode('upstream')
|
||||
upstream.addOutput('out', 'INT')
|
||||
graph.add(upstream)
|
||||
upstream.connect(0, node, 0)
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
|
||||
@@ -105,7 +87,7 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget input link reactivity', () => {
|
||||
describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
@@ -114,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)
|
||||
|
||||
@@ -128,14 +112,81 @@ 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('updates slotMetadata.linked to false after link disconnect event', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
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,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -154,14 +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(
|
||||
linkedWidgetedInputs(nodeData!, subgraph.rootGraph.id).map((s) => s.name)
|
||||
).toEqual(['prompt'])
|
||||
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' }]
|
||||
})
|
||||
@@ -177,21 +229,39 @@ 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)
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.sourceWidgetName).toBe('prompt')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
expect(valueState?.name).toBe('value')
|
||||
expect(valueState?.value).toBe('hello')
|
||||
expect(renderState).toMatchObject({
|
||||
hasLayoutSize: false,
|
||||
isDOMWidget: false
|
||||
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(widgetData.slotMetadata?.linked).toBe(true)
|
||||
|
||||
node.inputs[0].name = 'other'
|
||||
node.inputs[0].widget = { name: 'other' }
|
||||
node.inputs[0].link = null
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
expect(renderState).not.toHaveProperty('sourceWidgetName')
|
||||
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -298,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', () => {
|
||||
@@ -333,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: '*' }]
|
||||
})
|
||||
@@ -375,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, {})
|
||||
@@ -397,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,14 +36,69 @@ 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))[]
|
||||
|
||||
const reactiveArrayNodes = new WeakSet<LGraphNode>()
|
||||
/**
|
||||
* 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
|
||||
@@ -51,23 +124,251 @@ 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[]
|
||||
} {
|
||||
// Wrapping is one-shot: re-running would stack another getter layer per call.
|
||||
if (reactiveArrayNodes.has(node)) {
|
||||
return { inputs: node.inputs ?? [], outputs: node.outputs ?? [] }
|
||||
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))
|
||||
}
|
||||
reactiveArrayNodes.add(node)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -75,6 +376,8 @@ function makeReactiveNodeArrays(node: LGraphNode): {
|
||||
)
|
||||
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() {
|
||||
@@ -103,7 +406,6 @@ function makeReactiveNodeArrays(node: LGraphNode): {
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
@@ -115,7 +417,6 @@ function makeReactiveNodeArrays(node: LGraphNode): {
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
|
||||
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
|
||||
Object.defineProperty(node, 'outputs', {
|
||||
get() {
|
||||
@@ -128,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 ||
|
||||
@@ -145,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 : '',
|
||||
@@ -152,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,
|
||||
@@ -169,17 +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 getNode = (id: NodeId): LGraphNode | undefined => nodeRefs.get(id)
|
||||
const refreshNodeSlots = (nodeId: NodeId) => {
|
||||
const nodeRef = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -187,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)
|
||||
}
|
||||
@@ -246,12 +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)
|
||||
originalCallback?.(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates cleanup function for event listeners and state
|
||||
*/
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
@@ -259,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
|
||||
@@ -268,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)
|
||||
}
|
||||
@@ -395,17 +760,29 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
}
|
||||
},
|
||||
'node:slot-errors:changed': (slotErrorsEvent) => {
|
||||
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
|
||||
},
|
||||
'node:slot-links:changed': (slotLinksEvent) => {
|
||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
|
||||
}
|
||||
},
|
||||
'node:slot-label:changed': (slotLabelEvent) => {
|
||||
const nodeId = toNodeId(slotLabelEvent.nodeId)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,14 +791,22 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
case 'node:property:changed':
|
||||
triggerHandlers['node:property:changed'](event)
|
||||
break
|
||||
case 'node:slot-errors:changed':
|
||||
triggerHandlers['node:slot-errors:changed'](event)
|
||||
break
|
||||
case 'node:slot-links:changed':
|
||||
triggerHandlers['node:slot-links:changed'](event)
|
||||
break
|
||||
case 'node:slot-label:changed':
|
||||
triggerHandlers['node:slot-label:changed'](event)
|
||||
break
|
||||
}
|
||||
|
||||
// Chain to original handler
|
||||
originalOnTrigger?.(event)
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
return createCleanupFunction(
|
||||
@@ -432,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()
|
||||
@@ -38,7 +39,25 @@ function useVueNodeLifecycleIndividual() {
|
||||
// Seed reroutes into the Layout Store so hit-testing uses the new path
|
||||
for (const reroute of activeGraph.reroutes.values()) {
|
||||
const [x, y] = reroute.pos
|
||||
layoutMutations.createReroute(reroute.id, { x, y })
|
||||
const parent = reroute.parentId ?? undefined
|
||||
const linkIds = Array.from(reroute.linkIds)
|
||||
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
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useUpstreamValue<T>(
|
||||
return computed(() => {
|
||||
const upstream = getLinkedUpstream()
|
||||
if (!upstream) return undefined
|
||||
const graphId = canvasStore.rootGraphId
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
if (!graphId) return undefined
|
||||
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
|
||||
return extractValue(widgets, upstream.outputName)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
|
||||
import { createUuidv4 } from '@/utils/uuid'
|
||||
|
||||
import {
|
||||
createTestSubgraphData,
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
describe('LGraph node badge registration', () => {
|
||||
it('registers a node in the root bucket on add, unregisters on remove', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('n')
|
||||
|
||||
graph.add(node)
|
||||
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual([
|
||||
node.id
|
||||
])
|
||||
|
||||
graph.remove(node)
|
||||
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the root bucket when the root graph is cleared', () => {
|
||||
const graph = new LGraph()
|
||||
graph.id = createUuidv4()
|
||||
graph.add(new LGraphNode('a'))
|
||||
graph.add(new LGraphNode('b'))
|
||||
const graphId = graph.rootGraph.id
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(useNodeBadgeStore().registeredNodeIds(graphId)).toEqual([])
|
||||
})
|
||||
|
||||
it('registers subgraph nodes in the root bucket', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
const inner = new LGraphNode('inner')
|
||||
|
||||
subgraph.add(inner)
|
||||
|
||||
expect(
|
||||
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
|
||||
).toContainEqual(inner.id)
|
||||
})
|
||||
|
||||
it('unregisters nodes at every nesting depth when a subgraph is cleared', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const keeper = new LGraphNode('keeper')
|
||||
rootGraph.add(keeper)
|
||||
|
||||
const outer = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
outer.add(new LGraphNode('direct'))
|
||||
const nested = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
nested.add(new LGraphNode('deep'))
|
||||
outer.add(createTestSubgraphNode(nested, { parentGraph: outer }))
|
||||
|
||||
outer.clear()
|
||||
|
||||
expect(
|
||||
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
|
||||
).toEqual([keeper.id])
|
||||
})
|
||||
|
||||
it('unregisters inner nodes when the subgraph definition is collected', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
const inner = new LGraphNode('inner')
|
||||
subgraph.add(inner)
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(
|
||||
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,10 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe } from 'vitest'
|
||||
import { describe } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
describe('LGraph Serialisation', () => {
|
||||
test('can (de)serialise node / group titles', ({ expect, minimalGraph }) => {
|
||||
const nodeTitle = 'Test Node'
|
||||
|
||||
@@ -12,17 +12,11 @@ import {
|
||||
Reroute,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
SerialisableGraph,
|
||||
SerialisableLLink,
|
||||
SerialisableReroute
|
||||
} 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 { slotFloatingLinks } from '@/lib/litegraph/src/LLink'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
@@ -45,8 +39,6 @@ import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
|
||||
import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds'
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
function swapNodes(nodes: LGraphNode[]) {
|
||||
const firstNode = nodes[0]
|
||||
const lastNode = nodes[nodes.length - 1]
|
||||
@@ -243,25 +235,6 @@ describe('Floating Links / Reroutes', () => {
|
||||
expect(graph.reroutes.size).toBe(4)
|
||||
})
|
||||
|
||||
test('slot floating links are derived from link endpoints', ({
|
||||
expect,
|
||||
linkedNodesGraph
|
||||
}) => {
|
||||
const graph = new LGraph(linkedNodesGraph)
|
||||
graph.createReroute([0, 0], graph.links.values().next().value!)
|
||||
const [origin, target] = graph.nodes
|
||||
|
||||
origin.disconnectOutput(0)
|
||||
|
||||
expect(slotFloatingLinks(graph, 'input', target.id, 0)).toHaveLength(1)
|
||||
expect(slotFloatingLinks(graph, 'output', origin.id, 0)).toHaveLength(0)
|
||||
|
||||
const [floatingLink] = slotFloatingLinks(graph, 'input', target.id, 0)
|
||||
graph.removeFloatingLink(floatingLink)
|
||||
|
||||
expect(slotFloatingLinks(graph, 'input', target.id, 0)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('Floating reroutes should be removed when neither input nor output is connected', ({
|
||||
expect,
|
||||
floatingBranchGraph: graph
|
||||
@@ -286,91 +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)
|
||||
)
|
||||
})
|
||||
|
||||
const REROUTE_KEYS = ['id', 'parentId', 'pos', 'linkIds', 'floating'] as const
|
||||
|
||||
function expectRerouteContractKeyOrder(reroute: SerialisableReroute) {
|
||||
const serialized: Record<string, unknown> = JSON.parse(
|
||||
JSON.stringify(reroute)
|
||||
)
|
||||
const expectedKeys = REROUTE_KEYS.filter(
|
||||
(key) => reroute[key] !== undefined
|
||||
)
|
||||
expect(Object.keys(serialized)).toEqual(expectedKeys)
|
||||
}
|
||||
|
||||
test('reroutes keep contract key order and round-trip byte-identically', ({
|
||||
expect,
|
||||
reroutesComplexGraph
|
||||
}) => {
|
||||
const first = reroutesComplexGraph.asSerialisable()
|
||||
const second = new LGraph(first).asSerialisable()
|
||||
|
||||
const reroutes = first.reroutes ?? []
|
||||
expect(reroutes.length).toBeGreaterThan(0)
|
||||
expect(reroutes.some((r) => r.floating !== undefined)).toBe(true)
|
||||
expect(reroutes.some((r) => r.parentId === undefined)).toBe(true)
|
||||
for (const reroute of reroutes) expectRerouteContractKeyOrder(reroute)
|
||||
expect(JSON.stringify(second.reroutes)).toBe(JSON.stringify(first.reroutes))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph Clearing and Callbacks', () => {
|
||||
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
|
||||
expect
|
||||
@@ -419,6 +307,8 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
|
||||
test('clear() removes graph-scoped preview and widget-value state', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const graph = new LGraph()
|
||||
const graphId = 'graph-clear-cleanup' as UUID
|
||||
graph.id = graphId
|
||||
@@ -454,6 +344,66 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
test('clear() purges widget state on a zero-UUID graph that holds nodes (node-id reuse leak)', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const graph = new LGraph()
|
||||
expect(graph.id).toBe(zeroUuid)
|
||||
|
||||
const node = new LGraphNode('ImageAnalyze')
|
||||
node.id = toNodeId(1)
|
||||
graph.add(node)
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const modeWidgetId = widgetId(graph.id, toNodeId(1), 'mode')
|
||||
widgetValueStore.registerWidget(modeWidgetId, {
|
||||
type: 'combo',
|
||||
value: 'Black White Levels',
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: undefined,
|
||||
disabled: undefined
|
||||
})
|
||||
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
previewExposureStore.addExposure(graph.id, `${graph.id}:1`, {
|
||||
sourceNodeId: '1',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(widgetValueStore.getWidget(modeWidgetId)).toBeUndefined()
|
||||
expect(
|
||||
previewExposureStore.getExposures(graph.id, `${graph.id}:1`)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('constructing a new empty graph does not purge existing zero-UUID widget state', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const active = new LGraph()
|
||||
const node = new LGraphNode('ImageAnalyze')
|
||||
node.id = toNodeId(1)
|
||||
active.add(node)
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const modeWidgetId = widgetId(active.id, toNodeId(1), 'mode')
|
||||
widgetValueStore.registerWidget(modeWidgetId, {
|
||||
type: 'combo',
|
||||
value: 'keep me',
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: undefined,
|
||||
disabled: undefined
|
||||
})
|
||||
|
||||
const throwaway = new LGraph()
|
||||
expect(throwaway.id).toBe(zeroUuid)
|
||||
|
||||
expect(widgetValueStore.getWidget(modeWidgetId)?.value).toBe('keep me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('node:before-removed event', () => {
|
||||
@@ -547,6 +497,10 @@ describe('node:before-removed event', () => {
|
||||
})
|
||||
|
||||
describe('Subgraph Definition Garbage Collection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function createSubgraphWithNodes(rootGraph: LGraph, nodeCount: number) {
|
||||
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
|
||||
@@ -936,7 +890,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
|
||||
}
|
||||
@@ -970,20 +924,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!
|
||||
@@ -1219,6 +1159,7 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
|
||||
const SHARED_NODE_IDS = [3, 8, 37]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
LiteGraph.registerNodeType('dummy', DummyNode)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { toString } from 'es-toolkit/compat'
|
||||
import { shallowRef, toRaw } from 'vue'
|
||||
|
||||
import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
@@ -9,13 +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 { useRerouteStore } from '@/stores/rerouteStore'
|
||||
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { UNASSIGNED_NODE_ID, parseNodeId, toNodeId } from '@/types/nodeId'
|
||||
@@ -34,20 +29,10 @@ 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 {
|
||||
registerRerouteChain,
|
||||
Reroute,
|
||||
unregisterAllRerouteChains,
|
||||
unregisterRerouteChain
|
||||
} from './Reroute'
|
||||
import { Reroute } from './Reroute'
|
||||
import type { RerouteId } from './Reroute'
|
||||
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
|
||||
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
|
||||
@@ -74,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'
|
||||
@@ -108,7 +92,6 @@ import type {
|
||||
import { getAllNestedItems } from './utils/collections'
|
||||
import {
|
||||
deduplicateSubgraphNodeIds,
|
||||
deduplicateSubgraphRerouteIds,
|
||||
topologicalSortSubgraphs
|
||||
} from './subgraph/subgraphDeduplication'
|
||||
|
||||
@@ -198,55 +181,6 @@ function fireNodeRemovalLifecycle(node: LGraphNode): void {
|
||||
graph?.onNodeRemoved?.(node)
|
||||
}
|
||||
|
||||
/** A reroute chain segment, terminal-first. */
|
||||
interface ChainSegment {
|
||||
/** Emitted reroute ids, in walk order. */
|
||||
segment: RerouteId[]
|
||||
/** `false` if the walk stopped at a broken reference or a cycle. */
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one hop of a reroute chain.
|
||||
* @param id The reroute id to resolve.
|
||||
* @returns The id to emit and the next id upstream, or `undefined` if the
|
||||
* reference is broken.
|
||||
*/
|
||||
type ChainStep = (
|
||||
id: RerouteId
|
||||
) => { emit: RerouteId; next: RerouteId | undefined } | undefined
|
||||
|
||||
/**
|
||||
* Walks a reroute chain, resolving each hop with `step`, until it runs out,
|
||||
* hits a broken reference, or detects a cycle.
|
||||
* @param start The reroute id to walk from, or `undefined` for an empty chain.
|
||||
* @param step Resolves each hop of the chain.
|
||||
* @returns The walked segment.
|
||||
*/
|
||||
function walkSegment(
|
||||
start: RerouteId | undefined,
|
||||
step: ChainStep
|
||||
): ChainSegment {
|
||||
const segment: RerouteId[] = []
|
||||
const visited = new Set<RerouteId>()
|
||||
let id = start
|
||||
while (id !== undefined) {
|
||||
if (visited.has(id)) {
|
||||
console.error('Infinite parentId loop when unpacking')
|
||||
return { segment, complete: false }
|
||||
}
|
||||
visited.add(id)
|
||||
const hop = step(id)
|
||||
if (!hop) {
|
||||
console.error('Broken Id link when unpacking')
|
||||
return { segment, complete: false }
|
||||
}
|
||||
segment.push(hop.emit)
|
||||
id = hop.next
|
||||
}
|
||||
return { segment, complete: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
|
||||
* supported callbacks:
|
||||
@@ -289,18 +223,7 @@ export class LGraph
|
||||
'extra'
|
||||
])
|
||||
|
||||
/**
|
||||
* Ref-backed so the id reassignment on every workflow load ({@link configure})
|
||||
* propagates to reactive consumers keyed by root graph id.
|
||||
*/
|
||||
private readonly _id = shallowRef<UUID>(zeroUuid)
|
||||
get id(): UUID {
|
||||
return toRaw(this)._id.value
|
||||
}
|
||||
set id(value: UUID) {
|
||||
toRaw(this)._id.value = value
|
||||
}
|
||||
|
||||
id: UUID = zeroUuid
|
||||
revision: number = 0
|
||||
|
||||
_version: number = -1
|
||||
@@ -469,20 +392,11 @@ export class LGraph
|
||||
this.status = LGraph.STATUS_STOPPED
|
||||
|
||||
const graphId = this.id
|
||||
if (this.isRootGraph && graphId !== zeroUuid) {
|
||||
const isEmptyUnconfiguredGraph =
|
||||
graphId === zeroUuid && this._nodes.length === 0
|
||||
if (this.isRootGraph && !isEmptyUnconfiguredGraph) {
|
||||
usePreviewExposureStore().clearGraph(graphId)
|
||||
useWidgetValueStore().clearGraph(graphId)
|
||||
useLinkStore().clearGraph(graphId)
|
||||
useRerouteStore().clearGraph(graphId)
|
||||
useNodeBadgeStore().clearGraph(graphId)
|
||||
} else {
|
||||
// Subgraphs and unconfigured (zero-uuid) graphs share their store
|
||||
// bucket with other graphs, so unregister each link individually.
|
||||
unregisterAllLinkTopologies(this)
|
||||
unregisterAllRerouteChains(this)
|
||||
forEachNode(this, (node) =>
|
||||
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
|
||||
)
|
||||
}
|
||||
|
||||
this.id = zeroUuid
|
||||
@@ -1091,20 +1005,12 @@ export class LGraph
|
||||
node.graph = this
|
||||
this.incrementVersion()
|
||||
|
||||
useNodeBadgeStore().registerNode(this.rootGraph.id, node.id)
|
||||
|
||||
// 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)
|
||||
@@ -1208,11 +1114,6 @@ export class LGraph
|
||||
|
||||
if (!hasRemainingReferences) {
|
||||
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
|
||||
forEachNode(node.subgraph, (innerNode) =>
|
||||
useNodeBadgeStore().unregisterNode(this.rootGraph.id, innerNode.id)
|
||||
)
|
||||
unregisterAllLinkTopologies(node.subgraph)
|
||||
unregisterAllRerouteChains(node.subgraph)
|
||||
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
||||
}
|
||||
}
|
||||
@@ -1239,7 +1140,6 @@ export class LGraph
|
||||
if (pos != -1) this._nodes.splice(pos, 1)
|
||||
|
||||
delete this._nodes_by_id[node.id]
|
||||
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
|
||||
|
||||
this.onNodeRemoved?.(node)
|
||||
|
||||
@@ -1524,48 +1424,49 @@ 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
|
||||
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
|
||||
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
|
||||
if (slot) {
|
||||
slot._floatingLinks ??= new Set()
|
||||
slot._floatingLinks.add(link)
|
||||
} else {
|
||||
console.warn(
|
||||
`Adding invalid floating link: target/slot: [${link.target_id}/${link.target_slot}] origin/slot: [${link.origin_id}/${link.origin_slot}]`
|
||||
)
|
||||
}
|
||||
|
||||
const reroutes = LLink.getReroutes(this, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.floatingLinkIds.add(link.id)
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
removeFloatingLink(link: LLink): void {
|
||||
this.floatingLinksInternal.delete(link.id)
|
||||
unregisterLinkTopology(link)
|
||||
|
||||
const slot =
|
||||
link.target_id !== UNASSIGNED_NODE_ID
|
||||
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
|
||||
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
|
||||
if (slot) {
|
||||
slot._floatingLinks?.delete(link)
|
||||
}
|
||||
|
||||
const reroutes = LLink.getReroutes(this, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.floatingLinkIds.delete(link.id)
|
||||
if (reroute.floatingLinkIds.size === 0) {
|
||||
reroute.floating = undefined
|
||||
delete reroute.floating
|
||||
}
|
||||
|
||||
if (reroute.totalLinks === 0) this.removeReroute(reroute.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1588,32 +1489,6 @@ export class LGraph
|
||||
return id == null ? undefined : this.reroutes.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a reroute to this graph's {@link reroutes} map and registers its
|
||||
* chain state with the reroute store. The single entry point for
|
||||
* populating {@link reroutes}; routing every add through here keeps the
|
||||
* store from silently desyncing.
|
||||
*/
|
||||
_addReroute(reroute: Reroute): void {
|
||||
this.reroutesInternal.set(reroute.id, reroute)
|
||||
registerRerouteChain(this, reroute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a reroute from this graph's {@link reroutes} map and
|
||||
* unregisters it from the reroute and layout stores. The delete-side
|
||||
* counterpart to {@link _addReroute}.
|
||||
*/
|
||||
_removeReroute(id: RerouteId): void {
|
||||
const reroute = this.reroutesInternal.get(id)
|
||||
if (!reroute) return
|
||||
this.reroutesInternal.delete(id)
|
||||
unregisterRerouteChain(reroute)
|
||||
const layoutMutations = useLayoutMutations()
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.deleteReroute(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures a reroute on the graph where ID is already known (probably deserialisation).
|
||||
* Creates the object if it does not exist.
|
||||
@@ -1623,6 +1498,7 @@ export class LGraph
|
||||
id,
|
||||
parentId,
|
||||
pos,
|
||||
linkIds,
|
||||
floating
|
||||
}: OptionalProps<SerialisableReroute, 'id'>): Reroute {
|
||||
const rerouteId =
|
||||
@@ -1634,11 +1510,11 @@ export class LGraph
|
||||
}
|
||||
|
||||
const reroute = this.reroutes.get(rerouteId) ?? new Reroute(rerouteId, this)
|
||||
reroute.parentId =
|
||||
const typedParentId =
|
||||
parentId === undefined ? undefined : toRerouteId(parentId)
|
||||
if (pos) reroute.pos = pos
|
||||
reroute.floating = floating
|
||||
this._addReroute(reroute)
|
||||
const typedLinkIds = linkIds?.map(toLinkId)
|
||||
reroute.update(typedParentId, pos, typedLinkIds, floating)
|
||||
this.reroutes.set(rerouteId, reroute)
|
||||
return reroute
|
||||
}
|
||||
|
||||
@@ -1656,24 +1532,41 @@ export class LGraph
|
||||
}
|
||||
const rerouteId = toRerouteId(Number(this.state.lastRerouteId) + 1)
|
||||
this.state.lastRerouteId = rerouteId
|
||||
const chainLinks =
|
||||
before instanceof Reroute
|
||||
? [
|
||||
...[...before.linkIds].map((id) => this._links.get(id)),
|
||||
...[...before.floatingLinkIds].map((id) =>
|
||||
this.floatingLinks.get(id)
|
||||
)
|
||||
]
|
||||
: [before]
|
||||
const reroute = new Reroute(rerouteId, this, pos, before.parentId)
|
||||
this._addReroute(reroute)
|
||||
const linkIds = before instanceof Reroute ? before.linkIds : [before.id]
|
||||
const floatingLinkIds =
|
||||
before instanceof Reroute ? before.floatingLinkIds : [before.id]
|
||||
const reroute = new Reroute(
|
||||
rerouteId,
|
||||
this,
|
||||
pos,
|
||||
before.parentId,
|
||||
linkIds,
|
||||
floatingLinkIds
|
||||
)
|
||||
this.reroutes.set(rerouteId, reroute)
|
||||
|
||||
// Register reroute in Layout Store for spatial tracking
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.createReroute(rerouteId, { x: pos[0], y: pos[1] })
|
||||
layoutMutations.createReroute(
|
||||
rerouteId,
|
||||
{ x: pos[0], y: pos[1] },
|
||||
before.parentId,
|
||||
Array.from(linkIds)
|
||||
)
|
||||
|
||||
// Splice the new reroute into every chain that contained `before`
|
||||
for (const link of chainLinks) {
|
||||
for (const linkId of linkIds) {
|
||||
const link = this._links.get(linkId)
|
||||
if (!link) continue
|
||||
if (link.parentId === before.parentId) link.parentId = rerouteId
|
||||
|
||||
const reroutes = LLink.getReroutes(this, link)
|
||||
for (const x of reroutes.filter((x) => x.parentId === before.parentId)) {
|
||||
x.parentId = rerouteId
|
||||
}
|
||||
}
|
||||
|
||||
for (const linkId of floatingLinkIds) {
|
||||
const link = this.floatingLinks.get(linkId)
|
||||
if (!link) continue
|
||||
if (link.parentId === before.parentId) link.parentId = rerouteId
|
||||
|
||||
@@ -1691,6 +1584,7 @@ export class LGraph
|
||||
* @param id ID of reroute to remove
|
||||
*/
|
||||
removeReroute(id: RerouteId): void {
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { reroutes } = this
|
||||
const reroute = reroutes.get(id)
|
||||
if (!reroute) return
|
||||
@@ -1733,7 +1627,11 @@ export class LGraph
|
||||
}
|
||||
}
|
||||
|
||||
this._removeReroute(id)
|
||||
reroutes.delete(id)
|
||||
|
||||
// Delete reroute from Layout Store
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.deleteReroute(id)
|
||||
|
||||
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
|
||||
// https://github.com/Comfy-Org/litegraph.js/issues/898
|
||||
@@ -1771,7 +1669,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)
|
||||
}
|
||||
}
|
||||
@@ -2010,7 +1910,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),
|
||||
@@ -2144,6 +2044,30 @@ export class LGraph
|
||||
group.pos[1] += offsetY
|
||||
toSelect.push(group)
|
||||
}
|
||||
//cleanup reoute.linkIds now, but leave link.parentIds dangling
|
||||
for (const islot of subgraphNode.inputs) {
|
||||
if (!islot.link) continue
|
||||
const link = this.links.get(islot.link)
|
||||
if (!link) {
|
||||
console.warn('Broken link', islot, islot.link)
|
||||
continue
|
||||
}
|
||||
for (const reroute of LLink.getReroutes(this, link)) {
|
||||
reroute.linkIds.delete(link.id)
|
||||
}
|
||||
}
|
||||
for (const oslot of subgraphNode.outputs) {
|
||||
for (const linkId of oslot.links ?? []) {
|
||||
const link = this.links.get(linkId)
|
||||
if (!link) {
|
||||
console.warn('Broken link', oslot, linkId)
|
||||
continue
|
||||
}
|
||||
for (const reroute of LLink.getReroutes(this, link)) {
|
||||
reroute.linkIds.delete(link.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const newLinks: {
|
||||
oid: NodeId
|
||||
oslot: number
|
||||
@@ -2278,52 +2202,89 @@ export class LGraph
|
||||
}
|
||||
newLink.id = created.id
|
||||
}
|
||||
// Migrate the subgraph's reroutes to fresh ids at their new positions.
|
||||
const rerouteIdMap = new Map<RerouteId, RerouteId>()
|
||||
const oldReroutes = subgraphNode.subgraph.reroutes
|
||||
for (const reroute of oldReroutes.values()) {
|
||||
const migratedId = toRerouteId(Number(this.state.lastRerouteId) + 1)
|
||||
this.state.lastRerouteId = migratedId
|
||||
const migratedReroute = new Reroute(migratedId, this, [
|
||||
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
|
||||
if (
|
||||
reroute.parentId !== undefined &&
|
||||
rerouteIdMap.get(reroute.parentId) === undefined
|
||||
) {
|
||||
console.error('Missing Parent ID')
|
||||
}
|
||||
const migratedRerouteId = toRerouteId(
|
||||
Number(this.state.lastRerouteId) + 1
|
||||
)
|
||||
this.state.lastRerouteId = migratedRerouteId
|
||||
const migratedReroute = new Reroute(migratedRerouteId, this, [
|
||||
reroute.pos[0] + offsetX,
|
||||
reroute.pos[1] + offsetY
|
||||
])
|
||||
rerouteIdMap.set(reroute.id, migratedId)
|
||||
this._addReroute(migratedReroute)
|
||||
rerouteIdMap.set(reroute.id, migratedReroute.id)
|
||||
this.reroutes.set(migratedReroute.id, migratedReroute)
|
||||
toSelect.push(migratedReroute)
|
||||
}
|
||||
|
||||
// Stitch each link's chain from its internal (migrated) and external
|
||||
// segments, ordered by which side was nearest the input. External hops walk
|
||||
// this graph's own reroutes; internal hops walk the old subgraph chain,
|
||||
// emitting migrated ids.
|
||||
//iterate over newly created links to update reroute parentIds
|
||||
for (const newLink of dedupedNewLinks) {
|
||||
const linkInstance = this.links.get(newLink.id)
|
||||
if (!linkInstance) continue
|
||||
|
||||
const internal = walkSegment(newLink.iparent, (id) => {
|
||||
const emit = rerouteIdMap.get(id)
|
||||
return emit === undefined
|
||||
? undefined
|
||||
: { emit, next: oldReroutes.get(id)?.parentId }
|
||||
})
|
||||
const external = walkSegment(newLink.eparent, (id) => {
|
||||
const reroute = this.reroutes.get(id)
|
||||
return reroute && { emit: id, next: reroute.parentId }
|
||||
})
|
||||
const [first, second] = newLink.externalFirst
|
||||
? [external, internal]
|
||||
: [internal, external]
|
||||
const chain = first.complete
|
||||
? [...first.segment, ...second.segment]
|
||||
: first.segment
|
||||
|
||||
let segmentEnd: LLink | Reroute = linkInstance
|
||||
for (const rerouteId of chain) {
|
||||
segmentEnd.parentId = rerouteId
|
||||
const next = this.reroutes.get(rerouteId)
|
||||
if (!next) break
|
||||
segmentEnd = next
|
||||
if (!linkInstance) {
|
||||
continue
|
||||
}
|
||||
let instance: Reroute | LLink | undefined = linkInstance
|
||||
let parentId: RerouteId | undefined
|
||||
if (newLink.externalFirst) {
|
||||
parentId = newLink.eparent
|
||||
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
|
||||
while (parentId) {
|
||||
instance.parentId = parentId
|
||||
instance = this.reroutes.get(parentId)
|
||||
if (!instance) {
|
||||
console.error('Broken Id link when unpacking')
|
||||
break
|
||||
}
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
parentId = instance.parentId
|
||||
}
|
||||
}
|
||||
if (!instance) continue
|
||||
parentId = newLink.iparent
|
||||
while (parentId) {
|
||||
const migratedId = rerouteIdMap.get(parentId)
|
||||
if (!migratedId) {
|
||||
console.error('Broken Id link when unpacking')
|
||||
break
|
||||
}
|
||||
instance.parentId = migratedId
|
||||
instance = this.reroutes.get(migratedId)
|
||||
if (!instance) {
|
||||
console.error('Broken Id link when unpacking')
|
||||
break
|
||||
}
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
|
||||
if (!oldReroute) {
|
||||
console.error('Broken Id link when unpacking')
|
||||
break
|
||||
}
|
||||
parentId = oldReroute.parentId
|
||||
}
|
||||
if (!instance) break
|
||||
if (!newLink.externalFirst) {
|
||||
parentId = newLink.eparent
|
||||
while (parentId) {
|
||||
instance.parentId = parentId
|
||||
instance = this.reroutes.get(parentId)
|
||||
if (!instance) {
|
||||
console.error('Broken Id link when unpacking')
|
||||
break
|
||||
}
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
parentId = instance.parentId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2512,6 +2473,7 @@ export class LGraph
|
||||
data: ISerialisedGraph | SerialisableGraph,
|
||||
keep_old?: boolean
|
||||
): boolean | undefined {
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const options: LGraphEventMap['configuring'] = {
|
||||
data,
|
||||
clearGraph: !keep_old
|
||||
@@ -2535,7 +2497,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
|
||||
@@ -2576,7 +2538,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2630,20 +2592,6 @@ export class LGraph
|
||||
)
|
||||
: undefined
|
||||
|
||||
if (deduplicated) {
|
||||
const reservedRerouteIds = new Set<number>()
|
||||
for (const reroute of this.reroutes.values())
|
||||
reservedRerouteIds.add(Number(reroute.id))
|
||||
for (const sg of this.subgraphs.values())
|
||||
for (const reroute of sg.reroutes.values())
|
||||
reservedRerouteIds.add(Number(reroute.id))
|
||||
deduplicateSubgraphRerouteIds(
|
||||
deduplicated.subgraphs,
|
||||
reservedRerouteIds,
|
||||
this.state
|
||||
)
|
||||
}
|
||||
|
||||
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
|
||||
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
|
||||
|
||||
@@ -2713,10 +2661,14 @@ export class LGraph
|
||||
}
|
||||
}
|
||||
|
||||
// Drop reroutes that no live link or floating link passes through
|
||||
// Drop broken reroutes
|
||||
for (const reroute of this.reroutes.values()) {
|
||||
if (reroute.totalLinks === 0) {
|
||||
this._removeReroute(reroute.id)
|
||||
// Drop broken links, and ignore reroutes with no valid links
|
||||
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
|
||||
this.reroutes.delete(reroute.id)
|
||||
// Clean up layout store
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.deleteReroute(reroute.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
function createSerialisedNode(
|
||||
id: number,
|
||||
type: string,
|
||||
@@ -258,31 +256,6 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
|
||||
registeredTypesToCleanup.push(type)
|
||||
}
|
||||
|
||||
it('prunes pasted reroutes that no pasted link passes through', () => {
|
||||
const nodeType = 'test/clipboard-reroute-prune'
|
||||
registerClipboardNodeType(nodeType)
|
||||
|
||||
const rootGraph = new LGraph()
|
||||
const canvas = createCanvas(rootGraph)
|
||||
|
||||
const source = LiteGraph.createNode(nodeType)!
|
||||
rootGraph.add(source)
|
||||
const target = LiteGraph.createNode(nodeType)!
|
||||
rootGraph.add(target)
|
||||
const link = source.connect(0, target, 0)!
|
||||
rootGraph.createReroute([50, 50], link)
|
||||
|
||||
// Copying only the reroute leaves it with no pasted link through it
|
||||
const result = canvas._deserializeItems(
|
||||
canvas._serializeItems([...rootGraph.reroutes.values()]),
|
||||
{ position: [300, 300] }
|
||||
)
|
||||
|
||||
expect(result?.reroutes.size).toBe(0)
|
||||
expect(result?.created).toHaveLength(0)
|
||||
expect(rootGraph.reroutes.size).toBe(1)
|
||||
})
|
||||
|
||||
it('reconnects pasted inputs when clipboard node IDs differ from link endpoint types', () => {
|
||||
const nodeType = 'test/clipboard-node-id-normalization'
|
||||
registerClipboardNodeType(nodeType)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
@@ -15,8 +13,6 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -17,8 +15,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
function createGhostTestHarness() {
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
@@ -22,8 +20,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -21,8 +19,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
describe('LGraphCanvas slot hit detection', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
|
||||
@@ -26,7 +26,7 @@ import { LGraphNode } from './LGraphNode'
|
||||
import type { NodeProperty } from './LGraphNode'
|
||||
import { parseNodeId, serializeNodeId } from '@/types/nodeId'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { LLink, slotFloatingLinks } from './LLink'
|
||||
import { LLink } from './LLink'
|
||||
import type { LinkId } from './LLink'
|
||||
import { Reroute } from './Reroute'
|
||||
import type { RerouteId } from './Reroute'
|
||||
@@ -2784,8 +2784,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
output: INodeOutputSlot,
|
||||
network: LinkNetwork
|
||||
): boolean {
|
||||
return (output.links ?? []).some(
|
||||
(linkId) => network.getLink(linkId) !== undefined
|
||||
const outputLinks = [
|
||||
...(output.links ?? []),
|
||||
...[...(output._floatingLinks ?? new Set())]
|
||||
]
|
||||
return outputLinks.some(
|
||||
(linkId) =>
|
||||
typeof linkId === 'number' && network.getLink(linkId) !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2796,7 +2801,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) {
|
||||
// Drag multiple output links
|
||||
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
|
||||
linkConnector.moveOutputLink(graph, node, output)
|
||||
linkConnector.moveOutputLink(graph, output)
|
||||
this._linkConnectorDrop()
|
||||
return
|
||||
}
|
||||
@@ -2842,15 +2847,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctrlOrMeta &&
|
||||
e.altKey &&
|
||||
!e.shiftKey
|
||||
if (
|
||||
input.link !== null ||
|
||||
slotFloatingLinks(graph, 'input', node.id, i).length > 0
|
||||
) {
|
||||
if (input.link !== null || input._floatingLinks?.size) {
|
||||
// Existing link
|
||||
if (shouldBreakLink || LiteGraph.click_do_break_link_to) {
|
||||
node.disconnectInput(i, true)
|
||||
} else if (e.shiftKey || this.allow_reconnect_links) {
|
||||
linkConnector.moveInputLink(graph, node, input)
|
||||
linkConnector.moveInputLink(graph, input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4292,14 +4294,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
// Remove reroutes that no pasted link passes through
|
||||
for (const [sourceId, reroute] of reroutes) {
|
||||
if (reroute.totalLinks === 0) {
|
||||
graph.removeReroute(reroute.id)
|
||||
reroutes.delete(sourceId)
|
||||
// Remap linkIds
|
||||
for (const reroute of reroutes.values()) {
|
||||
const ids = [...reroute.linkIds].map((x) => links.get(x)?.id ?? x)
|
||||
reroute.update(reroute.parentId, undefined, ids, reroute.floating)
|
||||
|
||||
const index = created.indexOf(reroute)
|
||||
if (index !== -1) created.splice(index, 1)
|
||||
// Remove any invalid items
|
||||
if (!reroute.validateLinks(graph.links, graph.floatingLinks)) {
|
||||
graph.removeReroute(reroute.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -31,8 +30,7 @@ import { BadgePosition, LGraphBadge } from './LGraphBadge'
|
||||
import { LGraphButton } from './LGraphButton'
|
||||
import type { LGraphButtonOptions } from './LGraphButton'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
import { LLink, slotFloatingLinks } from './LLink'
|
||||
import { anchorRerouteChain } from './Reroute'
|
||||
import { LLink } from './LLink'
|
||||
import type { Reroute, RerouteId } from './Reroute'
|
||||
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
|
||||
import type { IDrawBoundingOptions } from './draw'
|
||||
@@ -98,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'
|
||||
@@ -1690,15 +1687,6 @@ export class LGraphNode
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.graph) {
|
||||
for (const floatingLink of this.graph.floatingLinks.values()) {
|
||||
if (
|
||||
floatingLink.origin_id === this.id &&
|
||||
floatingLink.origin_slot > slot
|
||||
)
|
||||
floatingLink.origin_slot--
|
||||
}
|
||||
}
|
||||
|
||||
this.onOutputRemoved?.(slot)
|
||||
this.setDirtyCanvas(true, true)
|
||||
@@ -1753,15 +1741,6 @@ export class LGraphNode
|
||||
if (link) link.target_slot--
|
||||
}
|
||||
}
|
||||
if (this.graph) {
|
||||
for (const floatingLink of this.graph.floatingLinks.values()) {
|
||||
if (
|
||||
floatingLink.target_id === this.id &&
|
||||
floatingLink.target_slot > slot
|
||||
)
|
||||
floatingLink.target_slot--
|
||||
}
|
||||
}
|
||||
this.onInputRemoved?.(slot, slot_info[0])
|
||||
this.setDirtyCanvas(true, true)
|
||||
}
|
||||
@@ -2076,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 {
|
||||
@@ -2926,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')
|
||||
@@ -2968,7 +2935,7 @@ export class LGraphNode
|
||||
// if there is something already plugged there, disconnect
|
||||
if (inputNode.inputs[inputIndex]?.link != null) {
|
||||
graph.beforeChange()
|
||||
inputNode.disconnectInput(inputIndex, true, afterRerouteId)
|
||||
inputNode.disconnectInput(inputIndex, true)
|
||||
}
|
||||
|
||||
const maybeCommonType =
|
||||
@@ -2988,15 +2955,52 @@ 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 ??= []
|
||||
output.links.push(link.id)
|
||||
// connect in input
|
||||
inputNode.inputs[inputIndex].link = link.id
|
||||
const targetInput = inputNode.inputs[inputIndex]
|
||||
targetInput.link = link.id
|
||||
if (targetInput.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: inputNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: inputIndex,
|
||||
connected: true,
|
||||
linkId: link.id
|
||||
})
|
||||
}
|
||||
|
||||
anchorRerouteChain(graph, link)
|
||||
// Reroutes
|
||||
const reroutes = LLink.getReroutes(graph, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.add(link.id)
|
||||
if (reroute.floating) reroute.floating = undefined
|
||||
reroute._dragging = undefined
|
||||
}
|
||||
|
||||
// If this is the terminus of a floating link, remove it
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (lastReroute) {
|
||||
for (const linkId of lastReroute.floatingLinkIds) {
|
||||
const link = graph.floatingLinks.get(linkId)
|
||||
if (link?.parentId === lastReroute.id) {
|
||||
graph.removeFloatingLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
graph.incrementVersion()
|
||||
|
||||
// link has been created now, so its updated
|
||||
@@ -3071,6 +3075,7 @@ export class LGraphNode
|
||||
if (!link)
|
||||
throw new Error('[connectFloatingReroute] Floating link not found')
|
||||
|
||||
reroute.floatingLinkIds.add(link.id)
|
||||
link.parentId = reroute.id
|
||||
parentReroute.floating = undefined
|
||||
return reroute
|
||||
@@ -3101,14 +3106,11 @@ export class LGraphNode
|
||||
const output = this.outputs[slot]
|
||||
if (!output) return false
|
||||
|
||||
if (this.graph) {
|
||||
for (const link of slotFloatingLinks(
|
||||
this.graph,
|
||||
'output',
|
||||
this.id,
|
||||
slot
|
||||
)) {
|
||||
this.graph.removeFloatingLink(link)
|
||||
if (output._floatingLinks) {
|
||||
for (const link of output._floatingLinks) {
|
||||
if (link.hasOrigin(this.id, slot)) {
|
||||
this.graph?.removeFloatingLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3136,6 +3138,15 @@ export class LGraphNode
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
// remove there
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: target.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: link_info.target_slot,
|
||||
connected: false,
|
||||
linkId: link_info.id
|
||||
})
|
||||
}
|
||||
|
||||
// remove the link from the links pool
|
||||
link_info.disconnect(graph, 'input')
|
||||
@@ -3183,6 +3194,15 @@ export class LGraphNode
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
// remove other side link
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: target.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: link_info.target_slot,
|
||||
connected: false,
|
||||
linkId: link_info.id
|
||||
})
|
||||
}
|
||||
|
||||
// link_info hasn't been modified so its ok
|
||||
target.onConnectionsChange?.(
|
||||
@@ -3215,15 +3235,9 @@ export class LGraphNode
|
||||
* Disconnect one input
|
||||
* @param slot Input slot index, or the name of the slot
|
||||
* @param keepReroutes If `true`, reroutes will not be garbage collected.
|
||||
* @param keepFloatingReroute Floating link(s) parented to this reroute are left
|
||||
* intact, so a chain being reconnected is not pruned before its new link exists.
|
||||
* @returns true if disconnected successfully or already disconnected, otherwise false
|
||||
*/
|
||||
disconnectInput(
|
||||
slot: number | string,
|
||||
keepReroutes?: boolean,
|
||||
keepFloatingReroute?: RerouteId
|
||||
): boolean {
|
||||
disconnectInput(slot: number | string, keepReroutes?: boolean): boolean {
|
||||
// Allow search by string
|
||||
if (typeof slot === 'string') {
|
||||
slot = this.findInputSlot(slot)
|
||||
@@ -3248,16 +3262,25 @@ export class LGraphNode
|
||||
const { graph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
// Break floating links, except the one whose reroute chain is being
|
||||
// reconnected (its reroute would be pruned before the new link is added).
|
||||
for (const link of slotFloatingLinks(graph, 'input', this.id, slot)) {
|
||||
if (link.parentId === keepFloatingReroute) continue
|
||||
graph.removeFloatingLink(link)
|
||||
// Break floating links
|
||||
if (input._floatingLinks?.size) {
|
||||
for (const link of input._floatingLinks) {
|
||||
graph.removeFloatingLink(link)
|
||||
}
|
||||
}
|
||||
|
||||
const link_id = this.inputs[slot].link
|
||||
if (link_id != null) {
|
||||
this.inputs[slot].link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: this.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: slot,
|
||||
connected: false,
|
||||
linkId: link_id
|
||||
})
|
||||
}
|
||||
|
||||
// remove other side
|
||||
const link_info = graph._links.get(link_id)
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
|
||||
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 { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
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('link.parentId writes are observable through the store query', () => {
|
||||
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()
|
||||
const parentId = computed(
|
||||
() => store.getInputSlotLink(graph.rootGraph.id, b.id, 0)?.parentId
|
||||
)
|
||||
expect(parentId.value).toBeUndefined()
|
||||
|
||||
link.parentId = toRerouteId(7)
|
||||
|
||||
expect(parentId.value).toBe(7)
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,18 +4,14 @@ import {
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
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'
|
||||
@@ -31,6 +27,8 @@ import type {
|
||||
} from './interfaces'
|
||||
import type { Serialisable, SerialisableLLink } from './types/serialisation'
|
||||
|
||||
const layoutMutations = useLayoutMutations()
|
||||
|
||||
export type { LinkId } from '@/types/linkId'
|
||||
export type SerialisedLLinkArray = [
|
||||
id: number,
|
||||
@@ -95,93 +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
|
||||
|
||||
/**
|
||||
* The link's topology state. Once registered with {@link useLinkStore},
|
||||
* this is the store's reactive proxy, so field writes are tracked.
|
||||
*/
|
||||
_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
|
||||
@@ -537,15 +462,19 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
network.addFloatingLink(newLink)
|
||||
}
|
||||
|
||||
network.links.delete(this.id)
|
||||
unregisterLinkTopology(this)
|
||||
layoutStore.deleteLinkLayout(this.id)
|
||||
|
||||
for (const reroute of reroutes) {
|
||||
reroute.linkIds.delete(this.id)
|
||||
if (!keepReroutes && !reroute.totalLinks) {
|
||||
network._removeReroute(reroute.id)
|
||||
network.reroutes.delete(reroute.id)
|
||||
// Delete reroute from Layout Store
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.deleteReroute(reroute.id)
|
||||
}
|
||||
}
|
||||
network.links.delete(this.id)
|
||||
// Delete link from Layout Store
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.deleteLink(this.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -576,79 +505,3 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the floating links attached to a slot. A floating link has exactly
|
||||
* one assigned endpoint, so its attachment is fully encoded in its own
|
||||
* origin/target fields; nothing is stored on the slot.
|
||||
* @param network The network whose floating links to search
|
||||
* @param side Which side of the slot's node the links attach to
|
||||
* @param nodeId The node (or subgraph IO node id) owning the slot
|
||||
* @param slot The slot index
|
||||
*/
|
||||
export function slotFloatingLinks(
|
||||
network: Pick<ReadonlyLinkNetwork, 'floatingLinks'>,
|
||||
side: 'input' | 'output',
|
||||
nodeId: NodeId,
|
||||
slot: number
|
||||
): LLink[] {
|
||||
const result: LLink[] = []
|
||||
for (const link of network.floatingLinks.values()) {
|
||||
const attached =
|
||||
side === 'input'
|
||||
? link.target_id === nodeId && link.target_slot === slot
|
||||
: link.origin_id === nodeId && link.origin_slot === slot
|
||||
if (attached) result.push(link)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a link's topology into {@link useLinkStore} and adopts the
|
||||
* store's reactive proxy as {@link LLink._state}, so the store and the link
|
||||
* always agree and field writes are tracked. 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
|
||||
const registered = useLinkStore().registerLink(graphId, link._state)
|
||||
if (registered) {
|
||||
link._state = registered
|
||||
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,236 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useRerouteStore } from '@/stores/rerouteStore'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
|
||||
|
||||
function connectedGraph() {
|
||||
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)!
|
||||
return { graph, a, b, link }
|
||||
}
|
||||
|
||||
describe('Reroute ↔ rerouteStore integration', () => {
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
it('createReroute registers the chain, removeReroute unregisters it', () => {
|
||||
const { graph, link } = connectedGraph()
|
||||
const store = useRerouteStore()
|
||||
|
||||
const reroute = graph.createReroute([10, 10], link)!
|
||||
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(
|
||||
reroute.id
|
||||
)
|
||||
|
||||
graph.removeReroute(reroute.id)
|
||||
expect(store.getReroute(graph.rootGraph.id, reroute.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setReroute (deserialisation) registers the chain', () => {
|
||||
const { graph } = connectedGraph()
|
||||
const store = useRerouteStore()
|
||||
|
||||
const reroute = graph.setReroute({
|
||||
id: toRerouteId(3),
|
||||
parentId: undefined,
|
||||
pos: [5, 5],
|
||||
linkIds: []
|
||||
})
|
||||
|
||||
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(3)
|
||||
})
|
||||
|
||||
it('class parentId writes are observable through the store query', () => {
|
||||
const { graph, link } = connectedGraph()
|
||||
const store = useRerouteStore()
|
||||
|
||||
const first = graph.createReroute([10, 10], link)!
|
||||
const second = graph.createReroute([20, 20], first)!
|
||||
|
||||
const parentId = computed(
|
||||
() => store.getReroute(graph.rootGraph.id, first.id)?.parentId
|
||||
)
|
||||
expect(parentId.value).toBe(second.id)
|
||||
|
||||
first.parentId = undefined
|
||||
|
||||
expect(parentId.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disconnect pruning an empty reroute unregisters it', () => {
|
||||
const { graph, link } = connectedGraph()
|
||||
const store = useRerouteStore()
|
||||
const reroute = graph.setReroute({
|
||||
id: toRerouteId(1),
|
||||
parentId: undefined,
|
||||
pos: [10, 10],
|
||||
linkIds: [link.id]
|
||||
})
|
||||
link.parentId = reroute.id
|
||||
|
||||
link.disconnect(graph)
|
||||
|
||||
expect(graph.reroutes.size).toBe(0)
|
||||
expect(store.getReroute(graph.rootGraph.id, reroute.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clear() removes the graph’s chains from the store', () => {
|
||||
const { graph, link } = connectedGraph()
|
||||
const store = useRerouteStore()
|
||||
const reroute = graph.createReroute([10, 10], link)!
|
||||
const graphId = graph.rootGraph.id
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(store.getReroute(graphId, reroute.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deduplicates colliding subgraph reroute ids into one root bucket', () => {
|
||||
LiteGraph.registerNodeType('dummy', LGraphNode)
|
||||
const data = structuredClone(
|
||||
duplicateSubgraphNodeIds
|
||||
) as unknown as SerialisableGraph
|
||||
const [a, b] = data.definitions!.subgraphs!
|
||||
a.reroutes = [{ id: 1, pos: [0, 0], linkIds: [1] }]
|
||||
a.links![0].parentId = toRerouteId(1)
|
||||
b.reroutes = [{ id: 1, pos: [0, 0], linkIds: [2] }]
|
||||
b.links![0].parentId = toRerouteId(1)
|
||||
|
||||
const graph = new LGraph(data)
|
||||
|
||||
const store = useRerouteStore()
|
||||
const subgraphs = [...graph.subgraphs.values()]
|
||||
const rerouteIds = subgraphs.map((sg) => [...sg.reroutes.keys()][0])
|
||||
expect(new Set(rerouteIds).size).toBe(2)
|
||||
|
||||
for (const sg of subgraphs) {
|
||||
const [reroute] = [...sg.reroutes.values()]
|
||||
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(
|
||||
reroute.id
|
||||
)
|
||||
const [link] = [...sg._links.values()]
|
||||
expect(link.parentId).toBe(reroute.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('linkIds follows the chain without manual set maintenance', () => {
|
||||
const { graph, link } = connectedGraph()
|
||||
const reroute = graph.setReroute({
|
||||
id: toRerouteId(1),
|
||||
parentId: undefined,
|
||||
pos: [10, 10],
|
||||
linkIds: []
|
||||
})
|
||||
|
||||
link.parentId = reroute.id
|
||||
|
||||
expect([...reroute.linkIds]).toEqual([link.id])
|
||||
|
||||
link.parentId = undefined
|
||||
|
||||
expect(reroute.linkIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('parentId setter rejects a mutual-parent cycle', () => {
|
||||
const { graph } = connectedGraph()
|
||||
const first = graph.setReroute({
|
||||
id: toRerouteId(1),
|
||||
parentId: undefined,
|
||||
pos: [10, 10],
|
||||
linkIds: []
|
||||
})
|
||||
const second = graph.setReroute({
|
||||
id: toRerouteId(2),
|
||||
parentId: undefined,
|
||||
pos: [20, 20],
|
||||
linkIds: []
|
||||
})
|
||||
|
||||
first.parentId = second.id
|
||||
second.parentId = first.id
|
||||
|
||||
expect(second.parentId).toBeUndefined()
|
||||
expect(first.getReroutes()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('parentId setter rejects extending a chain back onto its root', () => {
|
||||
const { graph } = connectedGraph()
|
||||
const a = graph.setReroute({
|
||||
id: toRerouteId(1),
|
||||
parentId: undefined,
|
||||
pos: [0, 0],
|
||||
linkIds: []
|
||||
})
|
||||
const b = graph.setReroute({
|
||||
id: toRerouteId(2),
|
||||
parentId: a.id,
|
||||
pos: [0, 0],
|
||||
linkIds: []
|
||||
})
|
||||
const c = graph.setReroute({
|
||||
id: toRerouteId(3),
|
||||
parentId: b.id,
|
||||
pos: [0, 0],
|
||||
linkIds: []
|
||||
})
|
||||
|
||||
a.parentId = c.id
|
||||
|
||||
expect(a.parentId).toBeUndefined()
|
||||
expect(c.getReroutes()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('snapToGrid mirrors the snapped position into the layout store', () => {
|
||||
const { graph, link } = connectedGraph()
|
||||
const reroute = graph.createReroute([12, 17], link)!
|
||||
|
||||
reroute.snapToGrid(10)
|
||||
|
||||
expect(layoutStore.getRerouteLayout(reroute.id)?.position).toEqual({
|
||||
x: reroute.pos[0],
|
||||
y: reroute.pos[1]
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses parentId writes that would create a cycle, allows repair', () => {
|
||||
const { graph, link } = connectedGraph()
|
||||
const first = graph.createReroute([10, 10], link)!
|
||||
const second = graph.createReroute([20, 20], first)!
|
||||
expect(first.parentId).toBe(second.id)
|
||||
|
||||
second.parentId = first.id
|
||||
|
||||
expect(second.parentId).toBeUndefined()
|
||||
|
||||
second._chain.parentId = first.id
|
||||
second.parentId = undefined
|
||||
|
||||
expect(second.parentId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('floating marker survives through the store state', () => {
|
||||
const { graph, a, link } = connectedGraph()
|
||||
const store = useRerouteStore()
|
||||
const reroute = graph.createReroute([10, 10], link)!
|
||||
|
||||
a.disconnectOutput(0)
|
||||
|
||||
expect(reroute.floating).toEqual({ slotType: 'input' })
|
||||
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.floating).toEqual({
|
||||
slotType: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { EMPTY_MEMBERSHIP, useRerouteStore } from '@/stores/rerouteStore'
|
||||
import type { RerouteMembership } from '@/stores/rerouteStore'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { FloatingRerouteSlot, RerouteChain } from '@/types/rerouteChain'
|
||||
import type { RerouteId } from '@/types/rerouteId'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
import { LGraphBadge } from './LGraphBadge'
|
||||
import type { LGraph } from './LGraph'
|
||||
import type { LGraphNode } from './LGraphNode'
|
||||
import { LLink } from './LLink'
|
||||
import type { LinkId } from './LLink'
|
||||
@@ -30,9 +25,14 @@ import type { Serialisable, SerialisableReroute } from './types/serialisation'
|
||||
|
||||
const layoutMutations = useLayoutMutations()
|
||||
|
||||
export type { FloatingRerouteSlot } from '@/types/rerouteChain'
|
||||
export type { RerouteId } from '@/types/rerouteId'
|
||||
|
||||
/** The input or output slot that an incomplete reroute link is connected to. */
|
||||
export interface FloatingRerouteSlot {
|
||||
/** Floating connection to an input or output */
|
||||
slotType: 'input' | 'output'
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an additional point on the graph that a link path will travel through. Used for visual organisation only.
|
||||
*
|
||||
@@ -59,52 +59,24 @@ export class Reroute
|
||||
/** The network this reroute belongs to. Contains all valid links and reroutes. */
|
||||
private readonly network: WeakRef<LinkNetwork>
|
||||
|
||||
/**
|
||||
* The reroute's chain state. Once registered with {@link useRerouteStore},
|
||||
* this is the store's reactive proxy, so field writes are tracked.
|
||||
*/
|
||||
_chain: RerouteChain
|
||||
|
||||
/** The graph this reroute is registered with in {@link useRerouteStore}, if any. */
|
||||
_graphId?: UUID
|
||||
|
||||
private parentIdInternal?: RerouteId
|
||||
public get parentId(): RerouteId | undefined {
|
||||
return this._chain.parentId
|
||||
return this.parentIdInternal
|
||||
}
|
||||
|
||||
/** Ignores attempts to create an infinite loop. @inheritdoc */
|
||||
public set parentId(value) {
|
||||
if (value === this.id) return
|
||||
if (value !== undefined && this.createsParentCycle(value)) return
|
||||
this._chain.parentId = value
|
||||
}
|
||||
|
||||
/** Walks the prospective parent chain from `value`, reporting whether it loops back to this reroute. */
|
||||
private createsParentCycle(value: RerouteId): boolean {
|
||||
const network = this.network.deref()
|
||||
const visited = new Set<RerouteId>([this.id])
|
||||
|
||||
let nextId: RerouteId | undefined = value
|
||||
while (nextId !== undefined) {
|
||||
if (visited.has(nextId)) return true
|
||||
visited.add(nextId)
|
||||
nextId = network?.reroutes.get(nextId)?.parentId
|
||||
}
|
||||
return false
|
||||
if (this.getReroutes() === null) return
|
||||
this.parentIdInternal = value
|
||||
}
|
||||
|
||||
public get parent(): Reroute | undefined {
|
||||
return this.network.deref()?.getReroute(this._chain.parentId)
|
||||
return this.network.deref()?.getReroute(this.parentIdInternal)
|
||||
}
|
||||
|
||||
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
|
||||
get floating(): FloatingRerouteSlot | undefined {
|
||||
return this._chain.floating
|
||||
}
|
||||
|
||||
set floating(value: FloatingRerouteSlot | undefined) {
|
||||
this._chain.floating = value
|
||||
}
|
||||
floating?: FloatingRerouteSlot
|
||||
|
||||
private readonly posInternal: Point = [0, 0]
|
||||
/** @inheritdoc */
|
||||
@@ -148,24 +120,11 @@ export class Reroute
|
||||
/** @inheritdoc */
|
||||
selected?: boolean
|
||||
|
||||
private get membership(): RerouteMembership {
|
||||
return this._graphId
|
||||
? useRerouteStore().getMembership(this._graphId, this.id)
|
||||
: EMPTY_MEMBERSHIP
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID ({@link LLink.id}) of every link using this reroute.
|
||||
* Derived from the links' parentId chains; never stored.
|
||||
*/
|
||||
get linkIds(): ReadonlySet<LinkId> {
|
||||
return this.membership.linkIds
|
||||
}
|
||||
/** The ID ({@link LLink.id}) of every link using this reroute */
|
||||
linkIds: Set<LinkId>
|
||||
|
||||
/** The ID ({@link LLink.id}) of every floating link using this reroute */
|
||||
get floatingLinkIds(): ReadonlySet<LinkId> {
|
||||
return this.membership.floatingLinkIds
|
||||
}
|
||||
floatingLinkIds: Set<LinkId>
|
||||
|
||||
/** Cached cos */
|
||||
cos: number = 0
|
||||
@@ -242,18 +201,61 @@ export class Reroute
|
||||
* @param id Unique identifier for this reroute
|
||||
* @param network The network of links this reroute belongs to. Internally converted to a WeakRef.
|
||||
* @param pos Position in graph coordinates
|
||||
* @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute
|
||||
*/
|
||||
constructor(
|
||||
id: RerouteId,
|
||||
network: LinkNetwork,
|
||||
pos?: Point,
|
||||
parentId?: RerouteId
|
||||
parentId?: RerouteId,
|
||||
linkIds?: Iterable<LinkId>,
|
||||
floatingLinkIds?: Iterable<LinkId>
|
||||
) {
|
||||
this.id = id
|
||||
this.network = new WeakRef(network)
|
||||
this._chain = { id }
|
||||
this.parentId = parentId
|
||||
if (pos) this.pos = pos
|
||||
this.linkIds = new Set(linkIds)
|
||||
this.floatingLinkIds = new Set(floatingLinkIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a new parentId to the reroute, and optinoally a new position and linkId.
|
||||
* Primarily used for deserialisation.
|
||||
* @param parentId The ID of the reroute prior to this reroute, or
|
||||
* `undefined` if it is the first reroute connected to a nodes output
|
||||
* @param pos The position of this reroute
|
||||
* @param linkIds All link IDs that pass through this reroute
|
||||
*/
|
||||
update(
|
||||
parentId: RerouteId | undefined,
|
||||
pos?: Point,
|
||||
linkIds?: Iterable<LinkId>,
|
||||
floating?: FloatingRerouteSlot
|
||||
): void {
|
||||
this.parentId = parentId
|
||||
if (pos) this.pos = pos
|
||||
if (linkIds) this.linkIds = new Set(linkIds)
|
||||
this.floating = floating
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the linkIds this reroute has. Removes broken links.
|
||||
* @param links Collection of valid links
|
||||
* @returns true if any links remain after validation
|
||||
*/
|
||||
validateLinks(
|
||||
links: ReadonlyMap<LinkId, LLink>,
|
||||
floatingLinks: ReadonlyMap<LinkId, LLink>
|
||||
): boolean {
|
||||
const { linkIds, floatingLinkIds } = this
|
||||
for (const linkId of linkIds) {
|
||||
if (!links.has(linkId)) linkIds.delete(linkId)
|
||||
}
|
||||
for (const linkId of floatingLinkIds) {
|
||||
if (!floatingLinks.has(linkId)) floatingLinkIds.delete(linkId)
|
||||
}
|
||||
return linkIds.size > 0 || floatingLinkIds.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,15 +268,15 @@ export class Reroute
|
||||
*/
|
||||
getReroutes(visited = new Set<Reroute>()): Reroute[] | null {
|
||||
// No parentId - last in the chain
|
||||
if (this._chain.parentId === undefined) return [this]
|
||||
if (this.parentIdInternal === undefined) return [this]
|
||||
// Invalid chain - looped
|
||||
if (visited.has(this)) return null
|
||||
visited.add(this)
|
||||
|
||||
const parent = this.network.deref()?.reroutes.get(this._chain.parentId)
|
||||
const parent = this.network.deref()?.reroutes.get(this.parentIdInternal)
|
||||
// Invalid parent (or network) - drop silently to recover
|
||||
if (!parent) {
|
||||
this._chain.parentId = undefined
|
||||
this.parentIdInternal = undefined
|
||||
return [this]
|
||||
}
|
||||
|
||||
@@ -293,14 +295,14 @@ export class Reroute
|
||||
withParentId: RerouteId,
|
||||
visited = new Set<Reroute>()
|
||||
): Reroute | null | undefined {
|
||||
if (this._chain.parentId === withParentId) return this
|
||||
if (this.parentIdInternal === withParentId) return this
|
||||
if (visited.has(this)) return null
|
||||
visited.add(this)
|
||||
if (this._chain.parentId === undefined) return
|
||||
if (this.parentIdInternal === undefined) return
|
||||
|
||||
return this.network
|
||||
.deref()
|
||||
?.reroutes.get(this._chain.parentId)
|
||||
?.reroutes.get(this.parentIdInternal)
|
||||
?.findNextReroute(withParentId, visited)
|
||||
}
|
||||
|
||||
@@ -384,14 +386,31 @@ export class Reroute
|
||||
/**
|
||||
* Changes the origin node/output of all floating links that pass through this reroute.
|
||||
* @param node The new origin node
|
||||
* @param index The slot index of the new origin output
|
||||
* @param output The new origin output slot
|
||||
* @param index The slot index of {@link output}
|
||||
*/
|
||||
setFloatingLinkOrigin(node: LGraphNode, index: number) {
|
||||
setFloatingLinkOrigin(
|
||||
node: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
index: number
|
||||
) {
|
||||
const network = this.network.deref()
|
||||
const floatingOutLinks = this.getFloatingLinks('output')
|
||||
if (!floatingOutLinks)
|
||||
throw new Error('[setFloatingLinkOrigin]: Invalid network.')
|
||||
if (!floatingOutLinks.length) return
|
||||
|
||||
output._floatingLinks ??= new Set()
|
||||
|
||||
for (const link of floatingOutLinks) {
|
||||
// Update cached floating links
|
||||
output._floatingLinks.add(link)
|
||||
|
||||
network
|
||||
?.getNodeById(link.origin_id)
|
||||
?.outputs[link.origin_slot]?._floatingLinks?.delete(link)
|
||||
|
||||
// Update the floating link
|
||||
link.origin_id = node.id
|
||||
link.origin_slot = index
|
||||
}
|
||||
@@ -418,22 +437,47 @@ export class Reroute
|
||||
|
||||
const offsetY = LiteGraph.NODE_SLOT_HEIGHT * 0.7
|
||||
const { pos } = this
|
||||
const previousPos = { x: pos[0], y: pos[1] }
|
||||
pos[0] = snapTo * Math.round(pos[0] / snapTo)
|
||||
pos[1] = snapTo * Math.round((pos[1] - offsetY) / snapTo) + offsetY
|
||||
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.moveReroute(this.id, { x: pos[0], y: pos[1] }, previousPos)
|
||||
return true
|
||||
}
|
||||
|
||||
removeAllFloatingLinks() {
|
||||
for (const linkId of this.floatingLinkIds) {
|
||||
this.removeFloatingLink(linkId)
|
||||
}
|
||||
}
|
||||
|
||||
removeFloatingLink(linkId: LinkId) {
|
||||
const network = this.network.deref()
|
||||
if (!network) return
|
||||
|
||||
for (const linkId of [...this.floatingLinkIds]) {
|
||||
const floatingLink = network.floatingLinks.get(linkId)
|
||||
if (floatingLink) network.removeFloatingLink(floatingLink)
|
||||
const floatingLink = network.floatingLinks.get(linkId)
|
||||
if (!floatingLink) {
|
||||
console.warn(
|
||||
`[Reroute.removeFloatingLink] Floating link not found: ${linkId}, ignoring and discarding ID.`
|
||||
)
|
||||
this.floatingLinkIds.delete(linkId)
|
||||
return
|
||||
}
|
||||
|
||||
network.removeFloatingLink(floatingLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a link or floating link from this reroute, by matching link object instance equality.
|
||||
* @param link The link to remove.
|
||||
* @remarks Does not remove the link from the network.
|
||||
*/
|
||||
removeLink(link: LLink) {
|
||||
const network = this.network.deref()
|
||||
if (!network) return
|
||||
|
||||
const floatingLink = network.floatingLinks.get(link.id)
|
||||
if (link === floatingLink) {
|
||||
this.floatingLinkIds.delete(link.id)
|
||||
} else {
|
||||
this.linkIds.delete(link.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,7 +688,7 @@ export class Reroute
|
||||
id,
|
||||
parentId,
|
||||
pos: [pos[0], pos[1]],
|
||||
linkIds: [...linkIds].sort((a, b) => a - b),
|
||||
linkIds: [...linkIds],
|
||||
floating: this.floating ? { slotType: this.floating.slotType } : undefined
|
||||
}
|
||||
}
|
||||
@@ -778,68 +822,3 @@ function getNextPos(
|
||||
function getDirection(fromPos: Point, toPos: Point) {
|
||||
return Math.atan2(toPos[1] - fromPos[1], toPos[0] - fromPos[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a link's reroute chain as no longer floating: clears each reroute's
|
||||
* floating marker and drag state, and removes any floating link that
|
||||
* terminates at the chain's last reroute. Call when a real link connects
|
||||
* through the chain.
|
||||
* @param network The network containing the chain
|
||||
* @param link The link whose chain was just connected
|
||||
*/
|
||||
export function anchorRerouteChain(network: LinkNetwork, link: LLink): void {
|
||||
const reroutes = LLink.getReroutes(network, link)
|
||||
for (const reroute of reroutes) {
|
||||
reroute.floating = undefined
|
||||
reroute._dragging = undefined
|
||||
}
|
||||
|
||||
const lastReroute = reroutes.at(-1)
|
||||
if (!lastReroute) return
|
||||
for (const linkId of lastReroute.floatingLinkIds) {
|
||||
const floatingLink = network.floatingLinks.get(linkId)
|
||||
if (floatingLink?.parentId === lastReroute.id) {
|
||||
network.removeFloatingLink(floatingLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a reroute's chain state into {@link useRerouteStore} and adopts
|
||||
* the store's reactive proxy as {@link Reroute._chain}, so the store and the
|
||||
* reroute always agree and field writes are tracked. Call this at every
|
||||
* site that adds a reroute to a graph's reroute map.
|
||||
* @param graph The graph (or subgraph) the reroute belongs to
|
||||
* @param reroute The reroute to register
|
||||
*/
|
||||
export function registerRerouteChain(
|
||||
graph: Pick<LGraph, 'rootGraph'>,
|
||||
reroute: Reroute
|
||||
): void {
|
||||
const graphId = graph.rootGraph.id
|
||||
reroute._chain = useRerouteStore().registerReroute(graphId, reroute._chain)
|
||||
reroute._graphId = graphId
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a reroute's chain state from {@link useRerouteStore} and detaches
|
||||
* the reroute. No-op for reroutes that were never registered.
|
||||
* @param reroute The reroute to unregister
|
||||
*/
|
||||
export function unregisterRerouteChain(reroute: Reroute): void {
|
||||
if (!reroute._graphId) return
|
||||
useRerouteStore().deleteReroute(reroute._graphId, reroute._chain)
|
||||
reroute._graphId = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters every reroute a graph owns. Used when a graph's reroutes
|
||||
* 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 reroutes should be unregistered
|
||||
*/
|
||||
export function unregisterAllRerouteChains(
|
||||
graph: Pick<LGraph, 'reroutes'>
|
||||
): void {
|
||||
for (const reroute of graph.reroutes.values()) unregisterRerouteChain(reroute)
|
||||
}
|
||||
|
||||