Compare commits
35 Commits
regen-scre
...
v1.42.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4781775a78 | ||
|
|
74a48ab2aa | ||
|
|
0875e2f50f | ||
|
|
63442d2fb0 | ||
|
|
bb6f00dc68 | ||
|
|
11fc09220c | ||
|
|
16f4f3f3ed | ||
|
|
4c5a49860c | ||
|
|
db5e8961e0 | ||
|
|
9f9fa60137 | ||
|
|
7131c274f3 | ||
|
|
82556f02a9 | ||
|
|
e34548724d | ||
|
|
fcdc08fb27 | ||
|
|
f1626acb61 | ||
|
|
5640eb7d92 | ||
|
|
9447a1f5d6 | ||
|
|
1054503b4e | ||
|
|
9652871aaf | ||
|
|
1280d4110d | ||
|
|
871715113c | ||
|
|
a9f9afd062 | ||
|
|
79d0e6dc69 | ||
|
|
de866a15d2 | ||
|
|
31a33a0ba2 | ||
|
|
d73f8e1beb | ||
|
|
4e85537b15 | ||
|
|
b5ddc70233 | ||
|
|
48d928fc9e | ||
|
|
10b0350d01 | ||
|
|
5167a511ce | ||
|
|
4bdf67ca21 | ||
|
|
e119383072 | ||
|
|
f4bf169b2f | ||
|
|
07d5bd50f6 |
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
|
||||
"Bash(pnpx vitest run \"draftCacheV2.property\")",
|
||||
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
179
.claude/skills/perf-fix-with-proof/SKILL.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
name: perf-fix-with-proof
|
||||
description: 'Ships performance fixes with CI-proven improvement using stacked PRs. PR1 adds a @perf test (establishes baseline on main), PR2 adds the fix (CI shows delta). Use when implementing a perf optimization and wanting to prove it in CI.'
|
||||
---
|
||||
|
||||
# Performance Fix with Proof
|
||||
|
||||
Ships perf fixes as two stacked PRs so CI automatically proves the improvement.
|
||||
|
||||
## Why Two PRs
|
||||
|
||||
The `ci-perf-report.yaml` workflow compares PR metrics against the **base branch baseline**. If you add a new `@perf` test in the same PR as the fix, that test doesn't exist on main yet — no baseline, no delta, no proof. Stacking solves this:
|
||||
|
||||
1. **PR1 (test-only)** — adds the `@perf` test that exercises the bottleneck. Merges to main. CI runs it on main → baseline established.
|
||||
2. **PR2 (fix)** — adds the optimization. CI runs the same test → compares against PR1's baseline → delta shows improvement.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Create the test branch
|
||||
|
||||
```bash
|
||||
git worktree add <worktree-path> -b perf/test-<name> origin/main
|
||||
```
|
||||
|
||||
### Step 2: Write the `@perf` test
|
||||
|
||||
Add a test to `browser_tests/tests/performance.spec.ts` (or a new file with `@perf` tag). The test should stress the specific bottleneck.
|
||||
|
||||
**Test structure:**
|
||||
|
||||
```typescript
|
||||
test('<descriptive name>', async ({ comfyPage }) => {
|
||||
// 1. Load a workflow that exercises the bottleneck
|
||||
await comfyPage.workflow.loadWorkflow('<workflow>')
|
||||
|
||||
// 2. Start measuring
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// 3. Perform the action that triggers the bottleneck (at scale)
|
||||
for (let i = 0; i < N; i++) {
|
||||
// ... stress the hot path ...
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// 4. Stop measuring and record
|
||||
const m = await comfyPage.perf.stopMeasuring('<metric-name>')
|
||||
recordMeasurement(m)
|
||||
console.log(`<name>: ${m.styleRecalcs} recalcs, ${m.layouts} layouts`)
|
||||
})
|
||||
```
|
||||
|
||||
**Available metrics** (from `PerformanceHelper`):
|
||||
|
||||
- `m.styleRecalcs` / `m.styleRecalcDurationMs` — style recalculation count and time
|
||||
- `m.layouts` / `m.layoutDurationMs` — forced layout count and time
|
||||
- `m.taskDurationMs` — total main-thread JS execution time
|
||||
- `m.heapDeltaBytes` — memory pressure delta
|
||||
|
||||
**Key helpers** (from `ComfyPage`):
|
||||
|
||||
- `comfyPage.perf.startMeasuring()` / `.stopMeasuring(name)` — CDP metrics capture
|
||||
- `comfyPage.nextFrame()` — wait one animation frame
|
||||
- `comfyPage.workflow.loadWorkflow(name)` — load a test workflow from `browser_tests/assets/`
|
||||
- `comfyPage.canvas` — the canvas locator
|
||||
- `comfyPage.page.mouse.move(x, y)` — mouse interaction
|
||||
|
||||
### Step 3: Add test workflow asset (if needed)
|
||||
|
||||
If the bottleneck needs a specific workflow (e.g., 50+ nodes, many DOM widgets), add it to `browser_tests/assets/`. Keep it minimal — only the structure needed to trigger the bottleneck.
|
||||
|
||||
### Step 4: Verify locally
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --project=performance --grep "<test name>"
|
||||
```
|
||||
|
||||
Confirm the test runs and produces reasonable metric values.
|
||||
|
||||
### Step 5: Create PR1 (test-only)
|
||||
|
||||
```bash
|
||||
pnpm typecheck:browser
|
||||
pnpm lint
|
||||
git add browser_tests/
|
||||
git commit -m "test: add perf test for <bottleneck description>"
|
||||
git push -u origin perf/test-<name>
|
||||
gh pr create --title "test: add perf test for <bottleneck>" \
|
||||
--body "Adds a @perf test to establish a baseline for <bottleneck>.
|
||||
|
||||
This is PR 1 of 2. The fix will follow in a separate PR once this baseline is established on main.
|
||||
|
||||
## What
|
||||
Adds \`<test-name>\` to the performance test suite measuring <metric> during <action>.
|
||||
|
||||
## Why
|
||||
Needed to prove the improvement from the upcoming fix for backlog item #<N>." \
|
||||
--base main
|
||||
```
|
||||
|
||||
### Step 6: Get PR1 merged
|
||||
|
||||
Once PR1 merges, CI runs the test on main → baseline artifact saved.
|
||||
|
||||
### Step 7: Create PR2 (fix) on top of main
|
||||
|
||||
```bash
|
||||
git worktree add <worktree-path> -b perf/fix-<name> origin/main
|
||||
```
|
||||
|
||||
Implement the fix. The `@perf` test from PR1 is now on main and will run automatically. CI will:
|
||||
|
||||
1. Run the test on the PR branch
|
||||
2. Download the baseline from main (which includes PR1's test results)
|
||||
3. Post a PR comment showing the delta
|
||||
|
||||
### Step 8: Verify the improvement shows in CI
|
||||
|
||||
The `ci-perf-report.yaml` posts a comment like:
|
||||
|
||||
```markdown
|
||||
## ⚡ Performance Report
|
||||
|
||||
| Metric | Baseline | PR (n=3) | Δ | Sig |
|
||||
| --------------------- | -------- | -------- | ---- | --- |
|
||||
| <name>: style recalcs | 450 | 12 | -97% | 🟢 |
|
||||
```
|
||||
|
||||
If Δ is negative for the target metric, the fix is proven.
|
||||
|
||||
## Test Design Guidelines
|
||||
|
||||
1. **Stress the specific bottleneck** — don't measure everything, isolate the hot path
|
||||
2. **Use enough iterations** — the test should run long enough that the metric difference is clear (100+ frames for idle tests, 50+ interactions for event tests)
|
||||
3. **Keep it deterministic** — avoid timing-dependent assertions; measure counts not durations when possible
|
||||
4. **Match the backlog entry** — reference the backlog item number in the test name or PR description
|
||||
|
||||
## Examples
|
||||
|
||||
**Testing DOM widget reactive mutations (backlog #8):**
|
||||
|
||||
```typescript
|
||||
test('DOM widget positioning recalculations', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
// Idle for 120 frames — DOM widgets update position every frame
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const m = await comfyPage.perf.stopMeasuring('dom-widget-idle')
|
||||
recordMeasurement(m)
|
||||
})
|
||||
```
|
||||
|
||||
**Testing measureText caching (backlog #4):**
|
||||
|
||||
```typescript
|
||||
test('canvas text rendering with many nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-workflow-50-nodes')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const m = await comfyPage.perf.stopMeasuring('text-rendering-50-nodes')
|
||||
recordMeasurement(m)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| Perf test file | `browser_tests/tests/performance.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
|
||||
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
|
||||
| Report generator | `scripts/perf-report.ts` |
|
||||
| Stats utilities | `scripts/perf-stats.ts` |
|
||||
| Backlog | `docs/perf/BACKLOG.md` (local only, not committed) |
|
||||
| Playbook | `docs/perf/PLAYBOOK.md` (local only, not committed) |
|
||||
361
.claude/skills/ticket-intake/SKILL.md
Normal file
@@ -0,0 +1,361 @@
|
||||
---
|
||||
name: ticket-intake
|
||||
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
|
||||
---
|
||||
|
||||
# Ticket Intake
|
||||
|
||||
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
|
||||
|
||||
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
|
||||
|
||||
## Supported Sources
|
||||
|
||||
| Source | URL Pattern | Provider File |
|
||||
| ------ | --------------------------------------------------- | --------------------- |
|
||||
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
|
||||
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
When given a ticket URL:
|
||||
|
||||
1. **Detect source type** from URL pattern
|
||||
2. **Load provider-specific logic** from `providers/` directory
|
||||
3. Fetch ticket content via appropriate API
|
||||
4. Extract and normalize properties to common schema
|
||||
5. **Register ticket in pipeline API** ← MANDATORY
|
||||
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
|
||||
7. **Run verification script** to confirm API registration
|
||||
8. Output summary and handoff to `research-orchestrator`
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses the **production API** by default. No configuration needed for read operations.
|
||||
|
||||
**Defaults (no setup required):**
|
||||
|
||||
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
|
||||
- Read-only endpoints at `/public/*` require no authentication
|
||||
|
||||
**For write operations** (transitions, creating tickets), set:
|
||||
|
||||
```bash
|
||||
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
|
||||
```
|
||||
|
||||
**Optional (for local working artifacts):**
|
||||
|
||||
```bash
|
||||
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
|
||||
```
|
||||
|
||||
## Mandatory API Calls (Execute ALL Three)
|
||||
|
||||
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
|
||||
|
||||
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
|
||||
|
||||
### Call 1: Create Ticket
|
||||
|
||||
```bash
|
||||
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
|
||||
API_KEY="${PIPELINE_API_KEY}"
|
||||
|
||||
curl -s -X POST "${API_URL}/v1/tickets" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"notion_page_id": "NOTION_PAGE_UUID_HERE",
|
||||
"title": "TICKET_TITLE_HERE",
|
||||
"source": "notion",
|
||||
"metadata": {
|
||||
"description": "DESCRIPTION_HERE",
|
||||
"priority": "High",
|
||||
"labels": [],
|
||||
"acceptanceCriteria": []
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Save the returned `id` — you need it for the next two calls.
|
||||
|
||||
### Call 2: Transition to RESEARCH
|
||||
|
||||
```bash
|
||||
TICKET_ID="id-from-step-1"
|
||||
|
||||
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"to_state": "RESEARCH",
|
||||
"reason": "Intake complete, starting research"
|
||||
}'
|
||||
```
|
||||
|
||||
### Call 3: Queue Source Update
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${API_URL}/v1/sync/queue" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"ticket_id": "TICKET_ID_HERE",
|
||||
"action": "update_status",
|
||||
"payload": { "status": "In Progress" },
|
||||
"priority": "normal"
|
||||
}'
|
||||
```
|
||||
|
||||
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
|
||||
|
||||
### TypeScript Equivalent (if using pipeline client)
|
||||
|
||||
```typescript
|
||||
import { PipelineClient } from '@pipeline/client'
|
||||
|
||||
const client = new PipelineClient({
|
||||
apiUrl:
|
||||
process.env.PIPELINE_API_URL ||
|
||||
'https://api-gateway-856475788601.us-central1.run.app',
|
||||
agentId: process.env.AGENT_ID!
|
||||
})
|
||||
|
||||
const ticket = await client.createTicket({
|
||||
notion_page_id: pageId,
|
||||
title: ticketTitle,
|
||||
source: 'notion',
|
||||
metadata: { description, priority, labels, acceptanceCriteria }
|
||||
})
|
||||
|
||||
await client.transitionState(
|
||||
ticket.id,
|
||||
'RESEARCH',
|
||||
'Intake complete, starting research'
|
||||
)
|
||||
|
||||
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Detect Source Type
|
||||
|
||||
Parse the URL to determine source:
|
||||
|
||||
```javascript
|
||||
if (url.includes('notion.so')) {
|
||||
source = 'notion'
|
||||
// Load providers/notion.md
|
||||
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
|
||||
source = 'github'
|
||||
// Load providers/github.md
|
||||
} else {
|
||||
// Error: Unsupported URL format
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Load Provider and Fetch Data
|
||||
|
||||
Read the appropriate provider file for source-specific instructions:
|
||||
|
||||
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
|
||||
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
|
||||
|
||||
Follow the provider's instructions for:
|
||||
|
||||
- Fetching content
|
||||
- Extracting properties
|
||||
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
|
||||
|
||||
### Step 3: Normalize to Common Schema
|
||||
|
||||
All providers must extract normalized ticket data following `schema.md`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "abc12345",
|
||||
"url": "https://...",
|
||||
"source": "notion | github",
|
||||
"title": "Ticket title",
|
||||
"description": "Full description",
|
||||
"status": "Not Started",
|
||||
"assignee": "username",
|
||||
"priority": "High",
|
||||
"area": "UI",
|
||||
"labels": ["bug", "frontend"],
|
||||
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
|
||||
"fetchedAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
|
||||
|
||||
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
|
||||
|
||||
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
|
||||
|
||||
1. `createTicket()` → save the returned ticket ID
|
||||
2. `transitionState(id, 'RESEARCH')` → confirm state changed
|
||||
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
|
||||
|
||||
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
|
||||
|
||||
### Step 5: Run Verification Script
|
||||
|
||||
After making the API calls, run the verification script to confirm everything worked:
|
||||
|
||||
```bash
|
||||
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
|
||||
```
|
||||
|
||||
**If the script is not available locally**, verify manually via the public API:
|
||||
|
||||
```bash
|
||||
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"state": "RESEARCH",
|
||||
"title": "...",
|
||||
"notion_page_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
|
||||
|
||||
### Step 6: Output Summary and Handoff
|
||||
|
||||
Print a clear summary:
|
||||
|
||||
```markdown
|
||||
## Ticket Intake Complete
|
||||
|
||||
**Source:** Notion | GitHub
|
||||
**Title:** [Ticket title]
|
||||
**ID:** abc12345
|
||||
**Status:** In Progress (queued)
|
||||
**Priority:** High
|
||||
**Area:** UI
|
||||
|
||||
### Description
|
||||
|
||||
[Brief description or first 200 chars]
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
|
||||
### Links
|
||||
|
||||
- **Ticket:** [Original URL]
|
||||
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
|
||||
|
||||
### Pipeline
|
||||
|
||||
- **API Ticket ID:** abc12345
|
||||
- **State:** RESEARCH
|
||||
- **Verified:** ✅ (via verify-intake.sh or public API)
|
||||
```
|
||||
|
||||
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
|
||||
|
||||
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
|
||||
|
||||
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Unsupported URL
|
||||
|
||||
```
|
||||
❌ Unsupported ticket URL format.
|
||||
|
||||
Supported formats:
|
||||
- Notion: https://notion.so/... or https://www.notion.so/...
|
||||
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
|
||||
|
||||
Received: [provided URL]
|
||||
```
|
||||
|
||||
### Provider-Specific Errors
|
||||
|
||||
See individual provider files for source-specific error handling:
|
||||
|
||||
- `providers/notion.md` - Authentication, page not found
|
||||
- `providers/github.md` - Auth, rate limits, issue not found
|
||||
|
||||
### Missing Properties
|
||||
|
||||
Continue with available data and note what's missing:
|
||||
|
||||
```
|
||||
⚠️ Some properties unavailable:
|
||||
- Priority: not found (using default: Medium)
|
||||
- Area: not found
|
||||
|
||||
Proceeding with available data...
|
||||
```
|
||||
|
||||
### API Call Failures
|
||||
|
||||
```
|
||||
❌ Pipeline API call failed: {method} {endpoint}
|
||||
Status: {status}
|
||||
Error: {message}
|
||||
|
||||
Retrying once...
|
||||
|
||||
❌ Retry also failed. INTAKE IS INCOMPLETE.
|
||||
The ticket was NOT registered in the pipeline.
|
||||
Downstream skills will not work until this is fixed.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This skill focuses ONLY on intake — it does not do research
|
||||
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
|
||||
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
|
||||
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
|
||||
- Pipeline state is tracked via the API, not local files
|
||||
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
|
||||
- The `source` field in the ticket determines which research strategies to use
|
||||
|
||||
## API Client Reference
|
||||
|
||||
### Available Methods
|
||||
|
||||
| Method | Description |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
|
||||
| `getTicket(id)` | Retrieve a ticket by ID |
|
||||
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
|
||||
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
|
||||
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
|
||||
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
|
||||
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
|
||||
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
|
||||
|
||||
try {
|
||||
await client.createTicket({ ... });
|
||||
} catch (error) {
|
||||
if (error instanceof PipelineAPIError) {
|
||||
console.error(`API Error ${error.status}: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
194
.claude/skills/ticket-intake/providers/github.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# GitHub Provider - Ticket Intake
|
||||
|
||||
Provider-specific logic for ingesting tickets from GitHub Issues.
|
||||
|
||||
## URL Pattern
|
||||
|
||||
```
|
||||
https://github.com/{owner}/{repo}/issues/{number}
|
||||
https://www.github.com/{owner}/{repo}/issues/{number}
|
||||
```
|
||||
|
||||
Extract: `owner`, `repo`, `issue_number` from URL.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Access to the repository
|
||||
|
||||
## Fetch Issue Content
|
||||
|
||||
Use `gh` CLI to fetch issue details:
|
||||
|
||||
```bash
|
||||
# Get issue details in JSON
|
||||
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
|
||||
|
||||
# Get comments separately if needed
|
||||
gh issue view {number} --repo {owner}/{repo} --comments
|
||||
```
|
||||
|
||||
## Extract Ticket Data
|
||||
|
||||
Map GitHub issue fields to normalized ticket data (stored via API):
|
||||
|
||||
| GitHub Field | ticket.json Field | Notes |
|
||||
| ------------ | ----------------- | -------------------------- |
|
||||
| title | title | Direct mapping |
|
||||
| body | description | Issue body/description |
|
||||
| state | status | Map: open → "Not Started" |
|
||||
| labels | labels | Array of label names |
|
||||
| assignees | assignee | First assignee login |
|
||||
| author | author | Issue author login |
|
||||
| milestone | milestone | Milestone title if present |
|
||||
| comments | comments | Array of comment objects |
|
||||
| linkedPRs | linkedPRs | PRs linked to this issue |
|
||||
|
||||
### Priority Mapping
|
||||
|
||||
Infer priority from labels:
|
||||
|
||||
- `priority:critical`, `P0` → "Critical"
|
||||
- `priority:high`, `P1` → "High"
|
||||
- `priority:medium`, `P2` → "Medium"
|
||||
- `priority:low`, `P3` → "Low"
|
||||
- No priority label → "Medium" (default)
|
||||
|
||||
### Area Mapping
|
||||
|
||||
Infer area from labels:
|
||||
|
||||
- `area:ui`, `frontend`, `component:*` → "UI"
|
||||
- `area:api`, `backend` → "API"
|
||||
- `area:docs`, `documentation` → "Docs"
|
||||
- `bug`, `fix` → "Bug"
|
||||
- `enhancement`, `feature` → "Feature"
|
||||
|
||||
## Update Source
|
||||
|
||||
**For GitHub issues, update is optional but recommended.**
|
||||
|
||||
Add a comment to indicate work has started:
|
||||
|
||||
```bash
|
||||
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
|
||||
```
|
||||
|
||||
Optionally assign to self:
|
||||
|
||||
```bash
|
||||
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
|
||||
```
|
||||
|
||||
Log any updates via the Pipeline API:
|
||||
|
||||
```typescript
|
||||
await client.updateTicket(ticketId, {
|
||||
metadata: {
|
||||
...ticket.metadata,
|
||||
githubWrites: [
|
||||
...(ticket.metadata?.githubWrites || []),
|
||||
{
|
||||
action: 'comment',
|
||||
issueNumber: 123,
|
||||
at: new Date().toISOString(),
|
||||
skill: 'ticket-intake',
|
||||
success: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## GitHub-Specific Ticket Fields
|
||||
|
||||
Store via API using `client.createTicket()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "github",
|
||||
"githubOwner": "Comfy-Org",
|
||||
"githubRepo": "ComfyUI_frontend",
|
||||
"githubIssueNumber": 123,
|
||||
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
|
||||
"labels": ["bug", "area:ui", "priority:high"],
|
||||
"linkedPRs": [456, 789],
|
||||
"dosuComment": "..." // Extracted Dosu bot analysis if present
|
||||
}
|
||||
```
|
||||
|
||||
## Dosu Bot Detection
|
||||
|
||||
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
|
||||
|
||||
```bash
|
||||
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
|
||||
```
|
||||
|
||||
Look for comments from:
|
||||
|
||||
- `dosu[bot]`
|
||||
- `dosu-bot`
|
||||
|
||||
Extract Dosu analysis which typically includes:
|
||||
|
||||
- Root cause analysis
|
||||
- Suggested files to modify
|
||||
- Related issues/PRs
|
||||
- Potential solutions
|
||||
|
||||
Store in ticket data via API:
|
||||
|
||||
```json
|
||||
{
|
||||
"dosuComment": {
|
||||
"found": true,
|
||||
"analysis": "...",
|
||||
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
|
||||
"relatedIssues": [100, 101]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Extract Linked Issues/PRs
|
||||
|
||||
Parse issue body and comments for references:
|
||||
|
||||
- `#123` → Issue or PR reference
|
||||
- `fixes #123`, `closes #123` → Linked issue
|
||||
- `https://github.com/.../issues/123` → Full URL reference
|
||||
|
||||
Store in ticket data via API for research phase:
|
||||
|
||||
```json
|
||||
{
|
||||
"referencedIssues": [100, 101, 102],
|
||||
"referencedPRs": [200, 201]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Error
|
||||
|
||||
```
|
||||
⚠️ GitHub CLI not authenticated.
|
||||
Run: gh auth login
|
||||
```
|
||||
|
||||
### Issue Not Found
|
||||
|
||||
```
|
||||
❌ GitHub issue not found or inaccessible.
|
||||
- Check the URL is correct
|
||||
- Ensure you have access to this repository
|
||||
- Run: gh auth status
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```
|
||||
⚠️ GitHub API rate limited.
|
||||
Wait a few minutes and try again.
|
||||
Check status: gh api rate_limit
|
||||
```
|
||||
202
.claude/skills/ticket-intake/providers/notion.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Notion Provider - Ticket Intake
|
||||
|
||||
Provider-specific logic for ingesting tickets from Notion.
|
||||
|
||||
## URL Pattern
|
||||
|
||||
```
|
||||
https://www.notion.so/workspace/Page-Title-abc123def456...
|
||||
https://notion.so/Page-Title-abc123def456...
|
||||
https://www.notion.so/abc123def456...
|
||||
```
|
||||
|
||||
Page ID is the 32-character hex string (with or without hyphens).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Notion MCP connected and authenticated
|
||||
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
|
||||
- Authenticate via `/mcp` command if prompted
|
||||
|
||||
## Fetch Ticket Content
|
||||
|
||||
Use `Notion:notion-fetch` with the page URL or ID:
|
||||
|
||||
```
|
||||
Fetch the full page content including all properties
|
||||
```
|
||||
|
||||
## Extract Ticket Data
|
||||
|
||||
Extract these properties (names may vary):
|
||||
|
||||
| Property | Expected Name | Type |
|
||||
| ------------- | ------------------------- | ------------ |
|
||||
| Title | Name / Title | Title |
|
||||
| Status | Status | Select |
|
||||
| Assignee | Assignee / Assigned To | Person |
|
||||
| Description | - | Page content |
|
||||
| Slack Link | Slack Link / Slack Thread | URL |
|
||||
| GitHub PR | GitHub PR / PR Link | URL |
|
||||
| Priority | Priority | Select |
|
||||
| Area | Area / Category | Select |
|
||||
| Related Tasks | Related Tasks | Relation |
|
||||
|
||||
**If properties are missing**: Note what's unavailable and continue with available data.
|
||||
|
||||
## Update Source (REQUIRED)
|
||||
|
||||
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
|
||||
|
||||
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
|
||||
|
||||
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
|
||||
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
|
||||
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
|
||||
|
||||
Use `Notion:notion-update-page` to update the ticket:
|
||||
|
||||
1. **Status**: Set to "In Progress" (only valid from "Not Started")
|
||||
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
|
||||
|
||||
```json
|
||||
{
|
||||
"page_id": "{page_id_from_ticket}",
|
||||
"command": "update_properties",
|
||||
"properties": {
|
||||
"Status": "In Progress",
|
||||
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After the update succeeds**, log the write via the Pipeline API:
|
||||
|
||||
```typescript
|
||||
await client.updateTicket(ticketId, {
|
||||
metadata: {
|
||||
...ticket.metadata,
|
||||
notionWrites: [
|
||||
...(ticket.metadata?.notionWrites || []),
|
||||
{
|
||||
field: 'Status',
|
||||
value: 'In Progress',
|
||||
previousValue: 'Not Started',
|
||||
at: new Date().toISOString(),
|
||||
skill: 'ticket-intake',
|
||||
success: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
If update fails, log with `success: false` and continue.
|
||||
|
||||
## Notion-Specific Ticket Fields
|
||||
|
||||
Store via API using `client.createTicket()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "notion",
|
||||
"notionPageId": "abc123def456...",
|
||||
"slackLink": "https://slack.com/...",
|
||||
"relatedTasks": ["page-id-1", "page-id-2"]
|
||||
}
|
||||
```
|
||||
|
||||
## Slack Thread Handling
|
||||
|
||||
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
|
||||
|
||||
### Slack URL Conversion
|
||||
|
||||
Notion stores Slack links in `slackMessage://` format:
|
||||
|
||||
```
|
||||
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
|
||||
```
|
||||
|
||||
Convert to browser-clickable format:
|
||||
|
||||
```
|
||||
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
|
||||
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
|
||||
|
||||
(Remove the dot from the last timestamp and prefix with `p`)
|
||||
|
||||
### Fetching Thread Content
|
||||
|
||||
Load the `slackdump` skill and use the **export-thread** workflow:
|
||||
|
||||
```bash
|
||||
# Export thread by URL
|
||||
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
|
||||
|
||||
# Or by colon notation (channel_id:thread_ts)
|
||||
slackdump dump CHANNEL_ID:THREAD_TS
|
||||
```
|
||||
|
||||
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
|
||||
|
||||
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
|
||||
|
||||
## Database Reference: Comfy Tasks
|
||||
|
||||
The "Comfy Tasks" database has these properties (verify via `notion-search`):
|
||||
|
||||
- **Status values**: Not Started, In Progress, In Review, Done
|
||||
- **Team assignment**: "Frontend Team" for unassigned tickets
|
||||
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
|
||||
|
||||
### Pipeline Owner Details
|
||||
|
||||
When assigning tickets, use these identifiers:
|
||||
|
||||
| Platform | Identifier |
|
||||
| --------------- | -------------------------------------- |
|
||||
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
|
||||
| Notion Name | Christian Byrne |
|
||||
| Notion Email | cbyrne@comfy.org |
|
||||
| Slack User ID | U087MJCDHHC |
|
||||
| GitHub Username | christian-byrne |
|
||||
|
||||
**To update Assignee**, use the Notion User ID (not name):
|
||||
|
||||
```
|
||||
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
|
||||
```
|
||||
|
||||
### Finding Active Tickets
|
||||
|
||||
To list your active tickets:
|
||||
|
||||
```
|
||||
Use Notion:notion-search for "Comfy Tasks"
|
||||
Filter by Assignee = current user OR Team = "Frontend Team"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Error
|
||||
|
||||
```
|
||||
⚠️ Notion authentication required.
|
||||
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
|
||||
Then authenticate via /mcp command.
|
||||
```
|
||||
|
||||
### Page Not Found
|
||||
|
||||
```
|
||||
❌ Notion page not found or inaccessible.
|
||||
- Check the URL is correct
|
||||
- Ensure you have access to this page
|
||||
- Try re-authenticating via /mcp
|
||||
```
|
||||
81
.claude/skills/ticket-intake/schema.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Ticket Schema
|
||||
|
||||
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
|
||||
|
||||
## Ticket Data Schema
|
||||
|
||||
```json
|
||||
{
|
||||
// Required fields (all sources)
|
||||
"id": "string", // Unique identifier (short form)
|
||||
"url": "string", // Original URL
|
||||
"source": "notion | github", // Source type
|
||||
"title": "string", // Ticket title
|
||||
"description": "string", // Full description/body
|
||||
"fetchedAt": "ISO8601", // When ticket was fetched
|
||||
|
||||
// Common optional fields
|
||||
"status": "string", // Current status
|
||||
"assignee": "string", // Assigned user
|
||||
"priority": "string", // Priority level
|
||||
"area": "string", // Category/area
|
||||
"labels": ["string"], // Tags/labels
|
||||
"acceptanceCriteria": ["string"] // List of AC items
|
||||
|
||||
// Source-specific fields (see providers)
|
||||
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
|
||||
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
|
||||
}
|
||||
```
|
||||
|
||||
## Ticket State Schema (via API)
|
||||
|
||||
State is managed via the Pipeline API using `client.transitionState()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ticketId": "string",
|
||||
"state": "intake | research | planning | implementation | pr_created | done | failed",
|
||||
"stateChangedAt": "ISO8601",
|
||||
|
||||
// Timestamps tracked by API
|
||||
"createdAt": "ISO8601",
|
||||
"updatedAt": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
## Priority Normalization
|
||||
|
||||
All sources should normalize to these values:
|
||||
|
||||
| Normalized | Description |
|
||||
| ---------- | ------------------------- |
|
||||
| Critical | Production down, security |
|
||||
| High | Blocking work, urgent |
|
||||
| Medium | Normal priority (default) |
|
||||
| Low | Nice to have, backlog |
|
||||
|
||||
## Status Normalization
|
||||
|
||||
Pipeline tracks these statuses internally:
|
||||
|
||||
| Status | Description |
|
||||
| -------------- | ---------------------------- |
|
||||
| research | Gathering context |
|
||||
| planning | Creating implementation plan |
|
||||
| implementation | Writing code |
|
||||
| review | Code review in progress |
|
||||
| qa | Quality assurance |
|
||||
| done | PR merged or completed |
|
||||
|
||||
## ID Generation
|
||||
|
||||
IDs are generated by the API when creating tickets. For reference:
|
||||
|
||||
- **Notion**: First 8 characters of page ID
|
||||
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
|
||||
|
||||
Examples:
|
||||
|
||||
- Notion: `abc12345`
|
||||
- GitHub: `gh-comfy-org-frontend-123`
|
||||
143
.claude/skills/writing-storybook-stories/SKILL.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: writing-storybook-stories
|
||||
description: 'Write or update Storybook stories for Vue components in ComfyUI_frontend. Use when adding, modifying, reviewing, or debugging `.stories.ts` files, Storybook docs, component demos, or visual catalog entries in `src/` or `apps/desktop-ui/`.'
|
||||
---
|
||||
|
||||
# Write Storybook Stories for ComfyUI_frontend
|
||||
|
||||
## Workflow
|
||||
|
||||
1. !!!!IMPORTANT Confirm the worktree is on a `feat/*` or `fix/*` branch. Base PRs on the local `main`, not a fork branch.
|
||||
2. Read the component source first. Understand props, emits, slots, exposed methods, and any supporting types or composables.
|
||||
3. Read nearby stories before writing anything.
|
||||
- Search stories: `rg --files src apps | rg '\.stories\.ts$'`
|
||||
- Inspect title patterns: `rg -n "title:\\s*'" src apps --glob '*.stories.ts'`
|
||||
4. If a Figma link is provided, list the states you need to cover before writing stories.
|
||||
5. Co-locate the story file with the component: `ComponentName.stories.ts`.
|
||||
6. Add each variation on separate stories, except hover state. this should be automatically applied by the implementation and not require a separate story.
|
||||
7. Run Storybook and validation checks before handing off.
|
||||
|
||||
## Match Local Conventions
|
||||
|
||||
- Copy the closest neighboring story instead of forcing one universal template.
|
||||
- Most repo stories use `@storybook/vue3-vite`. Some stories under `apps/desktop-ui` still use `@storybook/vue3`; keep the local convention for that area.
|
||||
- Add `tags: ['autodocs']` unless the surrounding stories in that area intentionally omit it.
|
||||
- Use `ComponentPropsAndSlots<typeof Component>` when it helps with prop and slot typing.
|
||||
- Keep `render` functions stateful when needed. Use `ref()`, `computed()`, and `toRefs(args)` instead of mutating Storybook args directly.
|
||||
- Use `args.default` or other slot-shaped args when the component content is provided through slots.
|
||||
- Use `ComponentExposed` only when a component's exposed API breaks the normal Storybook typing.
|
||||
- Add decorators for realistic width or background context when the component needs it.
|
||||
|
||||
## Title Patterns
|
||||
|
||||
Do not invent titles from scratch when a close sibling story already exists. Match the nearest domain pattern.
|
||||
|
||||
| Component area | Typical title pattern |
|
||||
| ------------------------------------------------------- | ------------------------------------ |
|
||||
| `src/components/ui/button/Button.vue` | `Components/Button/Button` |
|
||||
| `src/components/ui/input/Input.vue` | `Components/Input` |
|
||||
| `src/components/ui/search-input/SearchInput.vue` | `Components/Input/SearchInput` |
|
||||
| `src/components/common/SearchBox.vue` | `Components/Input/SearchBox` |
|
||||
| `src/renderer/extensions/vueNodes/widgets/components/*` | `Widgets/<WidgetName>` |
|
||||
| `src/platform/assets/components/*` | `Platform/Assets/<ComponentName>` |
|
||||
| `apps/desktop-ui/src/components/*` | `Desktop/Components/<ComponentName>` |
|
||||
| `apps/desktop-ui/src/views/*` | `Desktop/Views/<ViewName>` |
|
||||
|
||||
If multiple patterns seem plausible, follow the closest sibling story in the same folder tree.
|
||||
|
||||
## Common Story Shapes
|
||||
|
||||
### Stateful input or `v-model`
|
||||
|
||||
```typescript
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MyComponent },
|
||||
setup() {
|
||||
const { disabled, size } = toRefs(args)
|
||||
const value = ref('Hello world')
|
||||
return { value, disabled, size }
|
||||
},
|
||||
template:
|
||||
'<MyComponent v-model="value" :disabled="disabled" :size="size" />'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Slot-driven content
|
||||
|
||||
```typescript
|
||||
const meta: Meta<ComponentPropsAndSlots<typeof Button>> = {
|
||||
argTypes: {
|
||||
default: { control: 'text' }
|
||||
},
|
||||
args: {
|
||||
default: 'Button'
|
||||
}
|
||||
}
|
||||
|
||||
export const SingleButton: Story = {
|
||||
render: (args) => ({
|
||||
components: { Button },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<Button v-bind="args">{{ args.default }}</Button>'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Variants or edge cases grid
|
||||
|
||||
```typescript
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MyComponent },
|
||||
template: `
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<MyComponent />
|
||||
<MyComponent disabled />
|
||||
<MyComponent loading />
|
||||
<MyComponent invalid />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Figma Mapping
|
||||
|
||||
- Extract the named states from the design first.
|
||||
- Prefer explicit prop-driven stories such as `Disabled`, `Loading`, `Invalid`, `WithPlaceholder`, `AllSizes`, or `EdgeCases`.
|
||||
- Add an aggregate story such as `AllVariants`, `AllSizes`, or `EdgeCases` when side-by-side comparison is useful.
|
||||
- Use pseudo-state parameters only if the addon is already configured in this repo.
|
||||
- If a Figma state cannot be represented exactly, capture the closest prop-driven version and explain the gap in the story docs.
|
||||
|
||||
## Component-Specific Notes
|
||||
|
||||
- Widget components often need a minimal `SimplifiedWidget` object. Build it in `setup()` and use `computed()` when `args` change `widget.options`.
|
||||
- Input and search components often need a width-constrained wrapper so they render at realistic sizes.
|
||||
- Asset and platform cards often need background decorators such as `bg-base-background` and fixed-width containers.
|
||||
- Desktop installer stories may need custom `backgrounds` parameters and may intentionally keep the older Storybook import style used by neighboring files.
|
||||
- Use semantic tokens such as `bg-base-background` and `bg-node-component-surface` instead of `dark:` variants or hardcoded theme assumptions.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Read the component source and any supporting types or composables
|
||||
- [ ] Match the nearest local title pattern and story style
|
||||
- [ ] Include a baseline story; name it `Default` only when that matches nearby conventions
|
||||
- [ ] Add focused stories for meaningful states
|
||||
- [ ] Add `tags: ['autodocs']`
|
||||
- [ ] Keep the story co-located with the component
|
||||
- [ ] Run `pnpm storybook`
|
||||
- [ ] Run `pnpm typecheck`
|
||||
- [ ] Run `pnpm lint`
|
||||
|
||||
## Avoid
|
||||
|
||||
- Do not guess props, emits, slots, or exposed methods.
|
||||
- Do not force one generic title convention across the repo.
|
||||
- Do not mutate Storybook args directly for `v-model` components.
|
||||
- Do not introduce `dark:` Tailwind variants in story wrappers.
|
||||
- Do not create barrel files.
|
||||
- Do not assume every story needs `layout: 'centered'` or a `Default` export; follow the nearest existing pattern.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: 'ComfyUI Storybook Stories'
|
||||
short_description: 'Write Vue Storybook stories for ComfyUI'
|
||||
default_prompt: 'Use $writing-storybook-stories to add or update a Storybook story for this ComfyUI_frontend component.'
|
||||
@@ -12,3 +12,14 @@ reviews:
|
||||
- comfy-pr-bot
|
||||
- github-actions
|
||||
- github-actions[bot]
|
||||
pre_merge_checks:
|
||||
custom_checks:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: warning
|
||||
instructions: |
|
||||
Pass if at least one of the following is true:
|
||||
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes at least one file under `browser_tests/`.
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -19,7 +19,7 @@ runs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: './pnpm-lock.yaml'
|
||||
|
||||
|
||||
29
.github/workflows/ci-lint-format.yaml
vendored
@@ -61,6 +61,22 @@ jobs:
|
||||
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
|
||||
git push
|
||||
|
||||
- name: Fail for fork PRs with unfixed lint/format issues
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: |
|
||||
echo "::error::Linting/formatting issues found. Since this PR is from a fork, auto-fix cannot be applied automatically."
|
||||
echo ""
|
||||
echo "Please run these commands locally and push the changes:"
|
||||
echo " pnpm lint:fix"
|
||||
echo " pnpm stylelint:fix"
|
||||
echo " pnpm format"
|
||||
echo ""
|
||||
echo "Or set up pre-commit hooks to automatically format on every commit:"
|
||||
echo " pnpm prepare"
|
||||
echo ""
|
||||
echo "See CONTRIBUTING.md for more details."
|
||||
exit 1
|
||||
|
||||
- name: Final validation
|
||||
run: |
|
||||
pnpm lint
|
||||
@@ -84,16 +100,3 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
|
||||
})
|
||||
|
||||
- name: Comment on PR about manual fix needed
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '## ⚠️ Linting/Formatting Issues Found\n\nThis PR has linting or formatting issues that need to be fixed.\n\n**Since this PR is from a fork, auto-fix cannot be applied automatically.**\n\n### Option 1: Set up pre-commit hooks (recommended)\nRun this once to automatically format code on every commit:\n```bash\npnpm prepare\n```\n\n### Option 2: Fix manually\nRun these commands and push the changes:\n```bash\npnpm lint:fix\npnpm format\n```\n\nSee [CONTRIBUTING.md](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/CONTRIBUTING.md#git-pre-commit-hooks) for more details.'
|
||||
})
|
||||
|
||||
@@ -328,9 +328,11 @@ test.describe('Settings', () => {
|
||||
})
|
||||
await newBlankWorkflowRow.click()
|
||||
|
||||
// Click edit button
|
||||
const editKeybindingButton = newBlankWorkflowRow.locator('.pi-pencil')
|
||||
await editKeybindingButton.click()
|
||||
// Click add keybinding button (New Blank Workflow has no default keybinding)
|
||||
const addKeybindingButton = newBlankWorkflowRow.locator(
|
||||
'.icon-\\[lucide--plus\\]'
|
||||
)
|
||||
await addKeybindingButton.click()
|
||||
|
||||
// Set new keybinding
|
||||
const input = comfyPage.page.getByPlaceholder('Enter your keybind')
|
||||
|
||||
123
browser_tests/tests/groupSelectChildren.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Returns the client-space position of a group's title bar (for clicking).
|
||||
*/
|
||||
async function getGroupTitlePosition(
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const pos = await comfyPage.page.evaluate((title) => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups.find(
|
||||
(g: { title: string }) => g.title === title
|
||||
)
|
||||
if (!group) return null
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
}, title)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}
|
||||
* from the canvas in the browser.
|
||||
*/
|
||||
async function getSelectionCounts(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
let selectedNodeCount = 0
|
||||
let selectedGroupCount = 0
|
||||
for (const item of canvas.selectedItems) {
|
||||
if ('inputs' in item || 'outputs' in item) selectedNodeCount++
|
||||
else selectedGroupCount++
|
||||
}
|
||||
return {
|
||||
selectedNodeCount,
|
||||
selectedGroupCount,
|
||||
selectedItemCount: canvas.selectedItems.size
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('Setting enabled: clicking outer group selects nested group and inner node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Group.SelectChildrenOnClick',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const counts = await getSelectionCounts(comfyPage)
|
||||
// Outer Group + Inner Group + 1 node = 3 items
|
||||
expect(counts.selectedItemCount).toBe(3)
|
||||
expect(counts.selectedGroupCount).toBe(2)
|
||||
expect(counts.selectedNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Setting disabled: clicking outer group selects only the group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Group.SelectChildrenOnClick',
|
||||
false
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(1)
|
||||
expect(counts.selectedGroupCount).toBe(1)
|
||||
expect(counts.selectedNodeCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Deselecting outer group deselects all children', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Group.SelectChildrenOnClick',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select the outer group (cascades to children)
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(3)
|
||||
|
||||
// Deselect all via page.evaluate to avoid UI overlay interception
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(0)
|
||||
})
|
||||
})
|
||||
104
browser_tests/tests/rendererToggleStability.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { Position } from '../fixtures/types'
|
||||
|
||||
type NodeSnapshot = { id: number } & Position
|
||||
|
||||
async function getAllNodePositions(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeSnapshot[]> {
|
||||
return comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((n) => ({
|
||||
id: n.id as number,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
async function getNodePosition(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
): Promise<Position | undefined> {
|
||||
return comfyPage.page.evaluate((targetNodeId) => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
|
||||
if (!node) return
|
||||
|
||||
return {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1]
|
||||
}
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async function expectNodePositionStable(
|
||||
comfyPage: ComfyPage,
|
||||
initial: NodeSnapshot,
|
||||
mode: string
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await getNodePosition(comfyPage, initial.id)
|
||||
return current?.x ?? Number.NaN
|
||||
},
|
||||
{ message: `node ${initial.id} x drifted in ${mode} mode` }
|
||||
)
|
||||
.toBeCloseTo(initial.x, 1)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await getNodePosition(comfyPage, initial.id)
|
||||
return current?.y ?? Number.NaN
|
||||
},
|
||||
{ message: `node ${initial.id} y drifted in ${mode} mode` }
|
||||
)
|
||||
.toBeCloseTo(initial.y, 1)
|
||||
}
|
||||
|
||||
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
|
||||
if (enabled) {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Renderer toggle stability',
|
||||
{ tag: ['@node', '@canvas'] },
|
||||
() => {
|
||||
test('node positions do not drift when toggling between Vue and LiteGraph renderers', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const TOGGLE_COUNT = 5
|
||||
|
||||
const initialPositions = await getAllNodePositions(comfyPage)
|
||||
expect(initialPositions.length).toBeGreaterThan(0)
|
||||
|
||||
for (let i = 0; i < TOGGLE_COUNT; i++) {
|
||||
await setVueMode(comfyPage, true)
|
||||
for (const initial of initialPositions) {
|
||||
await expectNodePositionStable(
|
||||
comfyPage,
|
||||
initial,
|
||||
`Vue toggle ${i + 1}`
|
||||
)
|
||||
}
|
||||
|
||||
await setVueMode(comfyPage, false)
|
||||
for (const initial of initialPositions) {
|
||||
await expectNodePositionStable(
|
||||
comfyPage,
|
||||
initial,
|
||||
`LiteGraph toggle ${i + 1}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
77
browser_tests/tests/subgraphProgressClear.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Subgraph progress clear on navigation',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('Stale progress is cleared on subgraph node after navigating back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Find the subgraph node
|
||||
const subgraphNodeId = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
return subgraphNode ? String(subgraphNode.id) : null
|
||||
})
|
||||
expect(subgraphNodeId).not.toBeNull()
|
||||
|
||||
// Simulate a stale progress value on the subgraph node.
|
||||
// This happens when:
|
||||
// 1. User views root graph during execution
|
||||
// 2. Progress watcher sets node.progress = 0.5
|
||||
// 3. User enters subgraph
|
||||
// 4. Execution completes (nodeProgressStates becomes {})
|
||||
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
|
||||
// SubgraphNode isn't visible so it keeps stale progress
|
||||
// 6. User navigates back — watcher should fire and clear it
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId!)
|
||||
|
||||
// Verify progress is set
|
||||
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressBefore).toBe(0.5)
|
||||
|
||||
// Navigate into the subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
|
||||
subgraphNodeId!
|
||||
)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify we're inside the subgraph
|
||||
const inSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
expect(inSubgraph).toBe(true)
|
||||
|
||||
// Navigate back to the root graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The progress watcher should fire when graph changes (because
|
||||
// nodeLocationProgressStates is empty {} and the watcher should
|
||||
// iterate canvas.graph.nodes to clear stale node.progress values).
|
||||
//
|
||||
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
|
||||
// fire on subgraph->root navigation when progress is already empty,
|
||||
// leaving stale node.progress = 0.5 on the SubgraphNode.
|
||||
await expect(async () => {
|
||||
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
102
browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Advanced Widget Visibility', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
|
||||
// Add a ModelSamplingFlux node which has both advanced (max_shift,
|
||||
// base_shift) and non-advanced (width, height) widgets.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
|
||||
node.pos = [500, 200]
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getNode(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
}
|
||||
|
||||
function getWidgets(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
test('should hide advanced widgets by default', async ({ comfyPage }) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
// Non-advanced widgets (width, height) should be visible
|
||||
await expect(widgets).toHaveCount(2)
|
||||
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
|
||||
|
||||
// Advanced widgets should not be rendered
|
||||
await expect(
|
||||
node.getByLabel('max_shift', { exact: true })
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
node.getByLabel('base_shift', { exact: true })
|
||||
).not.toBeVisible()
|
||||
|
||||
// "Show advanced inputs" button should be present
|
||||
await expect(node.getByText('Show advanced inputs')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when per-node toggle is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Click the toggle button to show advanced widgets
|
||||
await node.getByText('Show advanced inputs').click()
|
||||
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// Button text should change to "Hide advanced inputs"
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
|
||||
|
||||
// Click again to hide
|
||||
await node.getByText('Hide advanced inputs').click()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should show advanced widgets when global setting is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Enable the global setting
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
true
|
||||
)
|
||||
|
||||
// All 4 widgets should now be visible
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// The toggle button should not be shown when global setting is active
|
||||
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ const config: KnipConfig = {
|
||||
'src/types/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
|
||||
},
|
||||
'apps/desktop-ui': {
|
||||
entry: ['src/main.ts', 'src/i18n.ts'],
|
||||
|
||||
BIN
original-8188-model-filter.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.42.4",
|
||||
"version": "1.42.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -154,6 +154,7 @@
|
||||
"eslint-plugin-storybook": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "catalog:",
|
||||
"happy-dom": "catalog:",
|
||||
|
||||
550
pnpm-lock.yaml
generated
@@ -69,6 +69,7 @@ catalog:
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
globals: ^16.5.0
|
||||
@@ -107,7 +108,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vite: 8.0.0-beta.13
|
||||
vite: ^8.0.0
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
|
||||
72
src/App.vue
@@ -9,15 +9,19 @@ import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
@@ -45,6 +49,19 @@ const showContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceError(url: string, tagName: string) {
|
||||
console.error('[resource:loadError]', { url, tagName })
|
||||
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(new Error(`Resource load failed: ${url}`), {
|
||||
tags: {
|
||||
error_type: 'resource_load_error',
|
||||
tag_name: tagName
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
|
||||
|
||||
@@ -56,15 +73,56 @@ onMounted(() => {
|
||||
// See: https://vite.dev/guide/build#load-error-handling
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
const info = parsePreloadError(event.payload)
|
||||
console.error('[vite:preloadError]', {
|
||||
url: info.url,
|
||||
fileType: info.fileType,
|
||||
chunkName: info.chunkName,
|
||||
message: info.message
|
||||
})
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(event.payload, {
|
||||
tags: { error_type: 'vite_preload_error' }
|
||||
tags: {
|
||||
error_type: 'vite_preload_error',
|
||||
file_type: info.fileType,
|
||||
chunk_name: info.chunkName ?? undefined
|
||||
},
|
||||
contexts: {
|
||||
preload: {
|
||||
url: info.url,
|
||||
fileType: info.fileType,
|
||||
chunkName: info.chunkName
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.error('[vite:preloadError]', event.payload)
|
||||
}
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.preloadErrorTitle'),
|
||||
detail: t('g.preloadError'),
|
||||
life: 10000
|
||||
})
|
||||
})
|
||||
|
||||
// Capture resource load failures (CSS, scripts) in non-localhost distributions
|
||||
if (__DISTRIBUTION__ !== 'localhost') {
|
||||
window.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLScriptElement) {
|
||||
handleResourceError(target.src, 'script')
|
||||
} else if (
|
||||
target instanceof HTMLLinkElement &&
|
||||
target.rel === 'stylesheet'
|
||||
) {
|
||||
handleResourceError(target.href, 'link')
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize conflict detection in background
|
||||
// This runs async and doesn't block UI setup
|
||||
void conflictDetection.initializeConflictDetection()
|
||||
|
||||
@@ -78,6 +78,14 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
menu: {
|
||||
element: document.createElement('div')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
type WrapperOptions = {
|
||||
pinia?: ReturnType<typeof createTestingPinia>
|
||||
stubs?: Record<string, boolean | Component>
|
||||
@@ -131,6 +139,18 @@ function createWrapper({
|
||||
})
|
||||
}
|
||||
|
||||
function getLegacyCommandsContainer(
|
||||
wrapper: ReturnType<typeof createWrapper>
|
||||
): HTMLElement {
|
||||
const legacyContainer = wrapper.find(
|
||||
'[data-testid="legacy-topbar-container"]'
|
||||
).element
|
||||
if (!(legacyContainer instanceof HTMLElement)) {
|
||||
throw new Error('Expected legacy commands container to be present')
|
||||
}
|
||||
return legacyContainer
|
||||
}
|
||||
|
||||
function createJob(id: string, status: JobStatus): JobListItem {
|
||||
return {
|
||||
id,
|
||||
@@ -515,4 +535,69 @@ describe('TopMenuSection', () => {
|
||||
|
||||
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
rafCallbacks.push(cb)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
|
||||
if (key === 'Comfy.RightSidePanel.IsOpen') return true
|
||||
return undefined
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ pinia, attachTo: document.body })
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
const actionbarContainer = wrapper.find('.actionbar-container')
|
||||
expect(actionbarContainer.classes()).toContain('w-0')
|
||||
|
||||
const legacyContainer = getLegacyCommandsContainer(wrapper)
|
||||
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
|
||||
|
||||
if (rafCallbacks.length > 0) {
|
||||
const initialCallbacks = [...rafCallbacks]
|
||||
rafCallbacks.length = 0
|
||||
initialCallbacks.forEach((callback) => callback(0))
|
||||
await nextTick()
|
||||
}
|
||||
querySpy.mockClear()
|
||||
querySpy.mockReturnValue(document.createElement('div'))
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const outer = document.createElement('div')
|
||||
const inner = document.createElement('div')
|
||||
inner.textContent = `legacy-${index}`
|
||||
outer.appendChild(inner)
|
||||
legacyContainer.appendChild(outer)
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(rafCallbacks.length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(querySpy).not.toHaveBeenCalled()
|
||||
|
||||
const callbacks = [...rafCallbacks]
|
||||
rafCallbacks.length = 0
|
||||
callbacks.forEach((callback) => callback(0))
|
||||
await nextTick()
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1)
|
||||
expect(actionbarContainer.classes()).toContain('px-2')
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
@@ -116,7 +117,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
@@ -264,6 +265,7 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
let legacyContentCheckRafId: number | null = null
|
||||
|
||||
function checkLegacyContent() {
|
||||
const el = legacyCommandsContainerRef.value
|
||||
@@ -276,19 +278,35 @@ function checkLegacyContent() {
|
||||
el.querySelector(':scope > * > *:not(:empty)') !== null
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
|
||||
function scheduleLegacyContentCheck() {
|
||||
if (legacyContentCheckRafId !== null) return
|
||||
|
||||
legacyContentCheckRafId = requestAnimationFrame(() => {
|
||||
legacyContentCheckRafId = null
|
||||
checkLegacyContent()
|
||||
})
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
subtree: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (legacyCommandsContainerRef.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
legacyCommandsContainerRef.value.appendChild(app.menu.element)
|
||||
checkLegacyContent()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (legacyContentCheckRafId === null) return
|
||||
|
||||
cancelAnimationFrame(legacyContentCheckRafId)
|
||||
legacyContentCheckRafId = null
|
||||
})
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,6 +25,10 @@ import { computed } from 'vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const { disabled = false } = defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed, provide, ref, toValue } from 'vue'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -30,7 +30,6 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
|
||||
@@ -46,19 +45,8 @@ const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
|
||||
useAppMode()
|
||||
const hoveringSelectable = ref(false)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
return node ? { nodeId, widgetName, node, widget } : null
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
@@ -201,7 +189,9 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
|
||||
<div
|
||||
class="flex h-12 items-center border-b border-border-subtle px-4 font-bold"
|
||||
>
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
@@ -209,34 +199,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
class="overflow-x-clip"
|
||||
>
|
||||
<div
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
|
||||
:aria-label="`${widget?.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div v-if="widget" class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="pointer-events-none p-1 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ widgetName }}
|
||||
<p class="text-xs italic">
|
||||
({{ t('linearMode.builder.unknownWidget') }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppModeWidgetList builder-mode />
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectInputsMode"
|
||||
|
||||
212
src/components/builder/AppModeWidgetList.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
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 { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
nodeData: ReturnType<typeof nodeToNodeData> & {
|
||||
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
|
||||
}
|
||||
action: { widget: IBaseWidget; node: LGraphNode }
|
||||
}
|
||||
|
||||
const { mobile = false, builderMode = false } = defineProps<{
|
||||
mobile?: boolean
|
||||
builderMode?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
const nodeDataByNode = new Map<
|
||||
LGraphNode,
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
}
|
||||
const fullNodeData = nodeDataByNode.get(node)!
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
matchingWidget.slotMetadata = undefined
|
||||
matchingWidget.nodeId = String(node.id)
|
||||
|
||||
return [
|
||||
{
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
widgets: [matchingWidget]
|
||||
},
|
||||
action: { widget, node }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
function getDropIndicator(node: LGraphNode) {
|
||||
if (node.type !== 'LoadImage') return undefined
|
||||
|
||||
const filename = node.widgets?.[0]?.value
|
||||
const resultItem = { type: 'input', filename: `${filename}` }
|
||||
|
||||
const buildImageUrl = () => {
|
||||
if (!filename) return undefined
|
||||
const params = new URLSearchParams(resultItem)
|
||||
appendCloudResParam(params, resultItem.filename)
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
}
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl: buildImageUrl(),
|
||||
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const dropIndicator = getDropIndicator(node)
|
||||
const nodeData = extractVueNodeData(node)
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-for="{ key, nodeData, action } in mappedSelections"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
builderMode &&
|
||||
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
builderMode
|
||||
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'mt-1.5 flex min-h-8 items-center gap-1 px-3',
|
||||
builderMode && 'drag-handle'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
|
||||
>
|
||||
{{ action.widget.label || action.widget.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="builderMode"
|
||||
class="pointer-events-none mx-1 min-w-10 flex-1 truncate text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{{ action.node.title }}
|
||||
</span>
|
||||
<div v-else class="flex-1" />
|
||||
<Popover
|
||||
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
|
||||
:entries="[
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'icon-[lucide--pencil]',
|
||||
command: () => promptRenameWidget(action.widget, action.node, t)
|
||||
},
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () =>
|
||||
appModeStore.removeSelectedInput(action.widget, action.node)
|
||||
}
|
||||
]"
|
||||
>
|
||||
<template #button>
|
||||
<Button variant="textonly" size="icon">
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div
|
||||
:class="builderMode && 'pointer-events-none'"
|
||||
:inert="builderMode || undefined"
|
||||
>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
:on-drag-drop="nodeData.onDragDrop"
|
||||
:drop-indicator="nodeData.dropIndicator"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-1 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DropZone>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,8 +3,13 @@
|
||||
ref="svgRef"
|
||||
viewBox="-0.04 -0.04 1.08 1.08"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
|
||||
@pointerdown.stop="handleSvgPointerDown"
|
||||
:class="
|
||||
cn(
|
||||
'aspect-square w-full rounded-[5px] bg-node-component-surface',
|
||||
disabled ? 'cursor-default' : 'cursor-crosshair'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop="onSvgPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<line
|
||||
@@ -56,20 +61,23 @@
|
||||
:stroke="curveColor"
|
||||
stroke-width="0.008"
|
||||
stroke-linecap="round"
|
||||
:opacity="disabled ? 0.5 : 1"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
<template v-if="!disabled">
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -77,14 +85,20 @@
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const { curveColor = 'white', histogram } = defineProps<{
|
||||
const {
|
||||
curveColor = 'white',
|
||||
histogram,
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
curveColor?: string
|
||||
histogram?: Uint32Array | null
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
@@ -98,6 +112,10 @@ const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
|
||||
modelValue
|
||||
})
|
||||
|
||||
function onSvgPointerDown(e: PointerEvent) {
|
||||
if (!disabled) handleSvgPointerDown(e)
|
||||
}
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
<template>
|
||||
<CurveEditor v-model="modelValue" />
|
||||
<CurveEditor
|
||||
:model-value="effectivePoints"
|
||||
:disabled="isDisabled"
|
||||
@update:model-value="modelValue = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from './types'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
singleValueExtractor,
|
||||
useUpstreamValue
|
||||
} from '@/composables/useUpstreamValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
import { isCurvePointArray } from './curveUtils'
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
default: () => [
|
||||
@@ -13,4 +29,17 @@ const modelValue = defineModel<CurvePoint[]>({
|
||||
[1, 1]
|
||||
]
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => !!widget.options?.disabled)
|
||||
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => widget.linkedUpstream,
|
||||
singleValueExtractor(isCurvePointArray)
|
||||
)
|
||||
|
||||
const effectivePoints = computed(() =>
|
||||
isDisabled.value && upstreamValue.value
|
||||
? upstreamValue.value
|
||||
: modelValue.value
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length >= 2 &&
|
||||
value.every(
|
||||
(p) =>
|
||||
Array.isArray(p) &&
|
||||
p.length === 2 &&
|
||||
typeof p[0] === 'number' &&
|
||||
typeof p[1] === 'number'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
* Produces a smooth curve that passes through all control points
|
||||
|
||||
@@ -5,101 +5,257 @@
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
:value="commandsData"
|
||||
:global-filter-fields="['id', 'label']"
|
||||
:filters="filters"
|
||||
selection-mode="single"
|
||||
striped-rows
|
||||
:pt="{
|
||||
header: 'px-0'
|
||||
}"
|
||||
@row-dblclick="editKeybinding($event.data)"
|
||||
>
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<template #body="slotProps">
|
||||
<div class="actions flex flex-row">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.edit')"
|
||||
@click="editKeybinding(slotProps.data)"
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.reset')"
|
||||
:disabled="
|
||||
!keybindingStore.isCommandKeybindingModified(slotProps.data.id)
|
||||
"
|
||||
@click="resetKeybinding(slotProps.data)"
|
||||
>
|
||||
<i class="pi pi-replay" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.delete')"
|
||||
:disabled="!slotProps.data.keybinding"
|
||||
@click="removeKeybinding(slotProps.data)"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="id"
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
class="flex items-center gap-1.5 truncate"
|
||||
:title="slotProps.data.id"
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div @contextmenu.capture="clearContextMenuTarget">
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
v-model:expanded-rows="expandedRows"
|
||||
:value="commandsData"
|
||||
data-key="id"
|
||||
:global-filter-fields="['id', 'label']"
|
||||
:filters="filters"
|
||||
selection-mode="single"
|
||||
context-menu
|
||||
striped-rows
|
||||
:pt="{
|
||||
header: 'px-0'
|
||||
}"
|
||||
@row-click="handleRowClick($event)"
|
||||
@row-dblclick="handleRowDblClick($event.data)"
|
||||
@row-contextmenu="handleRowContextMenu($event)"
|
||||
>
|
||||
<i
|
||||
v-if="slotProps.data.keybinding?.combo.isBrowserReserved"
|
||||
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
|
||||
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
|
||||
/>
|
||||
{{ slotProps.data.label }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="keybinding"
|
||||
:header="$t('g.keybinding')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<KeyComboDisplay
|
||||
v-if="slotProps.data.keybinding"
|
||||
:key-combo="slotProps.data.keybinding.combo"
|
||||
:is-modified="
|
||||
keybindingStore.isCommandKeybindingModified(slotProps.data.id)
|
||||
<Column
|
||||
field="id"
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
class="flex items-center gap-1 truncate"
|
||||
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
|
||||
:title="slotProps.data.id"
|
||||
>
|
||||
<i
|
||||
v-if="slotProps.data.keybindings.length >= 2"
|
||||
class="icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform"
|
||||
:class="
|
||||
expandedCommandIds.has(slotProps.data.id) && 'rotate-90'
|
||||
"
|
||||
/>
|
||||
<i
|
||||
v-if="
|
||||
slotProps.data.keybindings.some(
|
||||
(b: KeybindingImpl) => b.combo.isBrowserReserved
|
||||
)
|
||||
"
|
||||
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
|
||||
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
|
||||
/>
|
||||
{{ slotProps.data.label }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="keybindings"
|
||||
:header="$t('g.keybinding')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
v-if="slotProps.data.keybindings.length > 0"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<template
|
||||
v-for="(binding, idx) in (
|
||||
slotProps.data as ICommandData
|
||||
).keybindings.slice(0, 2)"
|
||||
:key="binding.combo.serialize()"
|
||||
>
|
||||
<span v-if="idx > 0" class="text-muted-foreground">,</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="
|
||||
keybindingStore.isCommandKeybindingModified(
|
||||
slotProps.data.id
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="slotProps.data.keybindings.length > 2"
|
||||
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('g.nMoreKeybindings', {
|
||||
count: slotProps.data.keybindings.length - 2
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
slotProps.data.source || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<template #body="slotProps">
|
||||
<div class="actions flex flex-row justify-end">
|
||||
<Button
|
||||
v-if="slotProps.data.keybindings.length === 1"
|
||||
v-tooltip="$t('g.edit')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.edit')"
|
||||
@click="
|
||||
editKeybinding(
|
||||
slotProps.data,
|
||||
slotProps.data.keybindings[0]
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--pencil]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="$t('g.addNewKeybinding')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.addNewKeybinding')"
|
||||
@click="addKeybinding(slotProps.data)"
|
||||
>
|
||||
<i class="icon-[lucide--plus]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="$t('g.reset')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.reset')"
|
||||
:disabled="
|
||||
!keybindingStore.isCommandKeybindingModified(
|
||||
slotProps.data.id
|
||||
)
|
||||
"
|
||||
@click="resetKeybinding(slotProps.data)"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="$t('g.delete')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.delete')"
|
||||
:disabled="slotProps.data.keybindings.length === 0"
|
||||
@click="handleRemoveKeybindingFromMenu(slotProps.data)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<template #expansion="slotProps">
|
||||
<div class="pl-4">
|
||||
<div
|
||||
v-for="(binding, idx) in (slotProps.data as ICommandData)
|
||||
.keybindings"
|
||||
:key="binding.combo.serialize()"
|
||||
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-muted-foreground">{{
|
||||
slotProps.data.label
|
||||
}}</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="
|
||||
keybindingStore.isCommandKeybindingModified(
|
||||
slotProps.data.id
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<Button
|
||||
v-tooltip="$t('g.edit')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.edit')"
|
||||
@click="editKeybinding(slotProps.data, binding)"
|
||||
>
|
||||
<i class="icon-[lucide--pencil]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="$t('g.removeKeybinding')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.removeKeybinding')"
|
||||
@click="removeSingleKeybinding(slotProps.data, idx)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
|
||||
:disabled="
|
||||
!contextMenuTarget || contextMenuTarget.keybindings.length === 0
|
||||
"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
slotProps.data.source || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
@select="ctxChangeKeybinding"
|
||||
>
|
||||
<i class="icon-[lucide--pencil] size-4" />
|
||||
{{ $t('g.changeKeybinding') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered"
|
||||
@select="ctxAddKeybinding"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" />
|
||||
{{ $t('g.addNewKeybinding') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator class="my-1 h-px bg-border-subtle" />
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
|
||||
:disabled="
|
||||
!contextMenuTarget ||
|
||||
!keybindingStore.isCommandKeybindingModified(contextMenuTarget.id)
|
||||
"
|
||||
@select="ctxResetToDefault"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
{{ $t('g.resetToDefault') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
|
||||
:disabled="
|
||||
!contextMenuTarget || contextMenuTarget.keybindings.length === 0
|
||||
"
|
||||
@select="ctxRemoveKeybinding"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
{{ $t('g.removeKeybinding') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
|
||||
<Button
|
||||
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
|
||||
@@ -107,7 +263,7 @@
|
||||
variant="destructive-textonly"
|
||||
@click="resetAllKeybindings"
|
||||
>
|
||||
<i class="pi pi-replay" />
|
||||
<i class="icon-[lucide--rotate-ccw]" />
|
||||
{{ $t('g.resetAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -118,16 +274,26 @@ import { FilterMatchMode } from '@primevue/core/api'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRoot,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
@@ -139,11 +305,12 @@ const filters = ref({
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybindingService = useKeybindingService()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface ICommandData {
|
||||
id: string
|
||||
keybinding: KeybindingImpl | null
|
||||
keybindings: KeybindingImpl[]
|
||||
label: string
|
||||
source?: string
|
||||
}
|
||||
@@ -155,31 +322,166 @@ const commandsData = computed<ICommandData[]>(() => {
|
||||
`commands.${normalizeI18nKey(command.id)}.label`,
|
||||
command.label ?? ''
|
||||
),
|
||||
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
|
||||
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
|
||||
source: command.source
|
||||
}))
|
||||
})
|
||||
|
||||
const expandedCommandIds = ref<Set<string>>(new Set())
|
||||
|
||||
const expandedRows = computed({
|
||||
get() {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const id of expandedCommandIds.value) {
|
||||
result[id] = true
|
||||
}
|
||||
return result
|
||||
},
|
||||
set(value: Record<string, boolean>) {
|
||||
expandedCommandIds.value = new Set(Object.keys(value))
|
||||
}
|
||||
})
|
||||
|
||||
function toggleExpanded(commandId: string) {
|
||||
if (expandedCommandIds.value.has(commandId)) {
|
||||
expandedCommandIds.value.delete(commandId)
|
||||
} else {
|
||||
expandedCommandIds.value.add(commandId)
|
||||
}
|
||||
}
|
||||
|
||||
watch(filters, () => expandedCommandIds.value.clear(), { deep: true })
|
||||
|
||||
const selectedCommandData = ref<ICommandData | null>(null)
|
||||
const editKeybindingDialog = useEditKeybindingDialog()
|
||||
|
||||
function editKeybinding(commandData: ICommandData) {
|
||||
const contextMenuTarget = ref<ICommandData | null>(null)
|
||||
|
||||
function editKeybinding(commandData: ICommandData, binding: KeybindingImpl) {
|
||||
editKeybindingDialog.show({
|
||||
commandId: commandData.id,
|
||||
commandLabel: commandData.label,
|
||||
currentCombo: commandData.keybinding?.combo ?? null
|
||||
currentCombo: binding.combo,
|
||||
mode: 'edit',
|
||||
existingBinding: binding
|
||||
})
|
||||
}
|
||||
|
||||
async function removeKeybinding(commandData: ICommandData) {
|
||||
if (commandData.keybinding) {
|
||||
keybindingStore.unsetKeybinding(commandData.keybinding)
|
||||
function addKeybinding(commandData: ICommandData) {
|
||||
editKeybindingDialog.show({
|
||||
commandId: commandData.id,
|
||||
commandLabel: commandData.label,
|
||||
currentCombo: null,
|
||||
mode: 'add'
|
||||
})
|
||||
}
|
||||
|
||||
function handleRowClick(event: { originalEvent: Event; data: ICommandData }) {
|
||||
const target = event.originalEvent.target as HTMLElement
|
||||
if (target.closest('.actions')) return
|
||||
const commandData = event.data
|
||||
if (
|
||||
commandData.keybindings.length >= 2 ||
|
||||
expandedCommandIds.value.has(commandData.id)
|
||||
) {
|
||||
toggleExpanded(commandData.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRowDblClick(commandData: ICommandData) {
|
||||
if (commandData.keybindings.length === 0) {
|
||||
addKeybinding(commandData)
|
||||
} else if (commandData.keybindings.length === 1) {
|
||||
editKeybinding(commandData, commandData.keybindings[0])
|
||||
}
|
||||
}
|
||||
|
||||
function handleRowContextMenu(event: {
|
||||
originalEvent: Event
|
||||
data: ICommandData
|
||||
}) {
|
||||
contextMenuTarget.value = event.data
|
||||
}
|
||||
|
||||
function clearContextMenuTarget() {
|
||||
contextMenuTarget.value = null
|
||||
}
|
||||
|
||||
async function removeSingleKeybinding(
|
||||
commandData: ICommandData,
|
||||
index: number
|
||||
) {
|
||||
const binding = commandData.keybindings[index]
|
||||
if (binding) {
|
||||
keybindingStore.unsetKeybinding(binding)
|
||||
if (commandData.keybindings.length <= 2) {
|
||||
expandedCommandIds.value.delete(commandData.id)
|
||||
}
|
||||
await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveAllKeybindings(commandData: ICommandData) {
|
||||
const dialog = showConfirmDialog({
|
||||
headerProps: { title: t('g.removeAllKeybindingsTitle') },
|
||||
props: { promptText: t('g.removeAllKeybindingsMessage') },
|
||||
footerProps: {
|
||||
confirmText: t('g.removeAll'),
|
||||
confirmVariant: 'destructive',
|
||||
onCancel: () => dialogStore.closeDialog(dialog),
|
||||
onConfirm: async () => {
|
||||
keybindingStore.removeAllKeybindingsForCommand(commandData.id)
|
||||
await keybindingService.persistUserKeybindings()
|
||||
dialogStore.closeDialog(dialog)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRemoveKeybindingFromMenu(commandData: ICommandData) {
|
||||
if (commandData.keybindings.length >= 2) {
|
||||
handleRemoveAllKeybindings(commandData)
|
||||
} else {
|
||||
removeSingleKeybinding(commandData, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function ctxChangeKeybinding() {
|
||||
if (!contextMenuTarget.value) return
|
||||
const target = contextMenuTarget.value
|
||||
if (target.keybindings.length === 1) {
|
||||
editKeybinding(target, target.keybindings[0])
|
||||
} else if (target.keybindings.length >= 2) {
|
||||
if (!expandedCommandIds.value.has(target.id)) {
|
||||
toggleExpanded(target.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ctxAddKeybinding() {
|
||||
if (contextMenuTarget.value) {
|
||||
addKeybinding(contextMenuTarget.value)
|
||||
}
|
||||
}
|
||||
|
||||
function ctxResetToDefault() {
|
||||
if (contextMenuTarget.value) {
|
||||
resetKeybinding(contextMenuTarget.value)
|
||||
}
|
||||
}
|
||||
|
||||
function ctxRemoveKeybinding() {
|
||||
if (
|
||||
contextMenuTarget.value &&
|
||||
contextMenuTarget.value.keybindings.length > 0
|
||||
) {
|
||||
handleRemoveKeybindingFromMenu(contextMenuTarget.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetKeybinding(commandData: ICommandData) {
|
||||
if (keybindingStore.resetKeybindingForCommand(commandData.id)) {
|
||||
expandedCommandIds.value.delete(commandData.id)
|
||||
await keybindingService.persistUserKeybindings()
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -189,14 +491,33 @@ async function resetKeybinding(commandData: ICommandData) {
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
async function resetAllKeybindings() {
|
||||
keybindingStore.resetAllKeybindings()
|
||||
await keybindingService.persistUserKeybindings()
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Info',
|
||||
detail: 'All keybindings reset',
|
||||
life: 3000
|
||||
|
||||
function resetAllKeybindings() {
|
||||
const dialog = showConfirmDialog({
|
||||
headerProps: {
|
||||
title: t('g.resetAllKeybindingsTitle')
|
||||
},
|
||||
props: {
|
||||
promptText: t('g.resetAllKeybindingsMessage')
|
||||
},
|
||||
footerProps: {
|
||||
confirmText: t('g.resetAll'),
|
||||
confirmVariant: 'destructive',
|
||||
onCancel: () => {
|
||||
dialogStore.closeDialog(dialog)
|
||||
},
|
||||
onConfirm: async () => {
|
||||
keybindingStore.resetAllKeybindings()
|
||||
await keybindingService.persistUserKeybindings()
|
||||
dialogStore.closeDialog(dialog)
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('g.info'),
|
||||
detail: t('g.allKeybindingsReset'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -64,9 +64,18 @@ async function handleSave() {
|
||||
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
if (dialogState.mode === 'add') {
|
||||
keybindingStore.addUserKeybinding(new KeybindingImpl({ commandId, combo }))
|
||||
} else if (dialogState.existingBinding) {
|
||||
keybindingStore.updateSpecificKeybinding(
|
||||
dialogState.existingBinding,
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
} else {
|
||||
keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
}
|
||||
await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<span>
|
||||
<template v-for="(sequence, index) in keySequences" :key="index">
|
||||
<Tag :severity="isModified ? 'info' : 'secondary'">
|
||||
<Tag
|
||||
class="bg-interface-menu-keybind-surface-default text-base-foreground"
|
||||
:severity="isModified ? 'info' : 'secondary'"
|
||||
>
|
||||
{{ sequence }}
|
||||
</Tag>
|
||||
<span v-if="index < keySequences.length - 1" class="px-2">+</span>
|
||||
|
||||
@@ -143,6 +143,7 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
@@ -245,6 +246,16 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
// Vue node system
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
|
||||
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
|
||||
let cleanupErrorHooks: (() => void) | null = null
|
||||
watch(
|
||||
() => canvasStore.currentGraph,
|
||||
(graph) => {
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = graph ? installErrorClearingHooks(graph) : null
|
||||
}
|
||||
)
|
||||
|
||||
const handleVueNodeLifecycleReset = async () => {
|
||||
if (shouldRenderVueNodes.value) {
|
||||
vueNodeLifecycle.disposeNodeManagerAndSyncs()
|
||||
@@ -363,10 +374,24 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Update the progress of executing nodes
|
||||
/**
|
||||
* Propagates execution progress from the store to LiteGraph node objects
|
||||
* and triggers a canvas redraw.
|
||||
*
|
||||
* No `deep: true` needed — `nodeLocationProgressStates` is a computed that
|
||||
* returns a new `Record` object on every progress event (the underlying
|
||||
* `nodeProgressStates` ref is replaced wholesale by the WebSocket handler).
|
||||
*
|
||||
* `currentGraph` triggers this watcher on subgraph navigation so stale
|
||||
* progress bars are cleared when returning to the root graph.
|
||||
*/
|
||||
watch(
|
||||
() =>
|
||||
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
|
||||
[
|
||||
executionStore.nodeLocationProgressStates,
|
||||
canvasStore.canvas,
|
||||
canvasStore.currentGraph
|
||||
] as const,
|
||||
([nodeLocationProgressStates, canvas]) => {
|
||||
if (!canvas?.graph) return
|
||||
for (const node of canvas.graph.nodes) {
|
||||
@@ -381,43 +406,15 @@ watch(
|
||||
|
||||
// Force canvas redraw to ensure progress updates are visible
|
||||
canvas.setDirty(true, false)
|
||||
},
|
||||
{ deep: true }
|
||||
}
|
||||
)
|
||||
|
||||
// Update node slot errors for LiteGraph nodes
|
||||
// (Vue nodes read from store directly)
|
||||
// Repaint canvas when node errors change.
|
||||
// Slot error flags are reconciled by reconcileNodeErrorFlags in executionErrorStore.
|
||||
watch(
|
||||
() => executionErrorStore.lastNodeErrors,
|
||||
(lastNodeErrors) => {
|
||||
if (!comfyApp.graph) return
|
||||
|
||||
forEachNode(comfyApp.rootGraph, (node) => {
|
||||
// Clear existing errors
|
||||
for (const slot of node.inputs) {
|
||||
delete slot.hasErrors
|
||||
}
|
||||
for (const slot of node.outputs) {
|
||||
delete slot.hasErrors
|
||||
}
|
||||
|
||||
const nodeErrors = lastNodeErrors?.[node.id]
|
||||
if (!nodeErrors) return
|
||||
|
||||
const validErrors = nodeErrors.errors.filter(
|
||||
(error) => error.extra_info?.input_name !== undefined
|
||||
)
|
||||
|
||||
validErrors.forEach((error) => {
|
||||
const inputName = error.extra_info!.input_name!
|
||||
const inputIndex = node.findInputSlot(inputName)
|
||||
if (inputIndex !== -1) {
|
||||
node.inputs[inputIndex].hasErrors = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
comfyApp.canvas.setDirty(true, true)
|
||||
() => {
|
||||
comfyApp.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -520,6 +517,11 @@ onMounted(async () => {
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
// Install error-clearing hooks on the initial graph
|
||||
if (comfyApp.canvas?.graph) {
|
||||
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
|
||||
}
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
@@ -563,13 +565,13 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = null
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
if (
|
||||
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
|
||||
!isMiddlePointerInput(e)
|
||||
)
|
||||
if (!isMiddlePointerInput(e)) return
|
||||
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
|
||||
return
|
||||
|
||||
canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
@@ -36,26 +36,37 @@
|
||||
|
||||
<div
|
||||
v-if="imageUrl && !isLoading"
|
||||
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
|
||||
:class="
|
||||
cn(
|
||||
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
|
||||
isDisabled && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="handle in resizeHandles"
|
||||
v-show="imageUrl && !isLoading"
|
||||
:key="handle.direction"
|
||||
:class="['absolute', handle.class]"
|
||||
:style="handle.style"
|
||||
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
|
||||
@pointermove="handleResizeMove"
|
||||
@pointerup="handleResizeEnd"
|
||||
/>
|
||||
<template v-for="handle in resizeHandles" :key="handle.direction">
|
||||
<div
|
||||
v-show="imageUrl && !isLoading"
|
||||
:class="
|
||||
cn(
|
||||
'absolute',
|
||||
handle.class,
|
||||
isDisabled && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
:style="handle.style"
|
||||
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
|
||||
@pointermove="handleResizeMove"
|
||||
@pointerup="handleResizeEnd"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<div v-if="!isDisabled" class="flex shrink-0 items-center gap-2">
|
||||
<label class="text-xs text-muted-foreground">
|
||||
{{ $t('imageCrop.ratio') }}
|
||||
</label>
|
||||
@@ -90,12 +101,16 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
|
||||
<WidgetBoundingBox
|
||||
v-model="effectiveBounds"
|
||||
:disabled="isDisabled"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -105,10 +120,17 @@ import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
|
||||
import {
|
||||
boundsExtractor,
|
||||
useUpstreamValue
|
||||
} from '@/composables/useUpstreamValue'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: SimplifiedWidget
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
@@ -116,6 +138,23 @@ const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => !!widget.options?.disabled)
|
||||
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => widget.linkedUpstream,
|
||||
boundsExtractor()
|
||||
)
|
||||
|
||||
const effectiveBounds = computed({
|
||||
get: () =>
|
||||
isDisabled.value && upstreamValue.value
|
||||
? upstreamValue.value
|
||||
: modelValue.value,
|
||||
set: (v) => {
|
||||
if (!isDisabled.value) modelValue.value = v
|
||||
}
|
||||
})
|
||||
|
||||
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
|
||||
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
|
||||
|
||||
@@ -139,5 +178,5 @@ const {
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd
|
||||
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
|
||||
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue: effectiveBounds })
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
import { getStableWidgetRenderKey } from '@/core/graph/subgraph/widgetRenderKey'
|
||||
|
||||
const {
|
||||
label,
|
||||
@@ -272,7 +273,7 @@ defineExpose({
|
||||
<TransitionGroup name="list-scale">
|
||||
<WidgetItem
|
||||
v-for="{ widget, node } in widgets"
|
||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||
:key="getStableWidgetRenderKey(widget)"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:is-draggable="isDraggable"
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Assets Header -->
|
||||
<div v-if="assets.length" class="px-2 2xl:px-4">
|
||||
<div
|
||||
class="flex items-center py-2 font-inter text-sm/normal font-normal text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
assetType === 'input'
|
||||
? 'sideToolbar.importedAssetsHeader'
|
||||
: 'sideToolbar.generatedAssetsHeader'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets Grid -->
|
||||
<VirtualGrid
|
||||
class="flex-1"
|
||||
@@ -40,22 +25,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
assetType = 'output',
|
||||
showOutputCount,
|
||||
getOutputCount
|
||||
} = defineProps<{
|
||||
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
assetType?: 'input' | 'output'
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
}>()
|
||||
@@ -68,8 +45,6 @@ const emit = defineEmits<{
|
||||
(e: 'output-count-click', asset: AssetItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
const assetItems = computed<AssetGridItem[]>(() =>
|
||||
|
||||
@@ -50,8 +50,7 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
|
||||
selectableAssets: [],
|
||||
isSelected: () => false,
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: async () => {},
|
||||
assetType: 'output'
|
||||
toggleStack: async () => {}
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -61,18 +60,6 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
|
||||
})
|
||||
|
||||
describe('AssetsSidebarListView', () => {
|
||||
it('shows generated assets header when there are assets', () => {
|
||||
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
|
||||
|
||||
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
|
||||
})
|
||||
|
||||
it('does not show assets header when there are no assets', () => {
|
||||
const wrapper = mountListView([])
|
||||
|
||||
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
|
||||
})
|
||||
|
||||
it('marks mp4 assets as video previews', () => {
|
||||
const videoAsset = {
|
||||
...buildAsset('video-asset', 'clip.mp4'),
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div v-if="assetItems.length" class="px-2">
|
||||
<div
|
||||
class="flex items-center p-2 font-inter text-sm/normal font-normal text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
assetType === 'input'
|
||||
? 'sideToolbar.importedAssetsHeader'
|
||||
: 'sideToolbar.generatedAssetsHeader'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VirtualGrid
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
@@ -106,15 +92,13 @@ const {
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack,
|
||||
assetType = 'output'
|
||||
toggleStack
|
||||
} = defineProps<{
|
||||
assetItems: OutputStackListItem[]
|
||||
selectableAssets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isStackExpanded: (asset: AssetItem) => boolean
|
||||
toggleStack: (asset: AssetItem) => Promise<void>
|
||||
assetType?: 'input' | 'output'
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
@@ -24,17 +24,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #tool-buttons>
|
||||
<!-- Normal Tab View -->
|
||||
<TabList v-if="!isInFolderView" v-model="activeTab">
|
||||
<Tab class="font-inter" value="output">{{
|
||||
$t('sideToolbar.labels.generated')
|
||||
}}</Tab>
|
||||
<Tab class="font-inter" value="input">{{
|
||||
$t('sideToolbar.labels.imported')
|
||||
}}</Tab>
|
||||
</TabList>
|
||||
</template>
|
||||
<template #header>
|
||||
<!-- Job Detail View Header -->
|
||||
<div v-if="isInFolderView" class="px-2 2xl:px-4">
|
||||
@@ -50,15 +39,24 @@
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
class="px-2 pb-1 2xl:px-4"
|
||||
bottom-divider
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<Divider type="dashed" class="my-2" />
|
||||
<!-- Tab list -->
|
||||
<div
|
||||
v-if="!isInFolderView"
|
||||
class="border-b border-comfy-input p-2 2xl:px-4"
|
||||
>
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="showLoadingState"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="n in skeletonCount"
|
||||
@@ -85,7 +83,11 @@
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<div
|
||||
v-else
|
||||
class="relative size-full py-2"
|
||||
@click="handleEmptySpaceClick"
|
||||
>
|
||||
<AssetsSidebarListView
|
||||
v-if="isListView"
|
||||
:asset-items="listViewAssetItems"
|
||||
@@ -93,7 +95,6 @@
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@preview-asset="handleZoomClick"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@@ -103,7 +104,6 @@
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@@ -203,7 +203,6 @@ import {
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
import Divider from 'primevue/divider'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
|
||||
@@ -20,15 +20,14 @@
|
||||
</Button>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SidebarTopArea>
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
class="workflows-search-box"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: searchSubject })"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</SidebarTopArea>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="!isSearching" class="comfyui-workflows-panel">
|
||||
@@ -147,6 +146,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
|
||||
import TextDivider from '@/components/common/TextDivider.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</Button>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SidebarTopArea>
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
@@ -32,7 +32,7 @@
|
||||
"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</SidebarTopArea>
|
||||
</template>
|
||||
<template #body>
|
||||
<ElectronDownloadItems v-if="isDesktop" />
|
||||
@@ -57,6 +57,7 @@ import { Divider } from 'primevue'
|
||||
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -96,12 +95,6 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
return mount(NodeLibrarySidebarTabV2, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ stubActions: false }), i18n],
|
||||
components: {
|
||||
TabsRoot,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent
|
||||
},
|
||||
stubs: {
|
||||
teleport: true
|
||||
}
|
||||
@@ -112,7 +105,7 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
it('should render with tabs', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const triggers = wrapper.findAllComponents(TabsTrigger)
|
||||
const triggers = wrapper.findAll('[role="tab"]')
|
||||
expect(triggers).toHaveLength(3)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('sideToolbar.nodes')">
|
||||
<template #header>
|
||||
<TabsRoot v-model="selectedTab" class="flex flex-col">
|
||||
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<SidebarTopArea bottom-divider>
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<template #actions>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('g.sort')"
|
||||
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
@@ -42,12 +43,13 @@
|
||||
</DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-if="selectedTab === 'all'">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
|
||||
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
|
||||
>
|
||||
<i class="icon-[lucide--list-filter] size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
@@ -102,65 +104,55 @@
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
<Separator decorative class="border border-dashed border-comfy-input" />
|
||||
<!-- Tab list in header (fixed) -->
|
||||
<TabsList
|
||||
class="bg-background flex gap-4 border-b border-comfy-input p-4"
|
||||
>
|
||||
<TabsTrigger
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-lg border-none px-3 py-2 outline-none select-none',
|
||||
'text-foreground text-sm transition-colors',
|
||||
selectedTab === tab.value
|
||||
? 'bg-comfy-input font-bold'
|
||||
: 'bg-transparent font-normal'
|
||||
)
|
||||
"
|
||||
>
|
||||
</template>
|
||||
</SidebarTopArea>
|
||||
<div class="border-b border-comfy-input p-2 2xl:px-4">
|
||||
<TabList v-model="selectedTab">
|
||||
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
|
||||
{{ tab.label }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</TabsRoot>
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeDragPreview />
|
||||
<!-- Tab content (scrollable) -->
|
||||
<TabsRoot v-model="selectedTab" class="h-full">
|
||||
<EssentialNodesPanel
|
||||
v-if="
|
||||
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
|
||||
"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderedEssentialRoot"
|
||||
:flat-nodes="essentialFlatNodes"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
<AllNodesPanel
|
||||
v-if="selectedTab === 'all'"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:sections="renderedSections"
|
||||
:fill-node-info="fillNodeInfo"
|
||||
:sort-order="sortOrder"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
<BlueprintsPanel
|
||||
v-if="selectedTab === 'blueprints'"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:sections="renderedBlueprintsSections"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabsRoot>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
<TabPanel
|
||||
v-if="flags.nodeLibraryEssentialsEnabled"
|
||||
:model-value="selectedTab"
|
||||
value="essentials"
|
||||
>
|
||||
<EssentialNodesPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderedEssentialRoot"
|
||||
:flat-nodes="essentialFlatNodes"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel :model-value="selectedTab" value="all">
|
||||
<AllNodesPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:sections="renderedSections"
|
||||
:fill-node-info="fillNodeInfo"
|
||||
:sort-order="sortOrder"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel :model-value="selectedTab" value="blueprints">
|
||||
<BlueprintsPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:sections="renderedBlueprintsSections"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -170,17 +162,18 @@ import {
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
Separator,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import TabPanel from '@/components/tab/TabPanel.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
|
||||
<div class="comfy-vue-side-bar-header flex flex-col">
|
||||
<Toolbar
|
||||
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-2 2xl:px-4"
|
||||
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-3 2xl:px-4"
|
||||
:pt="sidebarPt"
|
||||
>
|
||||
<template #start>
|
||||
|
||||
17
src/components/sidebar/tabs/SidebarTopArea.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 p-2 2xl:px-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bottomDivider" class="border-t border-dashed border-comfy-input" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
bottomDivider?: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabsContent value="all" class="h-full flex-1 overflow-y-auto">
|
||||
<div class="h-full flex-1 overflow-y-auto">
|
||||
<!-- Favorites section -->
|
||||
<h3
|
||||
class="mb-0 px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground uppercase"
|
||||
@@ -34,11 +34,10 @@
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TabsContent } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabsContent value="blueprints" class="h-full flex-1 overflow-y-auto">
|
||||
<div class="h-full flex-1 overflow-y-auto">
|
||||
<div v-for="(section, index) in sections" :key="section.title ?? index">
|
||||
<h3
|
||||
v-if="section.title"
|
||||
@@ -14,12 +14,10 @@
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TabsContent } from 'reka-ui'
|
||||
|
||||
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabsContent value="custom" class="flex h-full flex-1 flex-col">
|
||||
<div class="flex h-full flex-1 flex-col">
|
||||
<div
|
||||
v-for="(section, index) in sections"
|
||||
:key="section.title ?? index"
|
||||
@@ -30,12 +30,10 @@
|
||||
{{ $t('g.manageExtensions') }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TabsContent } from 'reka-ui'
|
||||
|
||||
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<TabsContent
|
||||
ref="panelEl"
|
||||
value="essentials"
|
||||
class="h-full flex-1 overflow-y-auto px-3"
|
||||
>
|
||||
<div ref="panelEl" class="h-full flex-1 overflow-y-auto px-3">
|
||||
<div class="flex flex-col gap-2 pb-6">
|
||||
<!-- Flat sorted grid when alphabetical -->
|
||||
<div
|
||||
@@ -57,29 +53,26 @@
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
TabsContent
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, provide, ref, watch } from 'vue'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const panelEl = ref<ComponentPublicInstance | null>(null)
|
||||
const panelRef = computed(() => panelEl.value?.$el as HTMLElement | null)
|
||||
provide('essentialsPanelRef', panelRef)
|
||||
|
||||
import EssentialNodeCard from './EssentialNodeCard.vue'
|
||||
|
||||
const panelEl = ref<HTMLDivElement | null>(null)
|
||||
provide('essentialsPanelRef', panelEl)
|
||||
|
||||
const { root, flatNodes = [] } = defineProps<{
|
||||
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
flatNodes?: RenderedTreeExplorerNode<ComfyNodeDefImpl>[]
|
||||
|
||||
@@ -1,48 +1,74 @@
|
||||
<template>
|
||||
<button
|
||||
:id="tabId"
|
||||
:class="tabClasses"
|
||||
:id="`tab-${props.value}`"
|
||||
role="tab"
|
||||
type="button"
|
||||
:aria-selected="isActive"
|
||||
:aria-controls="panelId"
|
||||
:tabindex="0"
|
||||
:aria-controls="`tabpanel-${props.value}`"
|
||||
:data-state="isActive ? 'active' : 'inactive'"
|
||||
:tabindex="isActive ? 0 : -1"
|
||||
:class="
|
||||
cn(
|
||||
'flex shrink-0 items-center justify-center',
|
||||
'cursor-pointer rounded-lg border-none px-2.5 py-2 text-sm transition-all duration-200',
|
||||
'focus-visible:ring-ring/20 outline-hidden focus-visible:ring-1',
|
||||
isActive
|
||||
? 'bg-interface-menu-component-surface-hovered text-text-primary'
|
||||
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends string = string">
|
||||
import type { Ref } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { value, panelId } = defineProps<{
|
||||
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
|
||||
|
||||
const props = defineProps<{
|
||||
value: T
|
||||
panelId?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const currentValue = inject<Ref<T>>('tabs-value')
|
||||
const updateValue = inject<(value: T) => void>('tabs-update')
|
||||
const context = inject(TAB_LIST_INJECTION_KEY)
|
||||
|
||||
const tabId = computed(() => `tab-${value}`)
|
||||
const isActive = computed(() => currentValue?.value === value)
|
||||
const isActive = computed(() => context?.modelValue.value === props.value)
|
||||
|
||||
const tabClasses = computed(() => {
|
||||
return cn(
|
||||
// Base styles from TextButton
|
||||
'flex shrink-0 items-center justify-center',
|
||||
'cursor-pointer rounded-lg px-2.5 py-2 text-sm transition-all duration-200',
|
||||
'border-none outline-hidden',
|
||||
// State styles with semantic tokens
|
||||
isActive.value
|
||||
? 'text-bold bg-interface-menu-component-surface-hovered text-text-primary'
|
||||
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
)
|
||||
})
|
||||
function handleClick() {
|
||||
context?.select(props.value)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
updateValue?.(value)
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const tablist = (event.currentTarget as HTMLElement).parentElement
|
||||
if (!tablist) return
|
||||
|
||||
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))
|
||||
const currentIndex = tabs.indexOf(event.currentTarget as HTMLElement)
|
||||
|
||||
let targetIndex = -1
|
||||
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
targetIndex = (currentIndex + 1) % tabs.length
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
targetIndex = (currentIndex - 1 + tabs.length) % tabs.length
|
||||
} else if (event.key === 'Home') {
|
||||
targetIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
targetIndex = tabs.length - 1
|
||||
}
|
||||
|
||||
if (targetIndex !== -1) {
|
||||
event.preventDefault()
|
||||
tabs[targetIndex].focus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div role="tablist" class="flex w-full items-center gap-2 pb-1">
|
||||
<div role="tablist" class="flex w-full items-center gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,11 +7,16 @@
|
||||
<script setup lang="ts" generic="T extends string = string">
|
||||
import { provide } from 'vue'
|
||||
|
||||
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
|
||||
|
||||
const modelValue = defineModel<T>({ required: true })
|
||||
|
||||
// Provide for child Tab components
|
||||
provide('tabs-value', modelValue)
|
||||
provide('tabs-update', (value: T) => {
|
||||
modelValue.value = value
|
||||
function select(value: string) {
|
||||
modelValue.value = value as T
|
||||
}
|
||||
|
||||
provide(TAB_LIST_INJECTION_KEY, {
|
||||
modelValue,
|
||||
select
|
||||
})
|
||||
</script>
|
||||
|
||||
29
src/components/tab/TabPanel.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isActive"
|
||||
:id="`tabpanel-${value}`"
|
||||
role="tabpanel"
|
||||
tabindex="0"
|
||||
:aria-labelledby="`tab-${value}`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends string = string">
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
|
||||
|
||||
const { value, modelValue } = defineProps<{
|
||||
value: T
|
||||
modelValue?: T
|
||||
}>()
|
||||
|
||||
const context = inject(TAB_LIST_INJECTION_KEY, undefined)
|
||||
const isActive = computed(() =>
|
||||
modelValue !== undefined
|
||||
? modelValue === value
|
||||
: context?.modelValue.value === value
|
||||
)
|
||||
</script>
|
||||
9
src/components/tab/tabKeys.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
interface TabListContext {
|
||||
modelValue: Ref<string>
|
||||
select: (value: string) => void
|
||||
}
|
||||
|
||||
export const TAB_LIST_INJECTION_KEY: InjectionKey<TabListContext> =
|
||||
Symbol('TabListContext')
|
||||
57
src/components/ui/chart/ChartBar.stories.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ChartBar from './ChartBar.vue'
|
||||
|
||||
const meta: Meta<typeof ChartBar> = {
|
||||
title: 'Components/Chart/ChartBar',
|
||||
component: ChartBar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Bar chart example',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'BarName1',
|
||||
data: [10, 50, 35, 75],
|
||||
backgroundColor: '#ff8000'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleDatasets: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Bar chart with multiple datasets',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Series 1',
|
||||
data: [30, 60, 45, 80],
|
||||
backgroundColor: '#ff8000'
|
||||
},
|
||||
{
|
||||
label: 'Series 2',
|
||||
data: [50, 40, 70, 20],
|
||||
backgroundColor: '#4ade80'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/components/ui/chart/ChartBar.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
|
||||
"
|
||||
>
|
||||
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useChart } from './useChart'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ChartData<'bar'>
|
||||
options?: ChartOptions<'bar'>
|
||||
ariaLabel?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useChart(
|
||||
canvasRef,
|
||||
ref('bar'),
|
||||
toRef(() => props.data),
|
||||
computed(() => props.options as ChartOptions | undefined)
|
||||
)
|
||||
</script>
|
||||
76
src/components/ui/chart/ChartLine.stories.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ChartLine from './ChartLine.vue'
|
||||
|
||||
const meta: Meta<typeof ChartLine> = {
|
||||
title: 'Components/Chart/ChartLine',
|
||||
component: ChartLine,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Line chart example',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'LineName1',
|
||||
data: [10, 45, 25, 80],
|
||||
borderColor: '#4ade80',
|
||||
borderDash: [5, 5],
|
||||
fill: true,
|
||||
backgroundColor: '#4ade8033',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleLines: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Line chart with multiple lines',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'LineName1',
|
||||
data: [10, 45, 25, 80],
|
||||
borderColor: '#4ade80',
|
||||
borderDash: [5, 5],
|
||||
fill: true,
|
||||
backgroundColor: '#4ade8033',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'LineName2',
|
||||
data: [80, 60, 40, 10],
|
||||
borderColor: '#ff8000',
|
||||
fill: true,
|
||||
backgroundColor: '#ff800033',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'LineName3',
|
||||
data: [60, 70, 35, 40],
|
||||
borderColor: '#ef4444',
|
||||
fill: true,
|
||||
backgroundColor: '#ef444433',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/components/ui/chart/ChartLine.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
|
||||
"
|
||||
>
|
||||
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useChart } from './useChart'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ChartData<'line'>
|
||||
options?: ChartOptions<'line'>
|
||||
ariaLabel?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useChart(
|
||||
canvasRef,
|
||||
ref('line'),
|
||||
toRef(() => props.data),
|
||||
computed(() => props.options as ChartOptions | undefined)
|
||||
)
|
||||
</script>
|
||||
196
src/components/ui/chart/useChart.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
)
|
||||
|
||||
function getCssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getDefaultOptions(type: ChartType): ChartOptions {
|
||||
const foreground = getCssVar('--color-base-foreground') || '#ffffff'
|
||||
const muted = getCssVar('--color-muted-foreground') || '#8a8a8a'
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
align: 'start',
|
||||
labels: {
|
||||
color: foreground,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 16,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
generateLabels(chart) {
|
||||
const datasets = chart.data.datasets
|
||||
return datasets.map((dataset, i) => {
|
||||
const color =
|
||||
(dataset as { borderColor?: string }).borderColor ??
|
||||
(dataset as { backgroundColor?: string }).backgroundColor ??
|
||||
'#888'
|
||||
return {
|
||||
text: dataset.label ?? '',
|
||||
fillStyle: color as string,
|
||||
strokeStyle: color as string,
|
||||
lineWidth: 0,
|
||||
pointStyle: 'circle' as const,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
hoverRadius: 4
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
padding: 8
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: muted + '33',
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: true, color: muted }
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
padding: 4
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: true, color: muted }
|
||||
}
|
||||
},
|
||||
...(type === 'bar' && {
|
||||
datasets: {
|
||||
bar: {
|
||||
borderRadius: { topLeft: 4, topRight: 4 },
|
||||
borderSkipped: false,
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.8
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useChart(
|
||||
canvasRef: Ref<HTMLCanvasElement | null>,
|
||||
type: Ref<ChartType>,
|
||||
data: Ref<ChartData>,
|
||||
options?: Ref<ChartOptions | undefined>
|
||||
) {
|
||||
const chartInstance = ref<Chart | null>(null)
|
||||
|
||||
function createChart() {
|
||||
if (!canvasRef.value) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
const defaults = getDefaultOptions(type.value)
|
||||
const merged = options?.value
|
||||
? deepMerge(defaults, options.value)
|
||||
: defaults
|
||||
|
||||
chartInstance.value = new Chart(canvasRef.value, {
|
||||
type: type.value,
|
||||
data: data.value,
|
||||
options: merged
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(createChart)
|
||||
|
||||
watch([type, data, options ?? ref(undefined)], () => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.data = data.value
|
||||
chartInstance.value.options = options?.value
|
||||
? deepMerge(getDefaultOptions(type.value), options.value)
|
||||
: getDefaultOptions(type.value)
|
||||
chartInstance.value.update()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance.value?.destroy()
|
||||
chartInstance.value = null
|
||||
})
|
||||
|
||||
return { chartInstance }
|
||||
}
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: Record<string, unknown>
|
||||
): T {
|
||||
const result = { ...target } as Record<string, unknown>
|
||||
for (const key of Object.keys(source)) {
|
||||
const srcVal = source[key]
|
||||
const tgtVal = result[key]
|
||||
if (
|
||||
srcVal &&
|
||||
typeof srcVal === 'object' &&
|
||||
!Array.isArray(srcVal) &&
|
||||
tgtVal &&
|
||||
typeof tgtVal === 'object' &&
|
||||
!Array.isArray(tgtVal)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
tgtVal as Record<string, unknown>,
|
||||
srcVal as Record<string, unknown>
|
||||
)
|
||||
} else {
|
||||
result[key] = srcVal
|
||||
}
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
352
src/composables/graph/useErrorClearingHooks.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
function seedSimpleError(
|
||||
store: ReturnType<typeof useExecutionErrorStore>,
|
||||
executionId: string,
|
||||
inputName: string
|
||||
) {
|
||||
store.lastNodeErrors = {
|
||||
[executionId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: inputName }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Connection error clearing via onConnectionsChange', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
function createGraphWithInput() {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
node.addInput('clip', 'CLIP')
|
||||
graph.add(node)
|
||||
return { graph, node }
|
||||
}
|
||||
|
||||
it('clears simple node error when INPUT is connected', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors on disconnection', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
node.inputs[0]
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors on OUTPUT connection', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
node.addOutput('out', 'CLIP')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.OUTPUT,
|
||||
0,
|
||||
true,
|
||||
null,
|
||||
node.outputs[0]
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears errors for pure input slots without widget property', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('model', 'MODEL')
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedSimpleError(store, String(node.id), 'model')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('clears simple error when widget value changes to valid range', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {
|
||||
min: 1,
|
||||
max: 100
|
||||
})
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('retains error when widget value is still out of range', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {
|
||||
min: 1,
|
||||
max: 100
|
||||
})
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
node.onWidgetChanged!.call(node, 'steps', 150, 20, node.widgets![0])
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when rootGraph is unavailable', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
|
||||
undefined as unknown as LGraph
|
||||
)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('uses interior node execution ID for promoted widget error clearing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['model.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
|
||||
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
|
||||
// with that name so the slot-name filter matches.
|
||||
seedSimpleError(store, interiorExecId, promotedWidget!.name)
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'other_model.safetensors',
|
||||
'model.safetensors',
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installErrorClearingHooks lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('propagates hooks to nodes added after installation', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('value', 'INT')
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
// Add a new node after hooks are installed
|
||||
const lateNode = new LGraphNode('late')
|
||||
lateNode.addInput('value', 'INT')
|
||||
graph.add(lateNode)
|
||||
|
||||
// The late-added node should have error-clearing hooks
|
||||
expect(lateNode.onConnectionsChange).toBeDefined()
|
||||
expect(lateNode.onWidgetChanged).toBeDefined()
|
||||
|
||||
// Verify the hooks actually work
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedSimpleError(store, String(lateNode.id), 'value')
|
||||
|
||||
lateNode.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
true,
|
||||
null,
|
||||
lateNode.inputs[0]
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('restores original onNodeAdded when cleanup is called', () => {
|
||||
const graph = new LGraph()
|
||||
const originalHook = vi.fn()
|
||||
graph.onNodeAdded = originalHook
|
||||
|
||||
const cleanup = installErrorClearingHooks(graph)
|
||||
expect(graph.onNodeAdded).not.toBe(originalHook)
|
||||
|
||||
cleanup()
|
||||
expect(graph.onNodeAdded).toBe(originalHook)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('passes widgetName (not errorInputName) for model lookup', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const widget = node.addWidget('number', 'steps', 42, () => undefined, {
|
||||
min: 0,
|
||||
max: 100
|
||||
})
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const clearSpy = vi.spyOn(store, 'clearWidgetRelatedErrors')
|
||||
|
||||
node.onWidgetChanged!.call(node, 'steps', 42, 0, widget)
|
||||
|
||||
expect(clearSpy).toHaveBeenCalledWith(
|
||||
String(node.id),
|
||||
'steps',
|
||||
'steps',
|
||||
42,
|
||||
{ min: 0, max: 100 }
|
||||
)
|
||||
|
||||
clearSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
108
src/composables/graph/useErrorClearingHooks.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Installs per-node error-clearing callbacks (onConnectionsChange,
|
||||
* onWidgetChanged) on all current and future nodes in a graph.
|
||||
*
|
||||
* Decoupled from the Vue rendering lifecycle so that error auto-clearing
|
||||
* works in legacy canvas mode as well.
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolvePromotedExecId(
|
||||
rootGraph: LGraph,
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string
|
||||
): string {
|
||||
if (!isPromotedWidgetView(widget)) return hostExecId
|
||||
const result = resolveConcretePromotedWidget(
|
||||
node,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved' && result.resolved.node) {
|
||||
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
|
||||
}
|
||||
return hostExecId
|
||||
}
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
function installNodeHooks(node: LGraphNode): void {
|
||||
if (hookedNodes.has(node)) return
|
||||
hookedNodes.add(node)
|
||||
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
function (type, slotIndex, isConnected) {
|
||||
if (type !== NodeSlotType.INPUT || !isConnected) return
|
||||
if (!app.rootGraph) return
|
||||
const slotName = node.inputs?.[slotIndex]?.name
|
||||
if (!slotName) return
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId) return
|
||||
useExecutionErrorStore().clearSimpleNodeErrors(execId, slotName)
|
||||
}
|
||||
)
|
||||
|
||||
node.onWidgetChanged = useChainCallback(
|
||||
node.onWidgetChanged,
|
||||
// _name is the LiteGraph callback arg; re-derive from the widget
|
||||
// object to handle promoted widgets where sourceWidgetName differs.
|
||||
function (_name, newValue, _oldValue, widget) {
|
||||
if (!app.rootGraph) return
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const execId = resolvePromotedExecId(
|
||||
app.rootGraph,
|
||||
node,
|
||||
widget,
|
||||
hostExecId
|
||||
)
|
||||
const widgetName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
execId,
|
||||
widget.name,
|
||||
widgetName,
|
||||
newValue,
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function installNodeHooksRecursive(node: LGraphNode): void {
|
||||
installNodeHooks(node)
|
||||
if (node.isSubgraphNode?.()) {
|
||||
for (const innerNode of node.subgraph._nodes ?? []) {
|
||||
installNodeHooksRecursive(innerNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
for (const node of graph._nodes ?? []) {
|
||||
installNodeHooksRecursive(node)
|
||||
}
|
||||
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
graph.onNodeAdded = function (node: LGraphNode) {
|
||||
installNodeHooksRecursive(node)
|
||||
originalOnNodeAdded?.call(this, node)
|
||||
}
|
||||
|
||||
return () => {
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
@@ -238,6 +241,160 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
).sourceNodeId = '9999'
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(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
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates output slot labels when node:slot-label:changed is triggered', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addOutput('original_name', 'STRING')
|
||||
node.addOutput('other_name', 'STRING')
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = String(node.id)
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
|
||||
|
||||
expect(nodeData.outputs[0].label).toBeUndefined()
|
||||
expect(nodeData.outputs[1].label).toBeUndefined()
|
||||
|
||||
// Simulate what SubgraphNode does: set the label, then fire the trigger
|
||||
node.outputs[0].label = 'custom_label'
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const updatedData = vueNodeData.get(nodeId)
|
||||
expect(updatedData?.outputs?.[0]?.label).toBe('custom_label')
|
||||
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('original_name', 'STRING')
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = String(node.id)
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
|
||||
|
||||
expect(nodeData.inputs[0].label).toBeUndefined()
|
||||
|
||||
node.inputs[0].label = 'custom_label'
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const updatedData = vueNodeData.get(nodeId)
|
||||
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
|
||||
})
|
||||
|
||||
it('ignores node:slot-label:changed for unknown node ids', () => {
|
||||
const graph = new LGraph()
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(() =>
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: 'missing-node',
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
@@ -315,4 +472,331 @@ describe('Nested promoted widget mapping', () => {
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget(
|
||||
'text',
|
||||
'string_a',
|
||||
'independent',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
subgraph.add(independentNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'string_a'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
`${subgraph.id}:${independentNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promoted widget sourceExecutionId', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model.safetensors',
|
||||
() => undefined,
|
||||
{
|
||||
values: ['model.safetensors']
|
||||
}
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = nodeData?.widgets?.find(
|
||||
(w) => w.name === 'ckpt_name'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
// 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('does not set sourceExecutionId for non-promoted widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
graph.add(node)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget?.sourceExecutionId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function setupGraphWithStore() {
|
||||
const graph = new LGraph()
|
||||
const nodeA = new LGraphNode('KSampler')
|
||||
nodeA.addInput('model', 'MODEL')
|
||||
nodeA.addInput('steps', 'INT')
|
||||
graph.add(nodeA)
|
||||
|
||||
const nodeB = new LGraphNode('LoadCheckpoint')
|
||||
nodeB.addInput('ckpt_name', 'STRING')
|
||||
graph.add(nodeB)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
|
||||
|
||||
// Initialize store (triggers watcher registration)
|
||||
useGraphNodeManager(graph)
|
||||
const store = useExecutionErrorStore()
|
||||
return { graph, nodeA, nodeB, store }
|
||||
}
|
||||
|
||||
it('sets has_errors on nodes referenced in lastNodeErrors', async () => {
|
||||
const { nodeA, nodeB, store } = setupGraphWithStore()
|
||||
|
||||
store.lastNodeErrors = {
|
||||
[String(nodeA.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(nodeA.has_errors).toBe(true)
|
||||
expect(nodeB.has_errors).toBeFalsy()
|
||||
})
|
||||
|
||||
it('sets slot hasErrors for inputs matching error input_name', async () => {
|
||||
const { nodeA, store } = setupGraphWithStore()
|
||||
|
||||
store.lastNodeErrors = {
|
||||
[String(nodeA.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'model' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(nodeA.inputs[0].hasErrors).toBe(true)
|
||||
expect(nodeA.inputs[1].hasErrors).toBe(false)
|
||||
})
|
||||
|
||||
it('clears has_errors and slot hasErrors when errors are removed', async () => {
|
||||
const { nodeA, store } = setupGraphWithStore()
|
||||
|
||||
store.lastNodeErrors = {
|
||||
[String(nodeA.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
expect(nodeA.has_errors).toBe(true)
|
||||
expect(nodeA.inputs[1].hasErrors).toBe(true)
|
||||
|
||||
store.lastNodeErrors = null
|
||||
await nextTick()
|
||||
|
||||
expect(nodeA.has_errors).toBeFalsy()
|
||||
expect(nodeA.inputs[1].hasErrors).toBe(false)
|
||||
})
|
||||
|
||||
it('propagates has_errors to parent subgraph node', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
interiorNode.addInput('value', 'INT')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
// Error on interior node: execution ID = "50:<interiorNodeId>"
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
store.lastNodeErrors = {
|
||||
[interiorExecId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'value' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'InnerNode'
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Interior node should have the error
|
||||
expect(interiorNode.has_errors).toBe(true)
|
||||
expect(interiorNode.inputs[0].hasErrors).toBe(true)
|
||||
// Parent subgraph node should also be flagged
|
||||
expect(subgraphNode.has_errors).toBe(true)
|
||||
})
|
||||
|
||||
it('sets has_errors on nodes with missing models', async () => {
|
||||
const { nodeA, nodeB } = setupGraphWithStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: String(nodeA.id),
|
||||
nodeType: 'CheckpointLoader',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(nodeA.has_errors).toBe(true)
|
||||
expect(nodeB.has_errors).toBeFalsy()
|
||||
})
|
||||
|
||||
it('clears has_errors when missing models are removed', async () => {
|
||||
const { nodeA } = setupGraphWithStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: String(nodeA.id),
|
||||
nodeType: 'CheckpointLoader',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
await nextTick()
|
||||
expect(nodeA.has_errors).toBe(true)
|
||||
|
||||
missingModelStore.clearMissingModels()
|
||||
await nextTick()
|
||||
expect(nodeA.has_errors).toBeFalsy()
|
||||
})
|
||||
|
||||
it('flags parent subgraph node when interior node has missing model', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('CheckpointLoader')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
|
||||
nodeType: 'CheckpointLoader',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(interiorNode.has_errors).toBe(true)
|
||||
expect(subgraphNode.has_errors).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
@@ -36,10 +37,13 @@ import type {
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
linked: boolean
|
||||
originNodeId?: string
|
||||
originOutputName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +84,13 @@ export interface SafeWidgetData {
|
||||
* which differs from the subgraph node's input slot widget name.
|
||||
*/
|
||||
slotName?: string
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the
|
||||
* host subgraph node. Used for missing-model lookups that key by
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -204,7 +215,7 @@ function safeWidgetMapper(
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.advanced,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
@@ -234,16 +245,17 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
const promotedInputName = node.inputs?.find((input) => {
|
||||
if (input.name === widget.name) return true
|
||||
if (input._widget === widget) return true
|
||||
return false
|
||||
})?.name
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const promotedSource = resolvePromotedSourceByInputName(displayName) ?? {
|
||||
const directSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
@@ -324,10 +336,21 @@ function safeWidgetMapper(
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
slotMetadata: slotInfo,
|
||||
// For promoted widgets, name is sourceWidgetName while widget.name
|
||||
// is the subgraph input slot name — store the slot name for lookups.
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[safeWidgetMapper] Failed to map widget:',
|
||||
widget.name,
|
||||
error
|
||||
)
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text'
|
||||
@@ -336,6 +359,36 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined,
|
||||
graphRef: LGraph | null | undefined
|
||||
): Map<string, WidgetSlotMetadata> {
|
||||
const metadata = new Map<string, WidgetSlotMetadata>()
|
||||
inputs?.forEach((input, index) => {
|
||||
let originNodeId: string | undefined
|
||||
let originOutputName: string | undefined
|
||||
|
||||
if (input.link != null && graphRef) {
|
||||
const link = graphRef.getLink(input.link)
|
||||
if (link) {
|
||||
originNodeId = String(link.origin_id)
|
||||
const originNode = graphRef.getNodeById(link.origin_id)
|
||||
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
|
||||
}
|
||||
}
|
||||
|
||||
const slotInfo: WidgetSlotMetadata = {
|
||||
index,
|
||||
linked: input.link != null,
|
||||
originNodeId,
|
||||
originOutputName
|
||||
}
|
||||
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
|
||||
@@ -377,7 +430,9 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
},
|
||||
set(v) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
@@ -387,21 +442,30 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
},
|
||||
set(v) {
|
||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
|
||||
Object.defineProperty(node, 'outputs', {
|
||||
get() {
|
||||
return reactiveOutputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveOutputs.splice(0, reactiveOutputs.length, ...v)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const widgetsSnapshot = node.widgets ?? []
|
||||
|
||||
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
|
||||
slotMetadata.clear()
|
||||
node.inputs?.forEach((input, index) => {
|
||||
const slotInfo = {
|
||||
index,
|
||||
linked: input.link != null
|
||||
}
|
||||
if (input.name) slotMetadata.set(input.name, slotInfo)
|
||||
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
|
||||
})
|
||||
for (const [key, value] of freshMetadata) {
|
||||
slotMetadata.set(key, value)
|
||||
}
|
||||
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
@@ -429,7 +493,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: reactiveInputs,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
outputs: reactiveOutputs,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
@@ -454,22 +518,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
if (!nodeRef || !currentData) return
|
||||
|
||||
// Only extract slot-related data instead of full node re-extraction
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
|
||||
nodeRef.inputs?.forEach((input, index) => {
|
||||
const slotInfo = {
|
||||
index,
|
||||
linked: input.link != null
|
||||
}
|
||||
if (input.name) slotMetadata.set(input.name, slotInfo)
|
||||
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
|
||||
})
|
||||
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +695,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
title: String(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'has_errors':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
hasErrors: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'flags.collapsed':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
@@ -721,6 +780,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(String(slotLinksEvent.nodeId))
|
||||
}
|
||||
},
|
||||
'node:slot-label:changed': (slotLabelEvent) => {
|
||||
const nodeId = String(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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +808,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
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
|
||||
|
||||
@@ -80,8 +80,12 @@ export function showNodeOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the node options popover
|
||||
* Check if the node options menu is currently open
|
||||
*/
|
||||
export function isNodeOptionsOpen(): boolean {
|
||||
return nodeOptionsInstance?.isOpen.value ?? false
|
||||
}
|
||||
|
||||
interface NodeOptionsInstance {
|
||||
toggle: (event: Event) => void
|
||||
show: (event: MouseEvent) => void
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
function useVueNodeLifecycleIndividual() {
|
||||
@@ -17,7 +16,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
const { startSync } = useLayoutSync()
|
||||
const { startSync, stopSync } = useLayoutSync()
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
@@ -55,11 +54,13 @@ function useVueNodeLifecycleIndividual() {
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize layout sync (one-way: Layout Store → LiteGraph)
|
||||
// Start sync AFTER seeding so bootstrap operations don't trigger
|
||||
// the Layout→LiteGraph writeback loop redundantly.
|
||||
startSync(canvasStore.canvas)
|
||||
}
|
||||
|
||||
const disposeNodeManagerAndSyncs = () => {
|
||||
stopSync()
|
||||
if (!nodeManager.value) return
|
||||
|
||||
try {
|
||||
@@ -76,9 +77,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
initializeNodeManager()
|
||||
ensureCorrectLayoutScale(
|
||||
comfyApp.canvas?.graph?.extra.workflowRendererVersion
|
||||
)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -87,26 +85,17 @@ function useVueNodeLifecycleIndividual() {
|
||||
whenever(
|
||||
() => !shouldRenderVueNodes.value,
|
||||
() => {
|
||||
ensureCorrectLayoutScale(
|
||||
comfyApp.canvas?.graph?.extra.workflowRendererVersion
|
||||
)
|
||||
disposeNodeManagerAndSyncs()
|
||||
comfyApp.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
// Consolidated watch for slot layout sync management
|
||||
// Clear stale slot layouts when switching modes
|
||||
watch(
|
||||
() => shouldRenderVueNodes.value,
|
||||
(vueMode, oldVueMode) => {
|
||||
const modeChanged = vueMode !== oldVueMode
|
||||
|
||||
// Clear stale slot layouts when switching modes
|
||||
if (modeChanged) {
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
() => {
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
}
|
||||
)
|
||||
|
||||
// Handle case where Vue nodes are enabled but graph starts empty
|
||||
|
||||
@@ -4,6 +4,7 @@ import EditKeybindingContent from '@/components/dialog/content/setting/keybindin
|
||||
import EditKeybindingFooter from '@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue'
|
||||
import EditKeybindingHeader from '@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue'
|
||||
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
@@ -13,6 +14,8 @@ export interface EditKeybindingDialogState {
|
||||
commandId: string
|
||||
newCombo: KeyComboImpl | null
|
||||
currentCombo: KeyComboImpl | null
|
||||
mode: 'edit' | 'add'
|
||||
existingBinding: KeybindingImpl | null
|
||||
}
|
||||
|
||||
export function useEditKeybindingDialog() {
|
||||
@@ -23,11 +26,15 @@ export function useEditKeybindingDialog() {
|
||||
commandId: string
|
||||
commandLabel: string
|
||||
currentCombo: KeyComboImpl | null
|
||||
mode?: 'edit' | 'add'
|
||||
existingBinding?: KeybindingImpl | null
|
||||
}) {
|
||||
const dialogState = reactive<EditKeybindingDialogState>({
|
||||
commandId: options.commandId,
|
||||
newCombo: options.currentCombo,
|
||||
currentCombo: options.currentCombo
|
||||
currentCombo: options.currentCombo,
|
||||
mode: options.mode ?? 'edit',
|
||||
existingBinding: options.existingBinding ?? null
|
||||
})
|
||||
|
||||
const existingKeybindingOnCombo = computed(() => {
|
||||
|
||||
118
src/composables/useUpstreamValue.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
|
||||
|
||||
function widget(name: string, value: unknown): WidgetState {
|
||||
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
|
||||
}
|
||||
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
|
||||
describe('singleValueExtractor', () => {
|
||||
const extract = singleValueExtractor(isNumber)
|
||||
|
||||
it('matches widget by outputName', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
expect(extract(widgets, 'b')).toBe(42)
|
||||
})
|
||||
|
||||
it('returns undefined when outputName widget has invalid value', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
|
||||
expect(extract(widgets, 'b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to unique valid widget when outputName has no match', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
expect(extract(widgets, 'missing')).toBe(42)
|
||||
})
|
||||
|
||||
it('falls back to unique valid widget when no outputName provided', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
expect(extract(widgets, undefined)).toBe(42)
|
||||
})
|
||||
|
||||
it('returns undefined when multiple widgets have valid values', () => {
|
||||
const widgets = [widget('a', 1), widget('b', 2)]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when no widgets have valid values', () => {
|
||||
const widgets = [widget('a', 'text')]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty widgets', () => {
|
||||
expect(extract([], undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundsExtractor', () => {
|
||||
const extract = boundsExtractor()
|
||||
|
||||
it('extracts a single bounds object widget', () => {
|
||||
const bounds = { x: 10, y: 20, width: 100, height: 200 }
|
||||
const widgets = [widget('crop', bounds)]
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
|
||||
it('matches bounds widget by outputName', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const widgets = [widget('other', 'text'), widget('crop', bounds)]
|
||||
expect(extract(widgets, 'crop')).toEqual(bounds)
|
||||
})
|
||||
|
||||
it('assembles bounds from individual x/y/width/height widgets', () => {
|
||||
const widgets = [
|
||||
widget('x', 10),
|
||||
widget('y', 20),
|
||||
widget('width', 100),
|
||||
widget('height', 200)
|
||||
]
|
||||
expect(extract(widgets, undefined)).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when some bound components are missing', () => {
|
||||
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when bound components have wrong types', () => {
|
||||
const widgets = [
|
||||
widget('x', '10'),
|
||||
widget('y', 20),
|
||||
widget('width', 100),
|
||||
widget('height', 200)
|
||||
]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty widgets', () => {
|
||||
expect(extract([], undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects partial bounds objects', () => {
|
||||
const partial = { x: 10, y: 20 }
|
||||
const widgets = [widget('crop', partial)]
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers single bounds object over individual widgets', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const widgets = [
|
||||
widget('crop', bounds),
|
||||
widget('x', 99),
|
||||
widget('y', 99),
|
||||
widget('width', 99),
|
||||
widget('height', 99)
|
||||
]
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
})
|
||||
80
src/composables/useUpstreamValue.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
|
||||
|
||||
type ValueExtractor<T = unknown> = (
|
||||
widgets: WidgetState[],
|
||||
outputName: string | undefined
|
||||
) => T | undefined
|
||||
|
||||
export function useUpstreamValue<T>(
|
||||
getLinkedUpstream: () => LinkedUpstreamInfo | undefined,
|
||||
extractValue: ValueExtractor<T>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
return computed(() => {
|
||||
const upstream = getLinkedUpstream()
|
||||
if (!upstream) return undefined
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
if (!graphId) return undefined
|
||||
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
|
||||
return extractValue(widgets, upstream.outputName)
|
||||
})
|
||||
}
|
||||
|
||||
export function singleValueExtractor<T>(
|
||||
isValid: (value: unknown) => value is T
|
||||
): ValueExtractor<T> {
|
||||
return (widgets, outputName) => {
|
||||
if (outputName) {
|
||||
const matched = widgets.find((w) => w.name === outputName)
|
||||
if (matched && isValid(matched.value)) return matched.value
|
||||
}
|
||||
const validValues = widgets.map((w) => w.value).filter(isValid)
|
||||
return validValues.length === 1 ? validValues[0] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isBoundsObject(value: unknown): value is Bounds {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const v = value as Record<string, unknown>
|
||||
return (
|
||||
typeof v.x === 'number' &&
|
||||
typeof v.y === 'number' &&
|
||||
typeof v.width === 'number' &&
|
||||
typeof v.height === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export function boundsExtractor(): ValueExtractor<Bounds> {
|
||||
const single = singleValueExtractor(isBoundsObject)
|
||||
return (widgets, outputName) => {
|
||||
const singleResult = single(widgets, outputName)
|
||||
if (singleResult) return singleResult
|
||||
|
||||
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
|
||||
const getNum = (name: string): number | undefined => {
|
||||
const w = widgets.find((w) => w.name === name)
|
||||
return typeof w?.value === 'number' ? w.value : undefined
|
||||
}
|
||||
const x = getNum('x')
|
||||
const y = getNum('y')
|
||||
const width = getNum('width')
|
||||
const height = getNum('height')
|
||||
if (
|
||||
x !== undefined &&
|
||||
y !== undefined &&
|
||||
width !== undefined &&
|
||||
height !== undefined
|
||||
) {
|
||||
return { x, y, width, height }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"supports_preview_metadata": true,
|
||||
"supports_manager_v4_ui": true
|
||||
"supports_manager_v4_ui": true,
|
||||
"supports_progress_text_metadata": true
|
||||
}
|
||||
|
||||
77
src/core/graph/subgraph/matchPromotedInput.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { matchPromotedInput } from './matchPromotedInput'
|
||||
|
||||
type MockInput = {
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
function createWidget(name: string): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
type: 'text'
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe(matchPromotedInput, () => {
|
||||
it('prefers exact _widget matches before same-name inputs', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const aliasWidget = createWidget('seed')
|
||||
|
||||
const aliasInput: MockInput = {
|
||||
name: 'seed',
|
||||
_widget: aliasWidget
|
||||
}
|
||||
const exactInput: MockInput = {
|
||||
name: 'seed',
|
||||
_widget: targetWidget
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[aliasInput, exactInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBe(exactInput)
|
||||
})
|
||||
|
||||
it('falls back to same-name matching when no exact widget match exists', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const aliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBe(aliasInput)
|
||||
})
|
||||
|
||||
it('does not guess when multiple same-name inputs exist without an exact match', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const firstAliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
const secondAliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[firstAliasInput, secondAliasInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBeUndefined()
|
||||
})
|
||||
})
|
||||
19
src/core/graph/subgraph/matchPromotedInput.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedInputLike = {
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
export function matchPromotedInput(
|
||||
inputs: PromotedInputLike[] | undefined,
|
||||
widget: IBaseWidget
|
||||
): PromotedInputLike | undefined {
|
||||
if (!inputs) return undefined
|
||||
|
||||
const exactMatch = inputs.find((input) => input._widget === widget)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const sameNameMatches = inputs.filter((input) => input.name === widget.name)
|
||||
return sameNameMatches.length === 1 ? sameNameMatches[0] : undefined
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Barrel import must come first to avoid circular dependency
|
||||
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
||||
@@ -11,19 +11,26 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
cleanupComplexPromotionFixtureNodeType,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
setupComplexPromotionFixture
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -78,6 +85,22 @@ function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
|
||||
return innerNode
|
||||
}
|
||||
|
||||
function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] {
|
||||
return node.widgets as PromotedWidgetView[]
|
||||
}
|
||||
|
||||
function callSyncPromotions(node: SubgraphNode) {
|
||||
;(
|
||||
node as unknown as {
|
||||
_syncPromotions: () => void
|
||||
}
|
||||
)._syncPromotions()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
})
|
||||
|
||||
describe(createPromotedWidgetView, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -263,6 +286,31 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(fallbackWidget.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('value setter falls back to host widget when linked states are unavailable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'initial', () => {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const linkedView = promotedWidgets(subgraphNode)[0]
|
||||
if (!linkedView) throw new Error('Expected a linked promoted widget')
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
linkedView.value = 'updated'
|
||||
|
||||
expect(linkedNode.widgets?.[0].value).toBe('updated')
|
||||
})
|
||||
|
||||
test('label falls back to displayName then widgetName', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -495,6 +543,185 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('renders all promoted widgets when duplicate input names are connected to different nodes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 94 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstSeedNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondSeedNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
String(firstNode.id),
|
||||
String(secondNode.id)
|
||||
])
|
||||
})
|
||||
|
||||
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 95 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const promotedNode = new LGraphNode('PromotedNode')
|
||||
promotedNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(promotedNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(promotedNode.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(linkedNodeA.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
|
||||
const linkedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(linkedNodeA.id)
|
||||
)
|
||||
const promotedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(promotedNode.id)
|
||||
)
|
||||
if (!linkedView || !promotedView)
|
||||
throw new Error(
|
||||
'Expected linked and store-promoted widgets to be present'
|
||||
)
|
||||
|
||||
linkedView.value = 'shared-value'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = subgraphNode.rootGraph.id
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent')
|
||||
|
||||
promotedView.value = 'independent-updated'
|
||||
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent-updated')
|
||||
})
|
||||
|
||||
test('duplicate-name promoted views map slot linkage by view identity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const linkedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(linkedNode.id)
|
||||
)
|
||||
const independentView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(independentNode.id)
|
||||
)
|
||||
if (!linkedView || !independentView)
|
||||
throw new Error('Expected linked and independent promoted views')
|
||||
|
||||
const linkedSlot = subgraphNode.getSlotFromWidget(linkedView)
|
||||
const independentSlot = subgraphNode.getSlotFromWidget(independentView)
|
||||
|
||||
expect(linkedSlot).toBeDefined()
|
||||
expect(independentSlot).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns empty array when no proxyWidgets', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
@@ -558,6 +785,273 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 125 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const liveNode = new LGraphNode('LiveNode')
|
||||
const liveInput = liveNode.addInput('widgetA', '*')
|
||||
liveNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
liveInput.widget = { name: 'widgetA' }
|
||||
subgraph.add(liveNode)
|
||||
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(liveNode.id), 'widgetA'],
|
||||
['9999', 'widgetA']
|
||||
])
|
||||
|
||||
callSyncPromotions(subgraphNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetA' }
|
||||
])
|
||||
})
|
||||
|
||||
test('input-added existing-input path tolerates missing link metadata', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 126 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const existingSlot = subgraph.inputNode.slots[0]
|
||||
if (!existingSlot) throw new Error('Expected subgraph input slot')
|
||||
|
||||
expect(() => {
|
||||
subgraph.events.dispatch('input-added', { input: existingSlot })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('syncPromotions prunes stale connected entries but keeps independent promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 96 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(independentNode.id), 'string_a'],
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a']
|
||||
])
|
||||
|
||||
callSyncPromotions(subgraphNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
|
||||
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
|
||||
])
|
||||
})
|
||||
|
||||
test('syncPromotions prunes stale deep-alias entries for nested linked promotions', () => {
|
||||
const { subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
const linkedView = promotedWidgets(subgraphNodeB)[0]
|
||||
if (!linkedView)
|
||||
throw new Error(
|
||||
'Expected nested subgraph to expose a linked promoted view'
|
||||
)
|
||||
|
||||
const concrete = resolveConcretePromotedWidget(
|
||||
subgraphNodeB,
|
||||
linkedView.sourceNodeId,
|
||||
linkedView.sourceWidgetName
|
||||
)
|
||||
if (concrete.status !== 'resolved')
|
||||
throw new Error(
|
||||
'Expected nested promoted view to resolve to concrete widget'
|
||||
)
|
||||
|
||||
const linkedEntry = [
|
||||
linkedView.sourceNodeId,
|
||||
linkedView.sourceWidgetName
|
||||
] as [string, string]
|
||||
const deepAliasEntry = [
|
||||
String(concrete.resolved.node.id),
|
||||
concrete.resolved.widget.name
|
||||
] as [string, string]
|
||||
|
||||
// Guardrail: this test specifically validates host/deep alias cleanup.
|
||||
expect(deepAliasEntry).not.toStrictEqual(linkedEntry)
|
||||
|
||||
setPromotions(subgraphNodeB, [linkedEntry, deepAliasEntry])
|
||||
|
||||
callSyncPromotions(subgraphNodeB)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNodeB.rootGraph.id,
|
||||
subgraphNodeB.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: linkedEntry[0],
|
||||
widgetName: linkedEntry[1]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('configure prunes stale disconnected host aliases that resolve to the active linked concrete widget', () => {
|
||||
const nestedSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('string_a', '*')
|
||||
concreteNode.addWidget('text', 'string_a', 'value', () => {})
|
||||
concreteInput.widget = { name: 'string_a' }
|
||||
nestedSubgraph.add(concreteNode)
|
||||
nestedSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const hostSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const activeAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 118 })
|
||||
const staleAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 119 })
|
||||
hostSubgraph.add(activeAliasNode)
|
||||
hostSubgraph.add(staleAliasNode)
|
||||
|
||||
activeAliasNode._internalConfigureAfterSlots()
|
||||
staleAliasNode._internalConfigureAfterSlots()
|
||||
hostSubgraph.inputNode.slots[0].connect(
|
||||
activeAliasNode.inputs[0],
|
||||
activeAliasNode
|
||||
)
|
||||
|
||||
const hostSubgraphNode = createTestSubgraphNode(hostSubgraph, { id: 120 })
|
||||
hostSubgraphNode.graph?.add(hostSubgraphNode)
|
||||
|
||||
setPromotions(hostSubgraphNode, [
|
||||
[String(activeAliasNode.id), 'string_a'],
|
||||
[String(staleAliasNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = hostSubgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(hostSubgraph, { id: 121 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: hostSubgraph.id,
|
||||
inputs: []
|
||||
})
|
||||
|
||||
const restoredPromotions = usePromotionStore().getPromotions(
|
||||
restoredNode.rootGraph.id,
|
||||
restoredNode.id
|
||||
)
|
||||
expect(restoredPromotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(activeAliasNode.id),
|
||||
widgetName: 'string_a'
|
||||
}
|
||||
])
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(1)
|
||||
expect(restoredWidgets[0].sourceNodeId).toBe(String(activeAliasNode.id))
|
||||
})
|
||||
|
||||
test('serialize syncs duplicate-name linked inputs by subgraph slot identity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 127 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('text', 'seed', 'first-initial', () => {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('text', 'seed', 'second-initial', () => {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const firstView = widgets[0]
|
||||
const secondView = widgets[1]
|
||||
if (!firstView || !secondView)
|
||||
throw new Error('Expected two linked promoted views')
|
||||
|
||||
firstView.value = 'first-updated'
|
||||
secondView.value = 'second-updated'
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
})
|
||||
|
||||
test('renaming an input updates linked promoted view display names', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 128 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('seed', '*')
|
||||
linkedNode.addWidget('text', 'seed', 'value', () => {})
|
||||
linkedInput.widget = { name: 'seed' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const beforeRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!beforeRename) throw new Error('Expected linked promoted view')
|
||||
expect(beforeRename.name).toBe('seed')
|
||||
|
||||
const inputToRename = subgraph.inputs[0]
|
||||
if (!inputToRename) throw new Error('Expected input to rename')
|
||||
subgraph.renameInput(inputToRename, 'seed_renamed')
|
||||
|
||||
const afterRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!afterRename) throw new Error('Expected linked promoted view')
|
||||
expect(afterRename.name).toBe('seed_renamed')
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
@@ -701,6 +1195,236 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('configure with empty serialized inputs keeps linked filtering active', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
|
||||
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(storeOnlyNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a'],
|
||||
[String(storeOnlyNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(subgraph, { id: 98 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraph.id,
|
||||
inputs: []
|
||||
})
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
|
||||
const linkedViewCount = restoredWidgets.filter((widget) =>
|
||||
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
|
||||
widget.sourceNodeId
|
||||
)
|
||||
).length
|
||||
expect(linkedViewCount).toBe(1)
|
||||
expect(
|
||||
restoredWidgets.some(
|
||||
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('configure with serialized inputs rebinds subgraph slots for linked filtering', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 107 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
|
||||
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(storeOnlyNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a'],
|
||||
[String(storeOnlyNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(subgraph, { id: 108 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraph.id,
|
||||
inputs: [
|
||||
{
|
||||
name: 'string_a',
|
||||
type: '*',
|
||||
link: null
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
|
||||
const linkedViewCount = restoredWidgets.filter((widget) =>
|
||||
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
|
||||
widget.sourceNodeId
|
||||
)
|
||||
).length
|
||||
expect(linkedViewCount).toBe(1)
|
||||
expect(
|
||||
restoredWidgets.some(
|
||||
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('fixture keeps earliest linked representative and independent promotion only', () => {
|
||||
const { graph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const hostWidgets = promotedWidgets(hostNode)
|
||||
expect(hostWidgets).toHaveLength(2)
|
||||
expect(hostWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'string_a' },
|
||||
{ interiorNodeId: '19', widgetName: 'string_a' }
|
||||
])
|
||||
|
||||
const linkedView = hostWidgets[0]
|
||||
const independentView = hostWidgets[1]
|
||||
if (!linkedView || !independentView)
|
||||
throw new Error('Expected linked and independent promoted widgets')
|
||||
|
||||
independentView.value = 'independent-value'
|
||||
linkedView.value = 'shared-linked'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const getValue = (nodeId: string) =>
|
||||
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
|
||||
?.value
|
||||
|
||||
expect(getValue('20')).toBe('shared-linked')
|
||||
expect(getValue('18')).toBe('shared-linked')
|
||||
expect(getValue('19')).toBe('independent-value')
|
||||
})
|
||||
|
||||
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
|
||||
const { subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const earliestLinkedNode = subgraph.getNodeById(20)
|
||||
if (!earliestLinkedNode?.widgets)
|
||||
throw new Error('Expected fixture to contain node 20 with widgets')
|
||||
|
||||
const originalWidgets = earliestLinkedNode.widgets
|
||||
earliestLinkedNode.widgets = originalWidgets.filter(
|
||||
(widget) => widget.name !== 'string_a'
|
||||
)
|
||||
|
||||
const unresolvedWidgets = promotedWidgets(hostNode)
|
||||
expect(
|
||||
unresolvedWidgets.map((widget) => widget.sourceNodeId)
|
||||
).toStrictEqual(['18', '20', '19'])
|
||||
|
||||
earliestLinkedNode.widgets = originalWidgets
|
||||
|
||||
const restoredWidgets = promotedWidgets(hostNode)
|
||||
expect(restoredWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
})
|
||||
|
||||
test('fixture converges external widgets and keeps rendered value isolation after transient linked fallback churn', () => {
|
||||
const { subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const initialWidgets = promotedWidgets(hostNode)
|
||||
expect(initialWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const earliestLinkedNode = subgraph.getNodeById(20)
|
||||
if (!earliestLinkedNode?.widgets)
|
||||
throw new Error('Expected fixture to contain node 20 with widgets')
|
||||
|
||||
const originalWidgets = earliestLinkedNode.widgets
|
||||
earliestLinkedNode.widgets = originalWidgets.filter(
|
||||
(widget) => widget.name !== 'string_a'
|
||||
)
|
||||
|
||||
const transientWidgets = promotedWidgets(hostNode)
|
||||
expect(transientWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual(
|
||||
['18', '20', '19']
|
||||
)
|
||||
|
||||
earliestLinkedNode.widgets = originalWidgets
|
||||
|
||||
const finalWidgets = promotedWidgets(hostNode)
|
||||
expect(finalWidgets).toHaveLength(2)
|
||||
expect(finalWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const finalLinkedView = finalWidgets.find(
|
||||
(widget) => widget.sourceNodeId === '20'
|
||||
)
|
||||
const finalIndependentView = finalWidgets.find(
|
||||
(widget) => widget.sourceNodeId === '19'
|
||||
)
|
||||
if (!finalLinkedView || !finalIndependentView)
|
||||
throw new Error('Expected final rendered linked and independent views')
|
||||
|
||||
finalIndependentView.value = 'independent-final'
|
||||
expect(finalIndependentView.value).toBe('independent-final')
|
||||
expect(finalLinkedView.value).not.toBe('independent-final')
|
||||
|
||||
finalLinkedView.value = 'linked-final'
|
||||
expect(finalLinkedView.value).toBe('linked-final')
|
||||
expect(finalIndependentView.value).toBe('independent-final')
|
||||
})
|
||||
|
||||
test('clone output preserves proxyWidgets for promotion hydration', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -751,6 +1475,103 @@ describe('widgets getter caching', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
void subgraphNode.widgets
|
||||
void subgraphNode.widgets
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not re-run reconciliation when only canvas frame advances', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
fakeCanvas.frame += 1
|
||||
void subgraphNode.widgets
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not re-resolve linked entries when linked input state is unchanged', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
const resolveSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown
|
||||
},
|
||||
'_resolveLinkedPromotionBySubgraphInput'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
const initialResolveCount = resolveSpy.mock.calls.length
|
||||
expect(initialResolveCount).toBeLessThanOrEqual(1)
|
||||
|
||||
void subgraphNode.widgets
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(initialResolveCount)
|
||||
})
|
||||
|
||||
test('preserves view identities when promotion order changes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
@@ -131,6 +135,38 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let didUpdateState = false
|
||||
for (const linkedWidget of linkedWidgets) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
linkedWidget.nodeId,
|
||||
linkedWidget.widgetName
|
||||
)
|
||||
if (state) {
|
||||
state.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (resolved) {
|
||||
const resolvedState = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
if (resolvedState) {
|
||||
resolvedState.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
if (didUpdateState) return
|
||||
}
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
@@ -278,6 +314,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
@@ -287,6 +326,57 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function hasWidgetNode(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { node: LGraphNode } {
|
||||
return 'node' in widget && !!widget.node
|
||||
}
|
||||
34
src/core/graph/subgraph/widgetRenderKey.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { getStableWidgetRenderKey } from './widgetRenderKey'
|
||||
|
||||
function createWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
return {
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe(getStableWidgetRenderKey, () => {
|
||||
it('returns a stable key for the same widget instance', () => {
|
||||
const widget = createWidget()
|
||||
|
||||
const first = getStableWidgetRenderKey(widget)
|
||||
const second = getStableWidgetRenderKey(widget)
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('returns distinct keys for distinct widget instances', () => {
|
||||
const firstWidget = createWidget()
|
||||
const secondWidget = createWidget()
|
||||
|
||||
const firstKey = getStableWidgetRenderKey(firstWidget)
|
||||
const secondKey = getStableWidgetRenderKey(secondWidget)
|
||||
|
||||
expect(secondKey).not.toBe(firstKey)
|
||||
})
|
||||
})
|
||||
17
src/core/graph/subgraph/widgetRenderKey.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
|
||||
let nextWidgetRenderKeyId = 0
|
||||
|
||||
export function getStableWidgetRenderKey(widget: IBaseWidget): string {
|
||||
const cachedKey = widgetRenderKeys.get(widget)
|
||||
if (cachedKey) return cachedKey
|
||||
|
||||
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
|
||||
const key = `${prefix}:${nextWidgetRenderKeyId++}`
|
||||
|
||||
widgetRenderKeys.set(widget, key)
|
||||
return key
|
||||
}
|
||||
@@ -534,6 +534,141 @@ describe('ensureGlobalIdUniqueness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('_removeDuplicateLinks', () => {
|
||||
class TestNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'TestNode')
|
||||
this.addInput('input_0', 'number')
|
||||
this.addOutput('output_0', 'number')
|
||||
}
|
||||
}
|
||||
|
||||
function registerTestNodes() {
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
}
|
||||
|
||||
it('removes orphaned duplicate links from _links and output.links', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
expect(graph._links.size).toBe(1)
|
||||
|
||||
const existingLink = graph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
|
||||
expect(graph._links.size).toBe(4)
|
||||
expect(source.outputs[0].links).toHaveLength(4)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(source.outputs[0].links).toHaveLength(1)
|
||||
expect(target.inputs[0].link).toBe(source.outputs[0].links![0])
|
||||
})
|
||||
|
||||
it('keeps the link referenced by input.link', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
const keptLinkId = target.inputs[0].link!
|
||||
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(target.inputs[0].link).toBe(keptLinkId)
|
||||
expect(graph._links.has(keptLinkId)).toBe(true)
|
||||
expect(graph._links.has(dupLink.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op when no duplicates exist', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
const linksBefore = graph._links.size
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
expect(graph._links.size).toBe(linksBefore)
|
||||
})
|
||||
|
||||
it('cleans up duplicate links in subgraph during configure', () => {
|
||||
const subgraphData = createTestSubgraphData()
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = rootGraph.createSubgraph(subgraphData)
|
||||
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'number')
|
||||
const target = new LGraphNode('Target')
|
||||
target.addInput('in', 'number')
|
||||
subgraph.add(source)
|
||||
subgraph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dup = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
}
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
// Serialize and reconfigure - should clean up during configure
|
||||
const serialized = subgraph.asSerialisable()
|
||||
subgraph.configure(serialized as never)
|
||||
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Unpacking', () => {
|
||||
class TestNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
|
||||
@@ -45,7 +45,8 @@ import { LiteGraph, SubgraphNode } from './litegraph'
|
||||
import {
|
||||
alignOutsideContainer,
|
||||
alignToContainer,
|
||||
createBounds
|
||||
createBounds,
|
||||
snapPoint
|
||||
} from './measure'
|
||||
import { SubgraphInput } from './subgraph/SubgraphInput'
|
||||
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
||||
@@ -84,7 +85,7 @@ export type {
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
export type RendererType = 'LG' | 'Vue'
|
||||
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
|
||||
|
||||
export interface LGraphState {
|
||||
lastGroupId: number
|
||||
@@ -164,6 +165,11 @@ export class LGraph
|
||||
static STATUS_STOPPED = 1
|
||||
static STATUS_RUNNING = 2
|
||||
|
||||
/** Generates a unique string key for a link's connection tuple. */
|
||||
static _linkTupleKey(link: LLink): string {
|
||||
return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}`
|
||||
}
|
||||
|
||||
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
||||
static readonly ConfigureProperties = new Set([
|
||||
'nodes',
|
||||
@@ -1331,7 +1337,8 @@ export class LGraph
|
||||
const validEventTypes = new Set([
|
||||
'node:slot-links:changed',
|
||||
'node:slot-errors:changed',
|
||||
'node:property:changed'
|
||||
'node:property:changed',
|
||||
'node:slot-label:changed'
|
||||
])
|
||||
|
||||
if (validEventTypes.has(action) && param && typeof param === 'object') {
|
||||
@@ -1611,6 +1618,52 @@ export class LGraph
|
||||
link.disconnect(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate links that share the same connection tuple
|
||||
* (origin_id, origin_slot, target_id, target_slot). Keeps the link
|
||||
* referenced by input.link and removes orphaned duplicates from
|
||||
* output.links and the graph's _links map.
|
||||
*/
|
||||
_removeDuplicateLinks(): void {
|
||||
const seen = new Map<string, LinkId>()
|
||||
const toRemove: LinkId[] = []
|
||||
|
||||
for (const [id, link] of this._links) {
|
||||
const key = LGraph._linkTupleKey(link)
|
||||
if (seen.has(key)) {
|
||||
const existingId = seen.get(key)!
|
||||
// Keep the link that the input side references
|
||||
const node = this.getNodeById(link.target_id)
|
||||
const input = node?.inputs?.[link.target_slot]
|
||||
if (input?.link === id) {
|
||||
toRemove.push(existingId)
|
||||
seen.set(key, id)
|
||||
} else {
|
||||
toRemove.push(id)
|
||||
}
|
||||
} else {
|
||||
seen.set(key, id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of toRemove) {
|
||||
const link = this._links.get(id)
|
||||
if (!link) continue
|
||||
|
||||
// Remove from origin node's output.links array
|
||||
const originNode = this.getNodeById(link.origin_id)
|
||||
if (originNode) {
|
||||
const output = originNode.outputs?.[link.origin_slot]
|
||||
if (output?.links) {
|
||||
const idx = output.links.indexOf(id)
|
||||
if (idx !== -1) output.links.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
this._links.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new subgraph definition, and adds it to the graph.
|
||||
* @param data Exported data (typically serialised) to configure the new subgraph with
|
||||
@@ -2072,7 +2125,7 @@ export class LGraph
|
||||
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
|
||||
const seenLinks = new Set<string>()
|
||||
const dedupedNewLinks = newLinks.filter((link) => {
|
||||
const key = `${link.oid}:${link.oslot}:${link.tid}:${link.tslot}`
|
||||
const key = `${link.oid}\0${link.oslot}\0${link.tid}\0${link.tslot}`
|
||||
if (seenLinks.has(key)) return false
|
||||
seenLinks.add(key)
|
||||
return true
|
||||
@@ -2542,7 +2595,18 @@ export class LGraph
|
||||
|
||||
// configure nodes afterwards so they can reach each other
|
||||
for (const [id, nodeData] of nodeDataMap) {
|
||||
this.getNodeById(id)?.configure(nodeData)
|
||||
const node = this.getNodeById(id)
|
||||
node?.configure(nodeData)
|
||||
|
||||
if (LiteGraph.alwaysSnapToGrid && node) {
|
||||
const snapTo = this.getSnapToGridSize()
|
||||
if (node.snapToGrid(snapTo)) {
|
||||
// snapToGrid mutates the internal _pos array in-place, bypassing the setter
|
||||
// This reassignment triggers the pos setter to sync to the Vue layout store
|
||||
node.pos = [node.pos[0], node.pos[1]]
|
||||
}
|
||||
snapPoint(node.size, snapTo, 'ceil')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2568,6 +2632,12 @@ export class LGraph
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicate links: links in output.links that share the same
|
||||
// (origin_id, origin_slot, target_id, target_slot) tuple.
|
||||
// This repairs corrupted data where extra link objects were created
|
||||
// without proper cleanup of the previous connection.
|
||||
this._removeDuplicateLinks()
|
||||
|
||||
// groups
|
||||
this._groups.length = 0
|
||||
const groupData = data.groups
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphIcon } from './LGraphIcon'
|
||||
import type { LGraphIconOptions } from './LGraphIcon'
|
||||
import { cachedMeasureText } from './utils/textMeasureCache'
|
||||
|
||||
export enum BadgePosition {
|
||||
TopLeft = 'top-left',
|
||||
@@ -80,11 +81,11 @@ export class LGraphBadge {
|
||||
iconWidth = this.icon.size + this.padding
|
||||
} else if (this.icon.unicode) {
|
||||
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
|
||||
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
|
||||
iconWidth = cachedMeasureText(ctx, this.icon.unicode) + this.padding
|
||||
}
|
||||
}
|
||||
ctx.font = `${this.fontSize}px sans-serif`
|
||||
const textWidth = this.text ? ctx.measureText(this.text).width : 0
|
||||
const textWidth = this.text ? cachedMeasureText(ctx, this.text) : 0
|
||||
ctx.font = font
|
||||
return iconWidth + textWidth + this.padding * 2
|
||||
}
|
||||
|
||||