Compare commits
45 Commits
fix/codera
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c318cc4c14 | ||
|
|
bfabf128ce | ||
|
|
6a8f3ef1a1 | ||
|
|
649b9b3fe3 | ||
|
|
d82bce90ea | ||
|
|
91e429a62f | ||
|
|
21edbd3ee5 | ||
|
|
f5363e4028 | ||
|
|
4337b8d6c6 | ||
|
|
113a2b5d92 | ||
|
|
bb84dd202d |
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()
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@ const extraFileExtensions = ['.vue']
|
||||
const commonGlobals = {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly',
|
||||
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
|
||||
__DISTRIBUTION__: 'readonly',
|
||||
__IS_NIGHTLY__: 'readonly'
|
||||
} as const
|
||||
|
||||
1
global.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
declare const __COMFYUI_FRONTEND_VERSION__: string
|
||||
declare const __COMFYUI_FRONTEND_COMMIT__: string
|
||||
declare const __SENTRY_ENABLED__: boolean
|
||||
declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
|
||||
55
index.html
@@ -47,7 +47,60 @@
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="splash.css" />
|
||||
<style>
|
||||
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
|
||||
index.html instead of CSS on cloud/ephemeral environments */
|
||||
#splash-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
}
|
||||
#splash-loader svg {
|
||||
width: min(200px, 50vw);
|
||||
height: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
animation: splash-rise 4s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-path {
|
||||
animation: splash-wave 1.2s linear infinite;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@keyframes splash-rise {
|
||||
from {
|
||||
transform: translateY(280px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
@keyframes splash-wave {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-880px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash-loader .wave-group,
|
||||
#splash-loader .wave-path {
|
||||
animation: none;
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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.3",
|
||||
"version": "1.42.4",
|
||||
"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>
|
||||
@@ -59,6 +59,8 @@ import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
}>()
|
||||
@@ -77,6 +79,7 @@ type SystemInfoKey = keyof SystemStats['system']
|
||||
type ColumnDef = {
|
||||
field: SystemInfoKey
|
||||
header: string
|
||||
getValue?: () => string
|
||||
format?: (value: string) => string
|
||||
formatNumber?: (value: number) => string
|
||||
}
|
||||
@@ -104,6 +107,7 @@ const cloudColumns: ColumnDef[] = [
|
||||
{
|
||||
field: 'comfyui_frontend_version',
|
||||
header: 'Frontend Version',
|
||||
getValue: () => frontendCommit,
|
||||
format: formatCommitHash
|
||||
},
|
||||
{ field: 'workflow_templates_version', header: 'Templates Version' }
|
||||
@@ -119,7 +123,9 @@ function isOutdated(column: ColumnDef): boolean {
|
||||
}
|
||||
|
||||
function getDisplayValue(column: ColumnDef) {
|
||||
const value = systemInfo.value[column.field]
|
||||
const value = column.getValue
|
||||
? column.getValue()
|
||||
: systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,31 +30,33 @@
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Skeleton v-if="showSkeleton(model)" class="ml-1.5 h-4 w-12" />
|
||||
<span
|
||||
v-else-if="model.isDownloadable && fileSizes.get(model.url)"
|
||||
class="pl-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<a
|
||||
v-else-if="gatedModelUrls.has(model.url)"
|
||||
:href="gatedModelUrls.get(model.url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ $t('missingModelsDialog.acceptTerms') }}
|
||||
</a>
|
||||
<Button
|
||||
v-else-if="model.isDownloadable"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-else-if="model.isDownloadable">
|
||||
<span
|
||||
v-if="fileSizes.get(model.url)"
|
||||
class="pl-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<a
|
||||
v-if="gatedModelUrls.has(model.url)"
|
||||
:href="gatedModelUrls.get(model.url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ $t('missingModelsDialog.acceptTerms') }}
|
||||
</a>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
68
src/components/ui/color-picker/ColorPicker.stories.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
|
||||
const meta: Meta<ComponentPropsAndSlots<typeof ColorPicker>> = {
|
||||
title: 'Components/ColorPicker',
|
||||
component: ColorPicker,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-60"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { ColorPicker },
|
||||
setup() {
|
||||
const color = ref('#e06cbd')
|
||||
return { color }
|
||||
},
|
||||
template: '<ColorPicker v-model="color" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Red: Story = {
|
||||
render: () => ({
|
||||
components: { ColorPicker },
|
||||
setup() {
|
||||
const color = ref('#ff0000')
|
||||
return { color }
|
||||
},
|
||||
template: '<ColorPicker v-model="color" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Black: Story = {
|
||||
render: () => ({
|
||||
components: { ColorPicker },
|
||||
setup() {
|
||||
const color = ref('#000000')
|
||||
return { color }
|
||||
},
|
||||
template: '<ColorPicker v-model="color" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const White: Story = {
|
||||
render: () => ({
|
||||
components: { ColorPicker },
|
||||
setup() {
|
||||
const color = ref('#ffffff')
|
||||
return { color }
|
||||
},
|
||||
template: '<ColorPicker v-model="color" />'
|
||||
})
|
||||
}
|
||||
125
src/components/ui/color-picker/ColorPicker.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ColorPickerPanel from './ColorPickerPanel.vue'
|
||||
|
||||
defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '#000000' })
|
||||
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
hsva,
|
||||
(newHsva) => {
|
||||
const hex = hsvaToHex(newHsva)
|
||||
if (hex !== modelValue.value) {
|
||||
modelValue.value = hex
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const baseRgb = computed(() =>
|
||||
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
|
||||
)
|
||||
|
||||
const previewColor = computed(() => {
|
||||
const hex = rgbToHex(baseRgb.value)
|
||||
const a = hsva.value.a / 100
|
||||
if (a < 1) {
|
||||
const alphaHex = Math.round(a * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `${hex}${alphaHex}`
|
||||
}
|
||||
return hex
|
||||
})
|
||||
|
||||
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
|
||||
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-node-component-surface pr-2 outline-none hover:bg-component-node-widget-background-hovered',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-node-component-slot-text"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="7"
|
||||
:collision-padding="10"
|
||||
class="z-1700"
|
||||
>
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
v-model:display-mode="displayMode"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
81
src/components/ui/color-picker/ColorPickerPanel.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
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 type { HSVA } from '@/utils/colorUtil'
|
||||
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const rgb = computed(() =>
|
||||
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
|
||||
)
|
||||
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
|
||||
>
|
||||
<ColorPickerSaturationValue
|
||||
v-model:saturation="hsva.s"
|
||||
v-model:value="hsva.v"
|
||||
:hue="hsva.h"
|
||||
/>
|
||||
<ColorPickerSlider v-model="hsva.h" type="hue" />
|
||||
<ColorPickerSlider
|
||||
v-model="hsva.a"
|
||||
type="alpha"
|
||||
:hue="hsva.h"
|
||||
:saturation="hsva.s"
|
||||
:brightness="hsva.v"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<Select v-model="displayMode">
|
||||
<SelectTrigger
|
||||
class="h-6 w-[58px] shrink-0 gap-0.5 overflow-clip rounded-sm border-0 px-1.5 py-0 text-xs [&>span]:overflow-visible"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="min-w-16 p-1">
|
||||
<SelectItem value="hex" class="px-2 py-1 text-xs">
|
||||
{{ t('color.hex') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="rgba" class="px-2 py-1 text-xs">
|
||||
{{ t('color.rgba') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span class="min-w-0 flex-1 truncate text-center">{{
|
||||
hexString
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
|
||||
</template>
|
||||
<span class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hue } = defineProps<{
|
||||
hue: number
|
||||
}>()
|
||||
|
||||
const saturation = defineModel<number>('saturation', { required: true })
|
||||
const value = defineModel<number>('value', { required: true })
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
|
||||
|
||||
const handleStyle = computed(() => ({
|
||||
left: `${saturation.value}%`,
|
||||
top: `${100 - value.value}%`
|
||||
}))
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||
saturation.value = Math.round(x * 100)
|
||||
value.value = Math.round((1 - y) * 100)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
role="slider"
|
||||
:aria-label="t('color.saturationBrightness')"
|
||||
:aria-valuetext="`${saturation}%, ${value}%`"
|
||||
class="relative aspect-square w-full cursor-crosshair rounded-sm"
|
||||
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="handleStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
91
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
type,
|
||||
hue = 0,
|
||||
saturation = 100,
|
||||
brightness = 100
|
||||
} = defineProps<{
|
||||
type: 'hue' | 'alpha'
|
||||
hue?: number
|
||||
saturation?: number
|
||||
brightness?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const max = computed(() => (type === 'hue' ? 360 : 100))
|
||||
|
||||
const fraction = computed(() => modelValue.value / max.value)
|
||||
|
||||
const trackBackground = computed(() => {
|
||||
if (type === 'hue') {
|
||||
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
|
||||
}
|
||||
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
|
||||
const hex = rgbToHex(rgb)
|
||||
return `linear-gradient(to right, transparent, ${hex})`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
if (type === 'alpha') {
|
||||
return {
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '8px 8px',
|
||||
touchAction: 'none'
|
||||
}
|
||||
}
|
||||
return {
|
||||
background: trackBackground.value,
|
||||
touchAction: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
modelValue.value = Math.round(x * max.value)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="slider"
|
||||
:aria-label="type === 'hue' ? t('color.hue') : t('color.alpha')"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="modelValue"
|
||||
class="relative flex h-4 cursor-pointer items-center rounded-full p-px"
|
||||
:style="containerStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
>
|
||||
<div
|
||||
v-if="type === 'alpha'"
|
||||
class="absolute inset-0 rounded-full"
|
||||
:style="{ background: trackBackground }"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="{ left: `${fraction * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
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
|
||||
}
|
||||
}
|
||||