Compare commits
50 Commits
remove-cac
...
import/exp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5779972811 | ||
|
|
1b32add744 | ||
|
|
02bdbf885d | ||
|
|
ae3ba8762f | ||
|
|
48d928fc9e | ||
|
|
10b0350d01 | ||
|
|
5167a511ce | ||
|
|
4bdf67ca21 | ||
|
|
e119383072 | ||
|
|
f4bf169b2f | ||
|
|
07d5bd50f6 | ||
|
|
c318cc4c14 | ||
|
|
bfabf128ce | ||
|
|
6a8f3ef1a1 | ||
|
|
649b9b3fe3 | ||
|
|
d82bce90ea | ||
|
|
91e429a62f | ||
|
|
21edbd3ee5 | ||
|
|
f5363e4028 | ||
|
|
4337b8d6c6 | ||
|
|
113a2b5d92 | ||
|
|
bb84dd202d | ||
|
|
e5c8d26061 | ||
|
|
37ff065061 | ||
|
|
5fe31e63ec | ||
|
|
7206dea2d6 | ||
|
|
8f07468fdd | ||
|
|
8b9eb5f60d | ||
|
|
8c93567019 | ||
|
|
70f61e3379 | ||
|
|
52a56b3a88 | ||
|
|
312c5e47a6 | ||
|
|
a40d274bfd | ||
|
|
832ba4bec5 | ||
|
|
a8a7d566d8 | ||
|
|
7392551e70 | ||
|
|
4602f31207 | ||
|
|
42f78c9f36 | ||
|
|
0fd9989e3c | ||
|
|
2d9d699f33 | ||
|
|
35dbc244e9 | ||
|
|
b0171dd6c5 | ||
|
|
4b0a2668d6 | ||
|
|
fe9b1a0964 | ||
|
|
b2dd0d8ad3 | ||
|
|
da144c709d | ||
|
|
83701d2bbd | ||
|
|
5a4a33aee0 | ||
|
|
bdbcba8f5c | ||
|
|
ecf0411799 |
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\\('', ''\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
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.'
|
||||
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.'
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- **Required Software**:
|
||||
- Node.js (see `.nvmrc`, currently v24) and pnpm
|
||||
- Node.js (see `.nvmrc` for the required version) and pnpm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
|
||||
|
||||
@@ -87,6 +87,10 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
|
||||
> ⚠️ IMPORTANT:
|
||||
> The dev server will NOT load JavaScript extensions from custom nodes. Only core extensions (built into the frontend) will be loaded. This is because the shim system that allows custom node JavaScript to import frontend modules only works in production builds. Python custom nodes still function normally. See [Extension Development Guide](docs/extensions/development.md) for details and workarounds. And See [Extension Overview](docs/extensions/README.md) for extensions overview.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you run into issues during development (e.g. `pnpm dev` hanging, TypeScript errors after pulling, lock file conflicts), see [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common fixes.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Architecture Decision Records
|
||||
|
||||
368
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
This guide helps you resolve common issues when developing ComfyUI Frontend.
|
||||
|
||||
## Quick Diagnostic Flowchart
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Having Issues?] --> B{What's the problem?}
|
||||
B -->|Dev server stuck| C[nx serve hangs]
|
||||
B -->|Build errors| D[Check build issues]
|
||||
B -->|Lint errors| Q[Check linting issues]
|
||||
B -->|Dependency issues| E[Package problems]
|
||||
B -->|Other| F[See FAQ below]
|
||||
|
||||
Q --> R{oxlint or ESLint?}
|
||||
R -->|oxlint| S[Check .oxlintrc.json<br/>and run pnpm lint:fix]
|
||||
R -->|ESLint| T[Check eslint.config.ts<br/>and run pnpm lint:fix]
|
||||
S --> L
|
||||
T --> L
|
||||
|
||||
C --> G{Tried quick fixes?}
|
||||
G -->|No| H[Run: pnpm i]
|
||||
G -->|Still stuck| I[Run: pnpm clean]
|
||||
I --> J{Still stuck?}
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
|
||||
J -->|No| L[Fixed!]
|
||||
H --> L
|
||||
|
||||
D --> M[Run: pnpm build]
|
||||
M --> N{Build succeeds?}
|
||||
N -->|No| O[Check error messages<br/>in FAQ]
|
||||
N -->|Yes| L
|
||||
|
||||
E --> H
|
||||
|
||||
F --> P[Search FAQ or<br/>ask in Discord]
|
||||
```
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### Development Server Issues
|
||||
|
||||
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Command hangs on "nx serve"
|
||||
- Dev server doesn't respond
|
||||
- Terminal appears frozen
|
||||
|
||||
**Solutions (try in order):**
|
||||
|
||||
1. **First attempt - Reinstall dependencies:**
|
||||
|
||||
```bash
|
||||
pnpm i
|
||||
```
|
||||
|
||||
2. **Second attempt - Clean build cache:**
|
||||
|
||||
```bash
|
||||
pnpm clean
|
||||
```
|
||||
|
||||
3. **Last resort - Full node_modules reset:**
|
||||
```bash
|
||||
pnpm dlx rimraf node_modules && pnpm i
|
||||
```
|
||||
|
||||
**Why this happens:**
|
||||
|
||||
- Corrupted dependency cache
|
||||
- Outdated lock files after branch switching
|
||||
- Incomplete previous installations
|
||||
- NX cache corruption
|
||||
|
||||
---
|
||||
|
||||
#### Q: Port conflicts - "Address already in use"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Error: `EADDRINUSE` or "port already in use"
|
||||
- Dev server fails to start
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Find and kill the process using the port:**
|
||||
|
||||
```bash
|
||||
# On Linux/Mac
|
||||
lsof -ti:5173 | xargs kill -9
|
||||
|
||||
# On Windows
|
||||
netstat -ano | findstr :5173
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
2. **Use a different port** by adding a `port` option to the `server` block in `vite.config.mts`:
|
||||
```ts
|
||||
server: {
|
||||
port: 3000,
|
||||
// ...existing config
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Build and Type Issues
|
||||
|
||||
#### Q: TypeScript errors after pulling latest changes
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Type errors in files you didn't modify
|
||||
- "Cannot find module" errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Rebuild TypeScript references:**
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
2. **Clean and reinstall:**
|
||||
|
||||
```bash
|
||||
pnpm clean && pnpm i
|
||||
```
|
||||
|
||||
3. **Restart your IDE's TypeScript server**
|
||||
- VS Code: `Cmd/Ctrl + Shift + P` → "TypeScript: Restart TS Server"
|
||||
|
||||
---
|
||||
|
||||
#### Q: "Workspace not found" or monorepo errors
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- pnpm can't find workspace packages
|
||||
- Import errors between packages
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Verify you're in the project root:**
|
||||
|
||||
```bash
|
||||
pwd # Should be in ComfyUI_frontend/
|
||||
```
|
||||
|
||||
2. **Rebuild workspace:**
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Linting Issues (oxlint)
|
||||
|
||||
#### Q: `eslint-disable` comment isn't suppressing an oxlint rule
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- `// eslint-disable-next-line rule-name` has no effect
|
||||
- Lint error persists despite the disable comment
|
||||
|
||||
**Solution:**
|
||||
|
||||
oxlint has its own disable syntax. Use `oxlint-disable` instead:
|
||||
|
||||
```ts
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log('debug')
|
||||
```
|
||||
|
||||
Check whether the rule is enforced by oxlint (in `.oxlintrc.json`) or ESLint (in `eslint.config.ts`) to pick the right disable comment.
|
||||
|
||||
---
|
||||
|
||||
#### Q: New lint errors after pulling/upgrading oxlint
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Lint errors in files you didn't change
|
||||
- Rules you haven't seen before (e.g. `no-immediate-mutation`, `prefer-optional-chain`)
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Run the auto-fixer first:**
|
||||
|
||||
```bash
|
||||
pnpm lint:fix
|
||||
```
|
||||
|
||||
2. **Review changes carefully** — some oxlint auto-fixes can produce incorrect code. Check the diff before committing.
|
||||
|
||||
3. **If a rule seems wrong**, check `.oxlintrc.json` to see if it should be disabled or configured differently.
|
||||
|
||||
**Why this happens:** oxlint version bumps often enable new rules by default.
|
||||
|
||||
---
|
||||
|
||||
#### Q: oxlint fails with TypeScript errors
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- `pnpm oxlint` or `pnpm lint` fails with type-related errors
|
||||
- Errors mention type resolution or missing type information
|
||||
|
||||
**Solution:**
|
||||
|
||||
oxlint runs with `--type-aware` in this project, which requires valid TypeScript compilation. Fix the TS errors first:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # Identify TS errors
|
||||
pnpm build # Or do a full build
|
||||
pnpm lint # Then re-run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Q: Duplicate lint errors from both oxlint and ESLint
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Same violation reported twice
|
||||
- Conflicting auto-fix suggestions
|
||||
|
||||
**Solution:**
|
||||
|
||||
The project uses `eslint-plugin-oxlint` to automatically disable ESLint rules that oxlint already covers (see `eslint.config.ts`). If you see duplicates:
|
||||
|
||||
1. Ensure `.oxlintrc.json` is up to date after adding new oxlint rules
|
||||
2. Run `pnpm lint` (which runs oxlint then ESLint in sequence) rather than running them individually
|
||||
|
||||
---
|
||||
|
||||
### Dependency and Package Issues
|
||||
|
||||
#### Q: "Package not found" after adding a dependency
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Module not found after `pnpm add`
|
||||
- Import errors for newly installed packages
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Ensure you installed in the correct workspace** (see `pnpm-workspace.yaml` for available workspaces):
|
||||
|
||||
```bash
|
||||
# Example: install in a specific workspace
|
||||
pnpm --filter <workspace-name> add <package>
|
||||
```
|
||||
|
||||
2. **Clear pnpm cache:**
|
||||
```bash
|
||||
pnpm store prune
|
||||
pnpm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Q: Lock file conflicts after merge/rebase
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Git conflicts in `pnpm-lock.yaml`
|
||||
- Dependency resolution errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Regenerate lock file:**
|
||||
|
||||
```bash
|
||||
rm pnpm-lock.yaml
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Or accept upstream lock file:**
|
||||
```bash
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
pnpm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Testing Issues
|
||||
|
||||
#### Q: Tests fail locally but pass in CI
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Flaky tests
|
||||
- Different results between local and CI
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Run tests in CI mode:**
|
||||
|
||||
```bash
|
||||
CI=true pnpm test:unit
|
||||
```
|
||||
|
||||
2. **Clear test cache:**
|
||||
|
||||
```bash
|
||||
pnpm test:unit --no-cache
|
||||
```
|
||||
|
||||
3. **Check Node version matches CI** (see `.nvmrc` for the required version):
|
||||
```bash
|
||||
node --version
|
||||
nvm use # If using nvm — reads .nvmrc automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Git and Branch Issues
|
||||
|
||||
#### Q: Changes from another branch appearing in my branch
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Uncommitted changes not related to your work
|
||||
- Dirty working directory
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Stash and reinstall:**
|
||||
|
||||
```bash
|
||||
git stash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Check for untracked files:**
|
||||
```bash
|
||||
git status
|
||||
git clean -fd # Careful: removes untracked files!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
1. **Search existing issues:** [GitHub Issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues)
|
||||
2. **Ask the community:** [Discord](https://discord.com/invite/comfyorg) (navigate to the `#dev-frontend` channel)
|
||||
3. **Create a new issue:** Include:
|
||||
- Your OS and Node version (`node --version`)
|
||||
- Steps to reproduce
|
||||
- Full error message
|
||||
- What you've already tried
|
||||
|
||||
## Contributing to This Guide
|
||||
|
||||
Found a solution to a common problem? Please:
|
||||
|
||||
1. Open a PR to add it to this guide
|
||||
2. Follow the FAQ format above
|
||||
3. Include the symptoms, solutions, and why it happens
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-03-10
|
||||
@@ -27,7 +27,7 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
|
||||
Ensure you have the Node.js version specified in `.nvmrc` installed.
|
||||
Then, set up the Chromium test driver:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
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: 29 KiB After Width: | Height: | Size: 29 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",
|
||||
|
||||
@@ -1420,15 +1420,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
font-size 0.1s ease;
|
||||
}
|
||||
|
||||
/* Performance optimization during canvas interaction */
|
||||
.transform-pane--interacting .lg-node * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.transform-pane--interacting .lg-node {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { computed, toValue } from 'vue'
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
@@ -23,6 +24,8 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
to?: string | HTMLElement
|
||||
itemClass?: string
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -44,7 +47,7 @@ const contentClass = computed(() =>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button size="icon">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
<i :class="icon ?? 'icon-[lucide--menu]'" />
|
||||
</Button>
|
||||
</slot>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,105 +1,283 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
class="max-w-96"
|
||||
size="lg"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<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"
|
||||
<Teleport defer to="#keybinding-panel-actions">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeybindingPresetToolbar
|
||||
:preset-names="presetNames"
|
||||
@presets-changed="refreshPresetList"
|
||||
/>
|
||||
<DropdownMenu
|
||||
:entries="menuEntries"
|
||||
icon="icon-[lucide--ellipsis]"
|
||||
item-class="text-sm gap-2"
|
||||
button-size="unset"
|
||||
button-class="size-10"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<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,29 +285,44 @@
|
||||
variant="destructive-textonly"
|
||||
@click="resetAllKeybindings"
|
||||
>
|
||||
<i class="pi pi-replay" />
|
||||
<i class="icon-[lucide--rotate-ccw]" />
|
||||
{{ $t('g.resetAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
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, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.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 { useKeybindingPresetService } from '@/platform/keybindings/presetService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
const filters = ref({
|
||||
@@ -138,12 +331,94 @@ const filters = ref({
|
||||
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybindingService = useKeybindingService()
|
||||
const presetService = useKeybindingPresetService()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const presetNames = ref<string[]>([])
|
||||
|
||||
async function refreshPresetList() {
|
||||
presetNames.value = (await presetService.listPresets()) ?? []
|
||||
}
|
||||
|
||||
async function initPresets() {
|
||||
await refreshPresetList()
|
||||
const currentName = settingStore.get('Comfy.Keybinding.CurrentPreset')
|
||||
if (currentName !== 'default') {
|
||||
const preset = await presetService.loadPreset(currentName)
|
||||
if (preset) {
|
||||
keybindingStore.savedPresetData = preset
|
||||
keybindingStore.currentPresetName = currentName
|
||||
} else {
|
||||
await presetService.switchToDefaultPreset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => initPresets())
|
||||
|
||||
// "..." menu entries (teleported to header)
|
||||
async function saveAsNewPreset() {
|
||||
await presetService.promptAndSaveNewPreset()
|
||||
refreshPresetList()
|
||||
}
|
||||
|
||||
async function handleDeletePreset() {
|
||||
await presetService.deletePreset(keybindingStore.currentPresetName)
|
||||
refreshPresetList()
|
||||
}
|
||||
|
||||
async function handleImportPreset() {
|
||||
await presetService.importPreset()
|
||||
refreshPresetList()
|
||||
}
|
||||
|
||||
const showSaveAsNew = computed(
|
||||
() =>
|
||||
keybindingStore.currentPresetName !== 'default' ||
|
||||
keybindingStore.isCurrentPresetModified
|
||||
)
|
||||
|
||||
const menuEntries = computed<MenuItem[]>(() => [
|
||||
...(showSaveAsNew.value
|
||||
? [
|
||||
{
|
||||
label: t('g.keybindingPresets.saveAsNewPreset'),
|
||||
icon: 'icon-[lucide--save]',
|
||||
command: saveAsNewPreset
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('g.keybindingPresets.resetToDefault'),
|
||||
icon: 'icon-[lucide--rotate-cw]',
|
||||
command: () =>
|
||||
presetService.switchPreset('default').then(() => refreshPresetList())
|
||||
},
|
||||
{
|
||||
label: t('g.keybindingPresets.deletePreset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
disabled: keybindingStore.currentPresetName === 'default',
|
||||
command: handleDeletePreset
|
||||
},
|
||||
{
|
||||
label: t('g.keybindingPresets.importPreset'),
|
||||
icon: 'icon-[lucide--file-input]',
|
||||
command: handleImportPreset
|
||||
},
|
||||
{
|
||||
label: t('g.keybindingPresets.exportPreset'),
|
||||
icon: 'icon-[lucide--file-output]',
|
||||
command: () => presetService.exportPreset()
|
||||
}
|
||||
])
|
||||
|
||||
// Keybinding table logic
|
||||
interface ICommandData {
|
||||
id: string
|
||||
keybinding: KeybindingImpl | null
|
||||
keybindings: KeybindingImpl[]
|
||||
label: string
|
||||
source?: string
|
||||
}
|
||||
@@ -155,31 +430,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 +599,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>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button v-if="showSaveButton" size="lg" @click="handleSavePreset">
|
||||
{{ $t('g.keybindingPresets.saveChanges') }}
|
||||
</Button>
|
||||
<Select v-model="selectedPreset">
|
||||
<SelectTrigger class="w-64">
|
||||
<SelectValue :placeholder="$t('g.keybindingPresets.default')">
|
||||
{{ displayLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
|
||||
<div class="max-w-60">
|
||||
<SelectItem
|
||||
value="default"
|
||||
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
|
||||
>
|
||||
{{ $t('g.keybindingPresets.default') }}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="name in presetNames"
|
||||
:key="name"
|
||||
:value="name"
|
||||
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
|
||||
>
|
||||
{{ name }}
|
||||
</SelectItem>
|
||||
<hr class="h-px max-w-60 border border-border-default" />
|
||||
<button
|
||||
class="relative flex w-full max-w-60 cursor-pointer items-center justify-between gap-3 rounded-sm border-none bg-transparent p-2 text-sm outline-none select-none hover:bg-secondary-background-hover focus:bg-secondary-background-hover"
|
||||
@click.stop="handleImportFromDropdown"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ $t('g.keybindingPresets.importKeybindingPreset') }}
|
||||
</span>
|
||||
<i
|
||||
class="icon-[lucide--file-input] shrink-0 text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
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 { useKeybindingPresetService } from '@/platform/keybindings/presetService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
|
||||
const { presetNames } = defineProps<{
|
||||
presetNames: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'presets-changed': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const presetService = useKeybindingPresetService()
|
||||
|
||||
const selectedPreset = ref(keybindingStore.currentPresetName)
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
const name =
|
||||
selectedPreset.value === 'default'
|
||||
? t('g.keybindingPresets.default')
|
||||
: selectedPreset.value
|
||||
return keybindingStore.isCurrentPresetModified ? `${name} *` : name
|
||||
})
|
||||
|
||||
watch(selectedPreset, async (newValue) => {
|
||||
if (newValue !== keybindingStore.currentPresetName) {
|
||||
await presetService.switchPreset(newValue)
|
||||
selectedPreset.value = keybindingStore.currentPresetName
|
||||
emit('presets-changed')
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => keybindingStore.currentPresetName,
|
||||
(name) => {
|
||||
selectedPreset.value = name
|
||||
}
|
||||
)
|
||||
|
||||
const showSaveButton = computed(
|
||||
() =>
|
||||
keybindingStore.currentPresetName !== 'default' &&
|
||||
keybindingStore.isCurrentPresetModified
|
||||
)
|
||||
|
||||
async function handleSavePreset() {
|
||||
await presetService.savePreset(keybindingStore.currentPresetName)
|
||||
}
|
||||
|
||||
async function handleImportFromDropdown() {
|
||||
await presetService.importPreset()
|
||||
emit('presets-changed')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[420px] flex-col border-t border-border-default"
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('g.keybindingPresets.unsavedChangesMessage') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="text-muted-foreground"
|
||||
@click="onResult(null)"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="bg-secondary-background"
|
||||
@click="onResult(false)"
|
||||
>
|
||||
{{ $t('g.keybindingPresets.discardAndSwitch') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="bg-base-foreground text-base-background"
|
||||
@click="onResult(true)"
|
||||
>
|
||||
{{ $t('g.keybindingPresets.saveAndSwitch') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { onResult } = defineProps<{
|
||||
onResult: (result: boolean | null) => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center p-4">
|
||||
<p class="m-0 text-sm font-medium">
|
||||
{{ $t('g.keybindingPresets.unsavedChangesTo', { name: presetName }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { presetName } = defineProps<{
|
||||
presetName: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -81,7 +81,6 @@
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
/>
|
||||
</TransformPane>
|
||||
@@ -364,7 +363,14 @@ 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).
|
||||
*/
|
||||
watch(
|
||||
() =>
|
||||
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
|
||||
@@ -382,8 +388,7 @@ watch(
|
||||
|
||||
// Force canvas redraw to ensure progress updates are visible
|
||||
canvas.setDirty(true, false)
|
||||
},
|
||||
{ deep: true }
|
||||
}
|
||||
)
|
||||
|
||||
// Update node slot errors for LiteGraph nodes
|
||||
|
||||
@@ -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>
|
||||
@@ -204,7 +204,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
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
@@ -57,6 +58,11 @@ export function promoteWidget(
|
||||
for (const parent of parents) {
|
||||
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
}
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Promoted widget "${widgetName}" on node ${node.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
@@ -72,6 +78,11 @@ export function demoteWidget(
|
||||
for (const parent of parents) {
|
||||
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
}
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Demoted widget "${widgetName}" on node ${node.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
function getParentNodes(): SubgraphNode[] {
|
||||
@@ -304,4 +315,9 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
363
src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphGroup,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
batchUpdateNodeBounds: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
el.height = 600
|
||||
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} satisfies Partial<CanvasRenderingContext2D>
|
||||
|
||||
el.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
||||
el.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
return new LGraphCanvas(el, graph, { skip_render: true })
|
||||
}
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
constructor() {
|
||||
super('test')
|
||||
}
|
||||
}
|
||||
|
||||
describe('LGraphCanvas group selection', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let group: LGraphGroup
|
||||
let nodeA: TestNode
|
||||
let nodeB: TestNode
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = createCanvas(graph)
|
||||
|
||||
group = new LGraphGroup('TestGroup')
|
||||
group._bounding.set([0, 0, 500, 500])
|
||||
graph.add(group)
|
||||
|
||||
nodeA = new TestNode()
|
||||
nodeA.pos = [50, 50]
|
||||
graph.add(nodeA)
|
||||
|
||||
nodeB = new TestNode()
|
||||
nodeB.pos = [100, 100]
|
||||
graph.add(nodeB)
|
||||
|
||||
group.recomputeInsideNodes()
|
||||
})
|
||||
|
||||
describe('select with groupSelectChildren enabled', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = true
|
||||
})
|
||||
|
||||
it('selects all children when selecting a group', () => {
|
||||
canvas.select(group)
|
||||
|
||||
expect(group.selected).toBe(true)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(group)).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeA)).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeB)).toBe(true)
|
||||
})
|
||||
|
||||
it('recursively selects nested group children', () => {
|
||||
const innerGroup = new LGraphGroup('InnerGroup')
|
||||
innerGroup._bounding.set([40, 40, 200, 200])
|
||||
graph.add(innerGroup)
|
||||
|
||||
const innerNode = new TestNode()
|
||||
innerNode.pos = [60, 60]
|
||||
graph.add(innerNode)
|
||||
|
||||
innerGroup.recomputeInsideNodes()
|
||||
group.recomputeInsideNodes()
|
||||
|
||||
canvas.select(group)
|
||||
|
||||
expect(innerGroup.selected).toBe(true)
|
||||
expect(innerNode.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(innerGroup)).toBe(true)
|
||||
expect(canvas.selectedItems.has(innerNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('selects descendants of already-selected nested groups', () => {
|
||||
const innerGroup = new LGraphGroup('InnerGroup')
|
||||
innerGroup._bounding.set([40, 40, 200, 200])
|
||||
graph.add(innerGroup)
|
||||
|
||||
const innerNode = new TestNode()
|
||||
innerNode.pos = [60, 60]
|
||||
graph.add(innerNode)
|
||||
|
||||
innerGroup.recomputeInsideNodes()
|
||||
group.recomputeInsideNodes()
|
||||
|
||||
// Pre-select the inner group before selecting the outer group
|
||||
canvas.select(innerGroup)
|
||||
expect(innerGroup.selected).toBe(true)
|
||||
expect(innerNode.selected).toBeFalsy()
|
||||
|
||||
canvas.select(group)
|
||||
|
||||
expect(innerNode.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(innerNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles deeply nested groups (depth 5)', () => {
|
||||
const groups: LGraphGroup[] = [group]
|
||||
const nodes: TestNode[] = [nodeA, nodeB]
|
||||
|
||||
for (let depth = 1; depth <= 5; depth++) {
|
||||
const offset = depth * 10
|
||||
const size = 500 - depth * 20
|
||||
|
||||
const nestedGroup = new LGraphGroup(`Depth${depth}`)
|
||||
nestedGroup._bounding.set([offset, offset, size, size])
|
||||
graph.add(nestedGroup)
|
||||
groups.push(nestedGroup)
|
||||
|
||||
const nestedNode = new TestNode()
|
||||
nestedNode.pos = [offset + 5, offset + 5]
|
||||
graph.add(nestedNode)
|
||||
nodes.push(nestedNode)
|
||||
}
|
||||
|
||||
// Recompute from innermost to outermost
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
groups[i].recomputeInsideNodes()
|
||||
}
|
||||
|
||||
canvas.select(group)
|
||||
|
||||
for (const g of groups) {
|
||||
expect(g.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(g)).toBe(true)
|
||||
}
|
||||
for (const n of nodes) {
|
||||
expect(n.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(n)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('select with groupSelectChildren disabled', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = false
|
||||
})
|
||||
|
||||
it('does not select children when selecting a group', () => {
|
||||
canvas.select(group)
|
||||
|
||||
expect(group.selected).toBe(true)
|
||||
expect(nodeA.selected).toBeFalsy()
|
||||
expect(nodeB.selected).toBeFalsy()
|
||||
expect(canvas.selectedItems.has(group)).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeA)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deselect with groupSelectChildren enabled', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = true
|
||||
})
|
||||
|
||||
it('deselects all children when deselecting a group', () => {
|
||||
canvas.select(group)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
|
||||
canvas.deselect(group)
|
||||
|
||||
expect(group.selected).toBe(false)
|
||||
expect(nodeA.selected).toBe(false)
|
||||
expect(nodeB.selected).toBe(false)
|
||||
expect(canvas.selectedItems.has(group)).toBe(false)
|
||||
expect(canvas.selectedItems.has(nodeA)).toBe(false)
|
||||
})
|
||||
|
||||
it('recursively deselects nested group children', () => {
|
||||
const innerGroup = new LGraphGroup('InnerGroup')
|
||||
innerGroup._bounding.set([40, 40, 200, 200])
|
||||
graph.add(innerGroup)
|
||||
|
||||
const innerNode = new TestNode()
|
||||
innerNode.pos = [60, 60]
|
||||
graph.add(innerNode)
|
||||
|
||||
innerGroup.recomputeInsideNodes()
|
||||
group.recomputeInsideNodes()
|
||||
|
||||
canvas.select(group)
|
||||
expect(innerNode.selected).toBe(true)
|
||||
|
||||
canvas.deselect(group)
|
||||
|
||||
expect(innerGroup.selected).toBe(false)
|
||||
expect(innerNode.selected).toBe(false)
|
||||
})
|
||||
|
||||
it('handles deeply nested deselection (depth 5)', () => {
|
||||
const groups: LGraphGroup[] = [group]
|
||||
const nodes: TestNode[] = [nodeA, nodeB]
|
||||
|
||||
for (let depth = 1; depth <= 5; depth++) {
|
||||
const offset = depth * 10
|
||||
const size = 500 - depth * 20
|
||||
|
||||
const nestedGroup = new LGraphGroup(`Depth${depth}`)
|
||||
nestedGroup._bounding.set([offset, offset, size, size])
|
||||
graph.add(nestedGroup)
|
||||
groups.push(nestedGroup)
|
||||
|
||||
const nestedNode = new TestNode()
|
||||
nestedNode.pos = [offset + 5, offset + 5]
|
||||
graph.add(nestedNode)
|
||||
nodes.push(nestedNode)
|
||||
}
|
||||
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
groups[i].recomputeInsideNodes()
|
||||
}
|
||||
|
||||
canvas.select(group)
|
||||
canvas.deselect(group)
|
||||
|
||||
for (const g of groups) {
|
||||
expect(g.selected).toBe(false)
|
||||
expect(canvas.selectedItems.has(g)).toBe(false)
|
||||
}
|
||||
for (const n of nodes) {
|
||||
expect(n.selected).toBe(false)
|
||||
expect(canvas.selectedItems.has(n)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('processSelect modifier-click deselect', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = true
|
||||
})
|
||||
|
||||
it('modifier-click deselects only the group, not its children', () => {
|
||||
canvas.select(group)
|
||||
expect(group.selected).toBe(true)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
|
||||
const shiftEvent = { shiftKey: true } as CanvasPointerEvent
|
||||
canvas.processSelect(group, shiftEvent)
|
||||
|
||||
expect(group.selected).toBe(false)
|
||||
expect(canvas.selectedItems.has(group)).toBe(false)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeA)).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeB)).toBe(true)
|
||||
})
|
||||
|
||||
it('ctrl-click deselects only the group, not its children', () => {
|
||||
canvas.select(group)
|
||||
|
||||
const ctrlEvent = { ctrlKey: true } as CanvasPointerEvent
|
||||
canvas.processSelect(group, ctrlEvent)
|
||||
|
||||
expect(group.selected).toBe(false)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deselect with groupSelectChildren disabled', () => {
|
||||
it('does not deselect children when deselecting a group', () => {
|
||||
canvas.groupSelectChildren = true
|
||||
canvas.select(group)
|
||||
|
||||
canvas.groupSelectChildren = false
|
||||
canvas.deselect(group)
|
||||
|
||||
expect(group.selected).toBe(false)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSelected with groupSelectChildren enabled', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = true
|
||||
// Attach canvas to DOM so checkPanels() can query parentNode
|
||||
document.body.appendChild(canvas.canvas)
|
||||
})
|
||||
|
||||
it('deletes group and all selected children', () => {
|
||||
canvas.select(group)
|
||||
|
||||
expect(canvas.selectedItems.size).toBeGreaterThan(1)
|
||||
|
||||
canvas.deleteSelected()
|
||||
|
||||
expect(graph.nodes).not.toContain(nodeA)
|
||||
expect(graph.nodes).not.toContain(nodeB)
|
||||
expect(graph.groups).not.toContain(group)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -565,6 +565,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
allow_dragnodes: boolean
|
||||
allow_interaction: boolean
|
||||
multi_select: boolean
|
||||
groupSelectChildren: boolean
|
||||
allow_searchbox: boolean
|
||||
allow_reconnect_links: boolean
|
||||
align_to_grid: boolean
|
||||
@@ -933,6 +934,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.allow_interaction = true
|
||||
// allow selecting multi nodes without pressing extra keys
|
||||
this.multi_select = false
|
||||
this.groupSelectChildren = false
|
||||
this.allow_searchbox = true
|
||||
// allows to change a connection with having to redo it again
|
||||
this.allow_reconnect_links = true
|
||||
@@ -1951,6 +1953,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return false
|
||||
}
|
||||
|
||||
/** Prevents default for middle-click auxclick only. */
|
||||
_preventMiddleAuxClick(e: MouseEvent): void {
|
||||
if (e.button === 1) e.preventDefault()
|
||||
}
|
||||
|
||||
/** Captures an event and prevents default - returns true. */
|
||||
_doReturnTrue(e: Event): boolean {
|
||||
e.preventDefault()
|
||||
@@ -1986,6 +1993,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
canvas.addEventListener('pointercancel', this._mousecancel_callback, true)
|
||||
|
||||
canvas.addEventListener('contextmenu', this._doNothing)
|
||||
// Prevent middle-click paste (PRIMARY clipboard on Linux) - fixes #4464
|
||||
canvas.addEventListener('auxclick', this._preventMiddleAuxClick)
|
||||
|
||||
// Keyboard
|
||||
this._key_callback = this.processKey.bind(this)
|
||||
@@ -2024,6 +2033,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
canvas.removeEventListener('keydown', this._key_callback!)
|
||||
document.removeEventListener('keyup', this._key_callback!)
|
||||
canvas.removeEventListener('contextmenu', this._doNothing)
|
||||
canvas.removeEventListener('auxclick', this._preventMiddleAuxClick)
|
||||
canvas.removeEventListener('dragenter', this._doReturnTrue)
|
||||
|
||||
this._mousedown_callback = undefined
|
||||
@@ -4363,7 +4373,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!modifySelection) this.deselectAll(item)
|
||||
this.select(item)
|
||||
} else if (modifySelection && !sticky) {
|
||||
this.deselect(item)
|
||||
// Modifier-click toggles only the clicked item, not its children.
|
||||
// Cascade on select is a convenience; cascade on deselect would
|
||||
// remove the user's ability to keep children selected (e.g. for
|
||||
// deletion) after toggling the group off.
|
||||
if (item instanceof LGraphGroup && this.groupSelectChildren) {
|
||||
item.selected = false
|
||||
this.selectedItems.delete(item)
|
||||
this.state.selectionChanged = true
|
||||
} else {
|
||||
this.deselect(item)
|
||||
}
|
||||
} else if (!sticky) {
|
||||
this.deselectAll(item)
|
||||
} else {
|
||||
@@ -4388,6 +4408,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (item instanceof LGraphGroup) {
|
||||
item.recomputeInsideNodes()
|
||||
if (this.groupSelectChildren) {
|
||||
this.#traverseGroupChildren(
|
||||
item,
|
||||
(child) => {
|
||||
if (!child.selected || !this.selectedItems.has(child)) {
|
||||
child.selected = true
|
||||
this.selectedItems.add(child)
|
||||
this.state.selectionChanged = true
|
||||
}
|
||||
},
|
||||
(child) => this.select(child)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4426,6 +4459,22 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
item.selected = false
|
||||
this.selectedItems.delete(item)
|
||||
this.state.selectionChanged = true
|
||||
|
||||
if (item instanceof LGraphGroup && this.groupSelectChildren) {
|
||||
this.#traverseGroupChildren(
|
||||
item,
|
||||
(child) => {
|
||||
if (child.selected || this.selectedItems.has(child)) {
|
||||
child.selected = false
|
||||
this.selectedItems.delete(child)
|
||||
this.state.selectionChanged = true
|
||||
}
|
||||
},
|
||||
(child) => this.deselect(child)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!(item instanceof LGraphNode)) return
|
||||
|
||||
// Node-specific handling
|
||||
@@ -4461,6 +4510,29 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterative traversal of a group's descendants.
|
||||
* Calls {@link groupAction} on nested groups and {@link leafAction} on
|
||||
* non-group children. Always recurses into nested groups regardless of
|
||||
* their current selection state.
|
||||
*/
|
||||
#traverseGroupChildren(
|
||||
group: LGraphGroup,
|
||||
groupAction: (child: LGraphGroup) => void,
|
||||
leafAction: (child: Positionable) => void
|
||||
): void {
|
||||
const stack: Positionable[] = [...group._children]
|
||||
while (stack.length > 0) {
|
||||
const child = stack.pop()!
|
||||
if (child instanceof LGraphGroup) {
|
||||
groupAction(child)
|
||||
for (const nested of child._children) stack.push(nested)
|
||||
} else {
|
||||
leafAction(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated See {@link LGraphCanvas.processSelect} */
|
||||
processNodeSelected(item: LGraphNode, e: CanvasPointerEvent): void {
|
||||
this.processSelect(
|
||||
@@ -4593,7 +4665,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.emitBeforeChange()
|
||||
graph.beforeChange()
|
||||
|
||||
for (const item of this.selectedItems) {
|
||||
// Snapshot to prevent mutation during iteration (e.g. group deselect cascade)
|
||||
const toDelete = [...this.selectedItems]
|
||||
for (const item of toDelete) {
|
||||
if (item instanceof LGraphNode) {
|
||||
const node = item
|
||||
if (node.block_delete) continue
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphEventMode,
|
||||
ExecutableNodeDTO
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
@@ -249,6 +252,136 @@ describe.skip('ExecutableNodeDTO Output Resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Muted node output resolution', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('should return undefined for NEVER mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Muted Node')
|
||||
node.addOutput('out', 'string')
|
||||
node.mode = LGraphEventMode.NEVER
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
const resolved = dto.resolveOutput(0, 'string', new Set())
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for muted subgraph nodes without throwing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }],
|
||||
nodeCount: 1
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
subgraphNode.mode = LGraphEventMode.NEVER
|
||||
|
||||
// Empty map simulates executionUtil skipping getInnerNodes() for muted nodes
|
||||
const nodesByExecutionId = new Map()
|
||||
const dto = new ExecutableNodeDTO(
|
||||
subgraphNode,
|
||||
[],
|
||||
nodesByExecutionId,
|
||||
undefined
|
||||
)
|
||||
nodesByExecutionId.set(dto.id, dto)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should resolve undefined when input is connected to a muted node', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const mutedNode = new LGraphNode('Muted Node')
|
||||
mutedNode.addOutput('result', 'IMAGE')
|
||||
mutedNode.mode = LGraphEventMode.NEVER
|
||||
graph.add(mutedNode)
|
||||
|
||||
const downstreamNode = new LGraphNode('Downstream')
|
||||
downstreamNode.addInput('input', 'IMAGE')
|
||||
graph.add(downstreamNode)
|
||||
|
||||
mutedNode.connect(0, downstreamNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const mutedDto = new ExecutableNodeDTO(mutedNode, [], nodeDtoMap, undefined)
|
||||
nodeDtoMap.set(mutedDto.id, mutedDto)
|
||||
|
||||
const downstreamDto = new ExecutableNodeDTO(
|
||||
downstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(downstreamDto.id, downstreamDto)
|
||||
|
||||
const resolved = downstreamDto.resolveInput(0)
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bypass node output resolution', () => {
|
||||
it('should still resolve bypass for BYPASS mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('in', 'IMAGE')
|
||||
bypassedNode.addOutput('out', 'IMAGE')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALWAYS mode node output resolution', () => {
|
||||
it('should attempt normal resolution for ALWAYS mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Normal Node')
|
||||
node.addOutput('out', 'IMAGE')
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.add(node)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const dto = new ExecutableNodeDTO(node, [], nodeDtoMap, undefined)
|
||||
nodeDtoMap.set(dto.id, dto)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(dto)
|
||||
expect(resolved?.origin_slot).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Properties', () => {
|
||||
it('should provide access to basic properties', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
@@ -266,6 +266,9 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
}
|
||||
visited.add(uniqueId)
|
||||
|
||||
// Muted nodes produce no output
|
||||
if (this.mode === LGraphEventMode.NEVER) return
|
||||
|
||||
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
|
||||
if (this.mode === LGraphEventMode.BYPASS) {
|
||||
// Bypass nodes by finding first input with matching type
|
||||
|
||||
@@ -862,7 +862,7 @@
|
||||
"promptExecutionError": "فشل تنفيذ الطلب"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} أخطاء | {count} خطأ | {count} أخطاء",
|
||||
"errorCount": "{count} خطأ | {count} أخطاء",
|
||||
"seeErrors": "عرض الأخطاء"
|
||||
},
|
||||
"essentials": {
|
||||
@@ -909,7 +909,7 @@
|
||||
"downloadFailed": "فشل تحميل \"{name}\"",
|
||||
"exportCompleted": "تحميل ZIP جاهز",
|
||||
"exportError": "فشل التصدير",
|
||||
"exportFailed": "{count} فشل في التصدير | {count} فشل في التصدير | {count} عمليات تصدير فشلت",
|
||||
"exportFailed": "{count} فشل في التصدير | {count} عمليات تصدير فشلت",
|
||||
"exportFailedSingle": "فشل إنشاء تصدير ZIP",
|
||||
"exportStarted": "يتم تجهيز تحميل ZIP...",
|
||||
"exportingAssets": "جاري تصدير العناصر",
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "الكمية",
|
||||
"apply": "تطبيق",
|
||||
"architecture": "الهندسة المعمارية",
|
||||
"asset": "{count} أصل | {count} أصل | {count} أصول",
|
||||
"asset": "{count} أصل | {count} أصول",
|
||||
"audioFailedToLoad": "فشل تحميل الصوت",
|
||||
"audioProgress": "تقدم الصوت",
|
||||
"author": "المؤلف",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "خطأ في فتحات العقدة",
|
||||
"nodeWidgetsError": "خطأ في عناصر واجهة العقدة",
|
||||
"nodes": "العُقَد",
|
||||
"nodesCount": "{count} عقدة | {count} عقدة | {count} عقدة",
|
||||
"nodesCount": "{count} عقدة | {count} عقدة",
|
||||
"nodesRunning": "العُقَد قيد التشغيل",
|
||||
"none": "لا شيء",
|
||||
"nothingToCopy": "لا يوجد ما يمكن نسخه",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "اكتملت جميع التنزيلات",
|
||||
"downloadingModel": "جاري تنزيل النموذج...",
|
||||
"downloadsFailed": "{count} فشل في التنزيل | {count} فشل في التنزيل | {count} فشل في التنزيل",
|
||||
"downloadsFailed": "{count} فشل في التنزيل | {count} فشل في التنزيل",
|
||||
"failed": "فشل",
|
||||
"filter": {
|
||||
"all": "الكل",
|
||||
|
||||
@@ -11306,6 +11306,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Painter": {
|
||||
"display_name": "الرسام",
|
||||
"inputs": {
|
||||
"bg_color": {
|
||||
"name": "لون الخلفية"
|
||||
},
|
||||
"height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"image": {
|
||||
"name": "الصورة",
|
||||
"tooltip": "صورة أساسية اختيارية للرسم فوقها"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PairConditioningCombine": {
|
||||
"display_name": "دمج زوج الشرط",
|
||||
"inputs": {
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
"resetAll": "Reset All",
|
||||
"clearFilters": "Clear Filters",
|
||||
"resetAllKeybindingsTooltip": "Reset all keybindings to default",
|
||||
"resetAllKeybindingsTitle": "Reset all keybindings?",
|
||||
"resetAllKeybindingsMessage": "All keybindings will be reset to their default values. This cannot be undone.",
|
||||
"allKeybindingsReset": "All keybindings reset to default",
|
||||
"removeAllKeybindingsTitle": "Remove all keybindings?",
|
||||
"removeAllKeybindingsMessage": "All keybindings for this command will be removed",
|
||||
"removeAll": "Remove all",
|
||||
"changeKeybinding": "Change keybinding",
|
||||
"addNewKeybinding": "Add new keybinding",
|
||||
"resetToDefault": "Reset to default",
|
||||
"removeKeybinding": "Remove keybinding",
|
||||
"nMoreKeybindings": "+ {count} more",
|
||||
"customizeFolder": "Customize Folder",
|
||||
"icon": "Icon",
|
||||
"color": "Color",
|
||||
@@ -271,6 +282,32 @@
|
||||
"browserReservedKeybinding": "This shortcut is reserved by some browsers and may have unexpected results.",
|
||||
"browserReservedKeybindingTooltip": "This shortcut conflicts with browser-reserved shortcuts",
|
||||
"keybindingAlreadyExists": "Keybinding already exists on",
|
||||
"keybindingPresets": {
|
||||
"importPreset": "Import preset",
|
||||
"importKeybindingPreset": "Import keybinding preset",
|
||||
"exportPreset": "Export preset",
|
||||
"saveChanges": "Save Changes",
|
||||
"saveAsNewPreset": "Save as new preset",
|
||||
"resetToDefault": "Reset to default",
|
||||
"deletePreset": "Delete preset",
|
||||
"unsavedChangesTo": "Unsaved changes to {name}",
|
||||
"unsavedChangesMessage": "You have unsaved changes that will be lost if you switch without saving.",
|
||||
"discardAndSwitch": "Discard and Switch",
|
||||
"saveAndSwitch": "Save and Switch",
|
||||
"deletePresetTitle": "Delete the current preset?",
|
||||
"deletePresetWarning": "This preset will be deleted. This cannot be undone.",
|
||||
"presetSaved": "Preset \"{name}\" saved",
|
||||
"presetDeleted": "Preset \"{name}\" deleted",
|
||||
"presetImported": "Keybinding preset imported",
|
||||
"invalidPresetFile": "Preset file must be valid JSON exported from ComfyUI",
|
||||
"invalidPresetName": "Preset name must not be empty, \"default\", start with a dot, contain path separators, or end with .json",
|
||||
"loadPresetFailed": "Failed to load preset \"{name}\"",
|
||||
"deletePresetFailed": "Failed to delete preset \"{name}\"",
|
||||
"overwritePresetTitle": "Overwrite Preset",
|
||||
"overwritePresetMessage": "A preset named \"{name}\" already exists. Overwrite it?",
|
||||
"presetNamePrompt": "Enter a name for the preset",
|
||||
"default": "Default Preset"
|
||||
},
|
||||
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
|
||||
"startRecording": "Start Recording",
|
||||
"stopRecording": "Stop Recording",
|
||||
@@ -496,7 +533,12 @@
|
||||
"cyan": "Cyan",
|
||||
"purple": "Purple",
|
||||
"black": "Black",
|
||||
"custom": "Custom"
|
||||
"custom": "Custom",
|
||||
"hex": "Hex",
|
||||
"rgba": "RGBA",
|
||||
"saturationBrightness": "Color saturation and brightness",
|
||||
"hue": "Hue",
|
||||
"alpha": "Alpha"
|
||||
},
|
||||
"contextMenu": {
|
||||
"Inputs": "Inputs",
|
||||
@@ -2511,7 +2553,7 @@
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
|
||||
"message": "Workspaces create a new credit pool that can be shared among members. You'll become the owner after creating this.",
|
||||
"nameLabel": "Workspace name*",
|
||||
"namePlaceholder": "Enter workspace name",
|
||||
"create": "Create"
|
||||
|
||||
@@ -11306,6 +11306,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Painter": {
|
||||
"display_name": "Painter",
|
||||
"inputs": {
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"bg_color": {
|
||||
"name": "bg_color"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Optional base image to paint over"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PairConditioningCombine": {
|
||||
"display_name": "Cond Pair Combine",
|
||||
"inputs": {
|
||||
|
||||
@@ -862,7 +862,7 @@
|
||||
"promptExecutionError": "La ejecución del prompt falló"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRORES | {count} ERROR | {count} ERRORES",
|
||||
"errorCount": "{count} ERROR | {count} ERRORES",
|
||||
"seeErrors": "Ver errores"
|
||||
},
|
||||
"essentials": {
|
||||
@@ -909,7 +909,7 @@
|
||||
"downloadFailed": "No se pudo descargar \"{name}\"",
|
||||
"exportCompleted": "Descarga ZIP lista",
|
||||
"exportError": "Error en la exportación",
|
||||
"exportFailed": "{count} exportación fallida | {count} exportación fallida | {count} exportaciones fallidas",
|
||||
"exportFailed": "{count} exportación fallida | {count} exportaciones fallidas",
|
||||
"exportFailedSingle": "No se pudo crear la exportación ZIP",
|
||||
"exportStarted": "Preparando descarga ZIP...",
|
||||
"exportingAssets": "Exportando recursos",
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "Cantidad",
|
||||
"apply": "Aplicar",
|
||||
"architecture": "Arquitectura",
|
||||
"asset": "{count} recursos | {count} recurso | {count} recursos",
|
||||
"asset": "{count} recurso | {count} recursos",
|
||||
"audioFailedToLoad": "No se pudo cargar el audio",
|
||||
"audioProgress": "Progreso de audio",
|
||||
"author": "Autor",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "Error de Ranuras del Nodo",
|
||||
"nodeWidgetsError": "Error de Widgets del Nodo",
|
||||
"nodes": "Nodos",
|
||||
"nodesCount": "{count} nodos | {count} nodo | {count} nodos",
|
||||
"nodesCount": "{count} nodo | {count} nodos",
|
||||
"nodesRunning": "nodos en ejecución",
|
||||
"none": "Ninguno",
|
||||
"nothingToCopy": "Nada para copiar",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "Todas las descargas completadas",
|
||||
"downloadingModel": "Descargando modelo...",
|
||||
"downloadsFailed": "{count} descargas fallidas | {count} descarga fallida | {count} descargas fallidas",
|
||||
"downloadsFailed": "{count} descarga fallida | {count} descargas fallidas",
|
||||
"failed": "Fallido",
|
||||
"filter": {
|
||||
"all": "Todo",
|
||||
|
||||
@@ -11306,6 +11306,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Painter": {
|
||||
"display_name": "Pintor",
|
||||
"inputs": {
|
||||
"bg_color": {
|
||||
"name": "color_de_fondo"
|
||||
},
|
||||
"height": {
|
||||
"name": "alto"
|
||||
},
|
||||
"image": {
|
||||
"name": "imagen",
|
||||
"tooltip": "Imagen base opcional para pintar encima"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
},
|
||||
"width": {
|
||||
"name": "ancho"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PairConditioningCombine": {
|
||||
"display_name": "CombinarCondPares",
|
||||
"inputs": {
|
||||
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "مقدار",
|
||||
"apply": "اعمال",
|
||||
"architecture": "معماری",
|
||||
"asset": "{count} دارایی | {count} دارایی | {count} دارایی",
|
||||
"asset": "{count} دارایی | {count} دارایی",
|
||||
"audioFailedToLoad": "بارگذاری صوت ناموفق بود",
|
||||
"audioProgress": "پیشرفت صوت",
|
||||
"author": "نویسنده",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "خطا در slotهای node",
|
||||
"nodeWidgetsError": "خطا در ابزارکهای node",
|
||||
"nodes": "nodeها",
|
||||
"nodesCount": "{count} نود | {count} نود | {count} نود",
|
||||
"nodesCount": "{count} نود | {count} نود",
|
||||
"nodesRunning": "nodeها در حال اجرا هستند",
|
||||
"none": "هیچکدام",
|
||||
"nothingToCopy": "موردی برای کپی وجود ندارد",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "همه دانلودها تکمیل شدند",
|
||||
"downloadingModel": "در حال دانلود مدل...",
|
||||
"downloadsFailed": "{count} دانلود ناموفق بود | {count} دانلود ناموفق بود | {count} دانلود ناموفق بودند",
|
||||
"downloadsFailed": "{count} دانلود ناموفق بود | {count} دانلود ناموفق بودند",
|
||||
"failed": "ناموفق",
|
||||
"filter": {
|
||||
"all": "همه",
|
||||
|
||||
@@ -11306,6 +11306,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Painter": {
|
||||
"display_name": "نقاش",
|
||||
"inputs": {
|
||||
"bg_color": {
|
||||
"name": "رنگ پسزمینه"
|
||||
},
|
||||
"height": {
|
||||
"name": "ارتفاع"
|
||||
},
|
||||
"image": {
|
||||
"name": "تصویر",
|
||||
"tooltip": "تصویر پایه اختیاری برای نقاشی روی آن"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
},
|
||||
"width": {
|
||||
"name": "عرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PairConditioningCombine": {
|
||||
"display_name": "ترکیب جفت شرطی",
|
||||
"inputs": {
|
||||
|
||||
@@ -862,7 +862,7 @@
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERREURS | {count} ERREUR | {count} ERREURS",
|
||||
"errorCount": "{count} ERREUR | {count} ERREURS",
|
||||
"seeErrors": "Voir les erreurs"
|
||||
},
|
||||
"essentials": {
|
||||
@@ -909,7 +909,7 @@
|
||||
"downloadFailed": "Échec du téléchargement de « {name} »",
|
||||
"exportCompleted": "Téléchargement ZIP prêt",
|
||||
"exportError": "Échec de l’exportation",
|
||||
"exportFailed": "{count} exportation échouée | {count} exportation échouée | {count} exportations échouées",
|
||||
"exportFailed": "{count} exportation échouée | {count} exportations échouées",
|
||||
"exportFailedSingle": "Échec de la création de l’export ZIP",
|
||||
"exportStarted": "Préparation du téléchargement ZIP...",
|
||||
"exportingAssets": "Exportation des ressources",
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "Quantité",
|
||||
"apply": "Appliquer",
|
||||
"architecture": "Architecture",
|
||||
"asset": "{count} ressources | {count} ressource | {count} ressources",
|
||||
"asset": "{count} ressource | {count} ressources",
|
||||
"audioFailedToLoad": "Échec du chargement de l'audio",
|
||||
"audioProgress": "Progression audio",
|
||||
"author": "Auteur",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "Erreur d'emplacements du nœud",
|
||||
"nodeWidgetsError": "Erreur de widgets du nœud",
|
||||
"nodes": "Nœuds",
|
||||
"nodesCount": "{count} nœuds | {count} nœud | {count} nœuds",
|
||||
"nodesCount": "{count} nœud | {count} nœuds",
|
||||
"nodesRunning": "nœuds en cours d’exécution",
|
||||
"none": "Aucun",
|
||||
"nothingToCopy": "Rien à copier",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "Tous les téléchargements sont terminés",
|
||||
"downloadingModel": "Téléchargement du modèle...",
|
||||
"downloadsFailed": "{count} téléchargements échoués | {count} téléchargement échoué | {count} téléchargements échoués",
|
||||
"downloadsFailed": "{count} téléchargement échoué | {count} téléchargements échoués",
|
||||
"failed": "Échec",
|
||||
"filter": {
|
||||
"all": "Tous",
|
||||
|
||||
@@ -11306,6 +11306,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Painter": {
|
||||
"display_name": "Peintre",
|
||||
"inputs": {
|
||||
"bg_color": {
|
||||
"name": "couleur de fond"
|
||||
},
|
||||
"height": {
|
||||
"name": "hauteur"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Image de base optionnelle sur laquelle peindre"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
},
|
||||
"width": {
|
||||
"name": "largeur"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PairConditioningCombine": {
|
||||
"display_name": "Cond Pair Combiner",
|
||||
"inputs": {
|
||||
|
||||