Compare commits
84 Commits
v1.41.14
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b63fde70 | ||
|
|
c602dce375 | ||
|
|
34b1799b21 | ||
|
|
bacb5570c8 | ||
|
|
8b53d5c807 | ||
|
|
39ce4a23cc | ||
|
|
ef477d0381 | ||
|
|
37c6ddfcd9 | ||
|
|
55c42ee484 | ||
|
|
b04db536a1 | ||
|
|
975d6a360d | ||
|
|
7e137d880b | ||
|
|
8db6fb7733 | ||
|
|
7c2c59b9fb | ||
|
|
2f7f3c4e56 | ||
|
|
4c00d39ade | ||
|
|
f1fc5fa9b3 | ||
|
|
c111fb7758 | ||
|
|
65655ba35f | ||
|
|
852d77159e | ||
|
|
b61029b9da | ||
|
|
0a62ea0b2c | ||
|
|
cccc0944a0 | ||
|
|
aadec87bff | ||
|
|
f5088d6cfb | ||
|
|
fd7ce3a852 | ||
|
|
08a2f8ae15 | ||
|
|
7b9f24f515 | ||
|
|
faed80e99a | ||
|
|
b129d64c5d | ||
|
|
4c9b83a224 | ||
|
|
e973efb44a | ||
|
|
9ecb100d11 | ||
|
|
dc3e455993 | ||
|
|
76006fca52 | ||
|
|
d2792cfac6 | ||
|
|
a786825093 | ||
|
|
b0f3b69bda | ||
|
|
d11a0f6c5e | ||
|
|
f97c38e6ee | ||
|
|
e89a0f96cd | ||
|
|
12989e8b63 | ||
|
|
c084605e4d | ||
|
|
b368a865cf | ||
|
|
1d7a5b9e0b | ||
|
|
fbcd36d355 | ||
|
|
e594164b71 | ||
|
|
245f840e7c | ||
|
|
240b54419b | ||
|
|
d9020b7fbe | ||
|
|
c4272ef1da | ||
|
|
2ef354447d | ||
|
|
55789ef0fb | ||
|
|
7add2c03e9 | ||
|
|
c81bc8400c | ||
|
|
af5a72021b | ||
|
|
4e5bb3e540 | ||
|
|
2ccfb822b4 | ||
|
|
370003da94 | ||
|
|
3b5af4960f | ||
|
|
46895ee1a9 | ||
|
|
7f0472fde4 | ||
|
|
24ac6388d7 | ||
|
|
6b6049e48e | ||
|
|
592f992d1d | ||
|
|
76fd80aa98 | ||
|
|
63c36d3f2f | ||
|
|
892a9cf2c5 | ||
|
|
308c22efc6 | ||
|
|
5728d240da | ||
|
|
acf2f4280c | ||
|
|
7ad6994d01 | ||
|
|
2829f78579 | ||
|
|
c4156d7059 | ||
|
|
725a0a2b89 | ||
|
|
8a5bcde168 | ||
|
|
83ffaf30c8 | ||
|
|
2875f897dc | ||
|
|
ec129de63d | ||
|
|
1687ca93b3 | ||
|
|
5bb742ac3a | ||
|
|
ca2d61f393 | ||
|
|
750a2d23e0 | ||
|
|
6d90bf3537 |
150
.claude/skills/backport-management/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: backport-management
|
||||
description: Manages cherry-pick backports across stable release branches. Discovers candidates from Slack/git, analyzes dependencies, resolves conflicts via worktree, and logs results. Use when asked to backport, cherry-pick to stable, manage release branches, do stable branch maintenance, or run a backport session.
|
||||
---
|
||||
|
||||
# Backport Management
|
||||
|
||||
Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release branches.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
|
||||
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
|
||||
3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
5. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
**Critical: Match PRs to the correct target branches.**
|
||||
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ----------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
|
||||
|
||||
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
|
||||
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
|
||||
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
|
||||
|
||||
## ⚠️ Gotchas (Learn from Past Sessions)
|
||||
|
||||
### Use `gh api` for Labels — NOT `gh pr edit`
|
||||
|
||||
`gh pr edit --add-label` triggers Projects Classic deprecation errors. Always use:
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
```
|
||||
|
||||
### Automation Over-Reports Conflicts
|
||||
|
||||
The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-pick -m 1` with git auto-merge handles many cases the automation can't. Always attempt manual cherry-pick before skipping.
|
||||
|
||||
### Never Skip Based on Conflict File Count
|
||||
|
||||
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
|
||||
| Type | Symptom | Resolution |
|
||||
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------- |
|
||||
| **Binary snapshots (PNGs)** | `.png` files in conflict list | `git checkout --theirs $FILE && git add $FILE` — always trivial |
|
||||
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
|
||||
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
|
||||
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
|
||||
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
|
||||
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
|
||||
|
||||
```python
|
||||
# Accept theirs for content conflicts
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### Escalation Triggers (Flag for Human)
|
||||
|
||||
- **Package.json/lockfile changes** → skip on stable (transitive dep regression risk)
|
||||
- **Core type definition changes** → requires human judgment
|
||||
- **Business logic conflicts** (not just imports/exports) → requires domain knowledge
|
||||
- **Admin-merged conflict resolutions** → get human review of the resolution before continuing the wave
|
||||
|
||||
## Auto-Skip Categories
|
||||
|
||||
Skip these without discussion:
|
||||
|
||||
- **Dep refresh PRs** — Risk of transitive dep regressions on stable. Cherry-pick individual CVE fixes instead.
|
||||
- **CI/tooling changes** — Not user-facing
|
||||
- **Test-only / lint rule changes** — Not user-facing
|
||||
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
|
||||
|
||||
```bash
|
||||
# Fetch latest state of target branch
|
||||
git fetch origin TARGET_BRANCH
|
||||
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuous backporting:
|
||||
|
||||
- Backport bug fixes as they merge to main (same day or next day)
|
||||
- Use the automation labels immediately after merge
|
||||
- Reserve session-style bulk backporting for catching up after gaps
|
||||
- When a release branch is created, immediately start the continuous process
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Label-Driven Automation (default path)
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
# Wait 3 min, check: gh pr list --base TARGET_BRANCH --state open
|
||||
```
|
||||
|
||||
### Manual Worktree Cherry-Pick (conflict fallback)
|
||||
|
||||
```bash
|
||||
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
|
||||
cd /tmp/backport-$BRANCH
|
||||
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
# Resolve conflicts, push, create PR, merge
|
||||
```
|
||||
|
||||
### PR Title Convention
|
||||
|
||||
```
|
||||
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
|
||||
```
|
||||
68
.claude/skills/backport-management/reference/analysis.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Analysis & Decision Framework
|
||||
|
||||
## Categorization
|
||||
|
||||
| Category | Criteria | Action |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| **MUST** | User-facing bug, crash, data corruption, security. Clear breakage that users will hit. | Backport (with deps if needed) |
|
||||
| **SHOULD** | UX improvement, minor bug, small dep chain. No user-visible breakage if skipped, but improves experience. | Backport if clean cherry-pick; defer if conflict resolution is non-trivial |
|
||||
| **SKIP** | CI/tooling, test-only, lint rules, cosmetic, dep refresh | Skip with documented reason |
|
||||
| **NEEDS DISCUSSION** | Large dep chain, unclear risk/benefit, touches core types | Flag for human |
|
||||
|
||||
### MUST vs SHOULD Decision Guide
|
||||
|
||||
When unsure, ask: "If a user on this stable branch reports this issue, would we consider it a bug?"
|
||||
|
||||
- **Yes** → MUST. The fix addresses broken behavior.
|
||||
- **No, but it's noticeably better** → SHOULD. The fix is a quality-of-life improvement.
|
||||
- **No, and it's cosmetic or internal** → SKIP.
|
||||
|
||||
For SHOULD items with conflicts: if conflict resolution requires more than trivial accept-theirs patterns (content conflicts in business logic, not just imports), downgrade to SKIP or escalate to NEEDS DISCUSSION.
|
||||
|
||||
## Branch Scope Filtering
|
||||
|
||||
**Before categorizing, filter by branch scope:**
|
||||
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
|
||||
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
|
||||
|
||||
## Features Not on Stable Branches
|
||||
|
||||
Check before backporting — these don't exist on older branches:
|
||||
|
||||
- **Painter** (`src/extensions/core/painter.ts`) — not on core/1.40
|
||||
- **GLSLShader** — not on core/1.40
|
||||
- **App builder** — check per branch
|
||||
- **appModeStore.ts** — not on core/1.40
|
||||
|
||||
## Dep Refresh PRs
|
||||
|
||||
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
|
||||
|
||||
## Revert Pairs
|
||||
|
||||
If PR A is reverted by PR B:
|
||||
|
||||
- Skip BOTH A and B
|
||||
- If a fixed version exists (PR C), backport only C
|
||||
|
||||
## Dependency Analysis
|
||||
|
||||
```bash
|
||||
# Find other PRs that touched the same files
|
||||
gh pr view $PR --json files --jq '.files[].path' | while read f; do
|
||||
git log --oneline origin/TARGET..$MERGE_SHA -- "$f"
|
||||
done
|
||||
```
|
||||
|
||||
## Human Review Checkpoint
|
||||
|
||||
Present decisions.md before execution. Include:
|
||||
|
||||
1. All MUST/SHOULD/SKIP categorizations with rationale
|
||||
2. Questions for human (feature existence, scope, deps)
|
||||
3. Estimated effort per branch
|
||||
42
.claude/skills/backport-management/reference/discovery.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Discovery — Candidate Collection
|
||||
|
||||
## Source 1: Slack Backport-Checker Bot
|
||||
|
||||
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
|
||||
|
||||
```bash
|
||||
slackdump export -o ~/slack-exports/frontend-releases.zip C09K9TPU2G7
|
||||
```
|
||||
|
||||
Parse bot messages for PRs flagged "Might need backport" per release version.
|
||||
|
||||
## Source 2: Git Log Gap Analysis
|
||||
|
||||
```bash
|
||||
# Count gap
|
||||
git log --oneline origin/TARGET..origin/main | wc -l
|
||||
|
||||
# List gap commits
|
||||
git log --oneline origin/TARGET..origin/main
|
||||
|
||||
# Check if a PR is already on target
|
||||
git log --oneline origin/TARGET --grep="#PR_NUMBER"
|
||||
|
||||
# Check for existing backport PRs
|
||||
gh pr list --base TARGET --state all --search "backport PR_NUMBER"
|
||||
```
|
||||
|
||||
## Source 3: GitHub PR Details
|
||||
|
||||
```bash
|
||||
# Get merge commit SHA
|
||||
gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.mergeCommit.oid)"'
|
||||
|
||||
# Get files changed
|
||||
gh pr view $PR --json files --jq '.files[].path'
|
||||
```
|
||||
|
||||
## Output: candidate_list.md
|
||||
|
||||
Table per target branch:
|
||||
| PR# | Title | Category | Flagged by Bot? | Decision |
|
||||
150
.claude/skills/backport-management/reference/execution.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Execution Workflow
|
||||
|
||||
## Per-Branch Execution Order
|
||||
|
||||
1. Smallest gap first (validation run)
|
||||
2. Medium gap next (quick win)
|
||||
3. Largest gap last (main effort)
|
||||
|
||||
## Step 1: Label-Driven Automation (Batch)
|
||||
|
||||
```bash
|
||||
# Add labels to all candidates for a target branch
|
||||
for pr in $PR_LIST; do
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$pr/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH" --silent
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
|
||||
```bash
|
||||
for pr in $AUTO_PRS; do
|
||||
# Check size
|
||||
gh pr view $pr --json title,additions,deletions,changedFiles \
|
||||
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
|
||||
# Admin merge
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
```
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/backport-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/backport-TARGET
|
||||
|
||||
for PR in ${CONFLICT_PRS[@]}; do
|
||||
# Refresh target ref so each branch is based on current HEAD
|
||||
git fetch origin TARGET_BRANCH
|
||||
git checkout origin/TARGET_BRANCH
|
||||
|
||||
git checkout -b backport-$PR-to-TARGET origin/TARGET_BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
|
||||
# If conflict — NEVER skip based on file count alone!
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
|
||||
# See SKILL.md Conflict Triage table for resolution per type.
|
||||
|
||||
# Resolve all conflicts, then:
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
git push origin backport-$PR-to-TARGET
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
cd -
|
||||
git worktree remove /tmp/backport-TARGET --force
|
||||
```
|
||||
|
||||
**⚠️ Human review for conflict resolutions:** When admin-merging a PR where you manually resolved conflicts (especially content conflicts beyond trivial accept-theirs), pause and present the resolution diff to the human for review before merging. Trivial resolutions (binary snapshots, modify/delete, locale key additions) can proceed without review.
|
||||
|
||||
## Step 4: Wave Verification
|
||||
|
||||
After completing all PRs in a wave for a target branch:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
|
||||
|
||||
## Conflict Resolution Patterns
|
||||
|
||||
### 1. Content Conflicts (accept theirs)
|
||||
|
||||
```python
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### 2. Modify/Delete (two cases!)
|
||||
|
||||
```bash
|
||||
# Case A: PR introduces NEW files not on target → keep them
|
||||
git add $FILE
|
||||
|
||||
# Case B: Target REMOVED files the PR modifies → drop them
|
||||
git rm $FILE
|
||||
```
|
||||
|
||||
### 3. Binary Files (snapshots)
|
||||
|
||||
```bash
|
||||
git checkout --theirs $FILE && git add $FILE
|
||||
```
|
||||
|
||||
### 4. Locale Files
|
||||
|
||||
Usually adding new i18n keys — accept theirs, validate JSON:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('src/locales/en/main.json'))" && echo "Valid"
|
||||
```
|
||||
|
||||
## Merge Conflicts After Other Merges
|
||||
|
||||
When merging multiple PRs to the same branch, later PRs may conflict with earlier merges:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Automation reports more conflicts than reality** — `cherry-pick -m 1` with git auto-merge handles many "conflicts" the automation can't
|
||||
2. **Never skip based on conflict file count** — 12 or 27 conflicts can be trivial (snapshots, new files). Categorize first: binary PNGs, modify/delete, content, add/add.
|
||||
3. **Modify/delete goes BOTH ways** — if the PR introduces new files (not on target), `git add` them. If target deleted files the PR modifies, `git rm`.
|
||||
4. **Binary snapshot PNGs** — always `git checkout --theirs && git add`. Never skip a PR just because it has many snapshot conflicts.
|
||||
5. **Batch label additions need 2s delay** between API calls to avoid rate limits
|
||||
6. **Merging 6+ PRs rapidly** can cause later PRs to become unmergeable — wait 20-30s for GitHub to recompute merge state
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
|
||||
96
.claude/skills/backport-management/reference/logging.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Logging & Session Reports
|
||||
|
||||
## During Execution
|
||||
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
|
||||
```markdown
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
|
||||
Track verification results per wave:
|
||||
|
||||
```markdown
|
||||
## Wave N Verification — TARGET_BRANCH
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
|
||||
## Session Report Template
|
||||
|
||||
```markdown
|
||||
# Backport Session Report
|
||||
|
||||
## Summary
|
||||
|
||||
| Branch | Candidates | Merged | Skipped | Deferred | Rate |
|
||||
| ------ | ---------- | ------ | ------- | -------- | ---- |
|
||||
|
||||
## Deferred Items (Needs Human)
|
||||
|
||||
| PR# | Title | Branch | Issue |
|
||||
|
||||
## Conflict Resolutions Requiring Review
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
| --------------------------- | ----- |
|
||||
| Auto success rate | X% |
|
||||
| Manual resolution rate | X% |
|
||||
| Overall clean rate | X% |
|
||||
| Wave verification pass rate | X% |
|
||||
|
||||
## Process Recommendations
|
||||
|
||||
- Were there clusters of related PRs that should have been backported together?
|
||||
- Any PRs that should have been backported sooner (continuous backporting candidates)?
|
||||
- Feature branches that need tracking for future sessions?
|
||||
```
|
||||
|
||||
## Final Deliverable: Visual Summary
|
||||
|
||||
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph branch1["☁️ cloud/X.XX — N PRs"]
|
||||
C1["#XXXX title"]
|
||||
C2["#XXXX title"]
|
||||
end
|
||||
|
||||
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
|
||||
M1["#XXXX title"]
|
||||
end
|
||||
|
||||
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
|
||||
S1["#XXXX-#XXXX N auto-merged"]
|
||||
S2["#XXXX-#XXXX N manual picks"]
|
||||
end
|
||||
|
||||
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
|
||||
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
|
||||
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
|
||||
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
|
||||
```
|
||||
|
||||
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
|
||||
|
||||
## Files to Track
|
||||
|
||||
- `candidate_list.md` — all candidates per branch
|
||||
- `decisions.md` — MUST/SHOULD/SKIP with rationale
|
||||
- `wave-plan.md` — execution order
|
||||
- `execution-log.md` — real-time status
|
||||
- `backport-session-report.md` — final summary
|
||||
|
||||
All in `~/temp/backport-session/`.
|
||||
@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Update electron types
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
25
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -4,6 +4,8 @@ name: 'CI: Tests Storybook'
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
@@ -138,6 +140,29 @@ jobs:
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
# Deploy Storybook to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook
|
||||
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
npx wrangler@^4.0.0 pages deploy storybook-static \
|
||||
--project-name=comfy-storybook \
|
||||
--branch=main
|
||||
|
||||
# Update comment with Chromatic URLs for version-bump branches
|
||||
update-comment-with-chromatic:
|
||||
needs: [chromatic-deployment, deploy-and-comment]
|
||||
|
||||
27
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
- 'cloud/*'
|
||||
- 'main'
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
types: [labeled, synchronize]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
@@ -26,11 +26,18 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
# Fork guard: prevent forks from dispatching to the cloud repo.
|
||||
# For pull_request events, only dispatch when the 'preview' label is added.
|
||||
# For pull_request events, only dispatch for preview labels.
|
||||
# - labeled: fires when a label is added; check the added label name.
|
||||
# - synchronize: fires on push; check existing labels on the PR.
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.label.name == 'preview')
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
@@ -39,18 +46,30 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
ACTION: ${{ github.event.action }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
|
||||
run: |
|
||||
if [ "${EVENT_NAME}" = "pull_request" ]; then
|
||||
REF="${PR_HEAD_SHA}"
|
||||
BRANCH="${PR_HEAD_REF}"
|
||||
|
||||
# Derive variant from all PR labels (default to cpu for frontend-only previews)
|
||||
VARIANT="cpu"
|
||||
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
|
||||
else
|
||||
REF="${GITHUB_SHA}"
|
||||
BRANCH="${GITHUB_REF_NAME}"
|
||||
PR_NUMBER=""
|
||||
VARIANT=""
|
||||
fi
|
||||
payload="$(jq -nc \
|
||||
--arg ref "${REF}" \
|
||||
--arg branch "${BRANCH}" \
|
||||
'{ref: $ref, branch: $branch}')"
|
||||
--arg pr_number "${PR_NUMBER}" \
|
||||
--arg variant "${VARIANT}" \
|
||||
'{ref: $ref, branch: $branch, pr_number: $pr_number, variant: $variant}')"
|
||||
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Dispatch to cloud repo
|
||||
|
||||
39
.github/workflows/cloud-dispatch-cleanup.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
# Dispatches a frontend-preview-cleanup event to the cloud repo when a
|
||||
# frontend PR with a preview label is closed or has its preview label
|
||||
# removed. The cloud repo handles the actual environment teardown.
|
||||
#
|
||||
# This is fire-and-forget — it does NOT wait for the cloud workflow to
|
||||
# complete. Status is visible in the cloud repo's Actions tab.
|
||||
|
||||
name: Cloud Frontend Preview Cleanup Dispatch
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed, unlabeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
# Only dispatch when:
|
||||
# - PR closed AND had a preview label
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))) ||
|
||||
(github.event.action == 'unlabeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch to cloud repo
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
|
||||
repository: Comfy-Org/cloud
|
||||
event-type: frontend-preview-cleanup
|
||||
client-payload: >-
|
||||
{"pr_number": "${{ github.event.pull_request.number }}"}
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
|
||||
2
.github/workflows/pr-perf-report.yaml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download PR metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.x'
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Read desktop-ui version
|
||||
id: get_version
|
||||
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -91,7 +91,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.x'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: 'frontend/.nvmrc'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
|
||||
2
.github/workflows/release-branch-create.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Check version bump type
|
||||
id: check_version
|
||||
|
||||
2
.github/workflows/release-draft-create.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get current version
|
||||
|
||||
2
.github/workflows/release-npm-types.yaml
vendored
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
|
||||
2
.github/workflows/release-pypi-dev.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get current version
|
||||
|
||||
2
.github/workflows/release-version-bump.yaml
vendored
@@ -149,7 +149,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.x'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Bump desktop-ui version
|
||||
|
||||
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
|
||||
1
.gitignore
vendored
@@ -26,6 +26,7 @@ dist-ssr
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
.claude/worktrees
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -58,7 +58,7 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
document.documentElement.classList.remove('dark-theme')
|
||||
document.body.classList.remove('dark-theme')
|
||||
}
|
||||
document.body.classList.add('[&_*]:!font-inter')
|
||||
document.body.classList.add('font-inter')
|
||||
|
||||
return Story(context.args, context)
|
||||
}
|
||||
|
||||
@@ -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 (v24) and pnpm
|
||||
- Node.js (see `.nvmrc`, currently v24) and pnpm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
|
||||
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
|
||||
Then, set up the Chromium test driver:
|
||||
|
||||
```bash
|
||||
pnpm exec playwright install chromium --with-deps
|
||||
|
||||
47
browser_tests/assets/nodes/load_image_with_ksampler.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KSampler",
|
||||
"pos": [500, 50],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -333,7 +333,7 @@ test.describe('Settings', () => {
|
||||
await editKeybindingButton.click()
|
||||
|
||||
// Set new keybinding
|
||||
const input = comfyPage.page.getByPlaceholder('Press keys for new binding')
|
||||
const input = comfyPage.page.getByPlaceholder('Enter your keybind')
|
||||
await input.press('Alt+n')
|
||||
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
@@ -345,7 +345,7 @@ test.describe('Settings', () => {
|
||||
|
||||
// Save keybinding
|
||||
const saveButton = comfyPage.page
|
||||
.getByLabel('New Blank Workflow')
|
||||
.getByLabel('Modify keybinding')
|
||||
.getByText('Save')
|
||||
await saveButton.click()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
58
browser_tests/tests/imagePastePriority.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Image paste priority over stale node metadata',
|
||||
{ tag: ['@node'] },
|
||||
() => {
|
||||
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBe(2)
|
||||
|
||||
// Copy the KSampler node (puts data-metadata in clipboard)
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
|
||||
// Select the LoadImage node
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
|
||||
// Simulate pasting when clipboard has stale node metadata (text/html
|
||||
// with data-metadata) but no image file items. This replicates the bug
|
||||
// scenario: user copied a node, then copied a web image (which replaces
|
||||
// clipboard files but may leave stale text/html with node metadata).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
|
||||
const base64 = btoa(JSON.stringify(nodeData))
|
||||
const html =
|
||||
'<meta charset="utf-8"><div><span data-metadata="' +
|
||||
base64 +
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node count should remain the same — stale node metadata should NOT
|
||||
// be deserialized when a media node is selected.
|
||||
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(finalCount).toBe(initialCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
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: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 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: 64 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: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
91
docs/architecture/change-tracker.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Change Tracker (Undo/Redo System)
|
||||
|
||||
The `ChangeTracker` class (`src/scripts/changeTracker.ts`) manages undo/redo
|
||||
history by comparing serialized graph snapshots.
|
||||
|
||||
## How It Works
|
||||
|
||||
`checkState()` is the core method. It:
|
||||
|
||||
1. Serializes the current graph via `app.rootGraph.serialize()`
|
||||
2. Deep-compares the result against the last known `activeState`
|
||||
3. If different, pushes `activeState` onto `undoQueue` and replaces it
|
||||
|
||||
**It is not reactive.** Changes to the graph (widget values, node positions,
|
||||
links, etc.) are only captured when `checkState()` is explicitly triggered.
|
||||
|
||||
## Automatic Triggers
|
||||
|
||||
These are set up once in `ChangeTracker.init()`:
|
||||
|
||||
| Trigger | Event / Hook | What It Catches |
|
||||
| ----------------------------------- | -------------------------------------------------- | --------------------------------------------------- |
|
||||
| Keyboard (non-modifier, non-repeat) | `window` `keydown` | Shortcuts, typing in canvas |
|
||||
| Modifier key release | `window` `keyup` | Releasing Ctrl/Shift/Alt/Meta |
|
||||
| Mouse click | `window` `mouseup` | General clicks on native DOM |
|
||||
| Canvas mouse up | `LGraphCanvas.processMouseUp` override | LiteGraph canvas interactions |
|
||||
| Number/string dialog | `LGraphCanvas.prompt` override | Dialog popups for editing widgets |
|
||||
| Context menu close | `LiteGraph.ContextMenu.close` override | COMBO widget menus in LiteGraph |
|
||||
| Active input element | `bindInput` (change/input/blur on focused element) | Native HTML input edits |
|
||||
| Prompt queued | `api` `promptQueued` event | Dynamic widget changes on queue |
|
||||
| Graph cleared | `api` `graphCleared` event | Full graph clear |
|
||||
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
|
||||
|
||||
## When You Must Call `checkState()` Manually
|
||||
|
||||
The automatic triggers above are designed around LiteGraph's native DOM
|
||||
rendering. They **do not cover**:
|
||||
|
||||
- **Vue-rendered widgets** — Vue handles events internally without triggering
|
||||
native DOM events that the tracker listens to (e.g., `mouseup` on a Vue
|
||||
dropdown doesn't bubble the same way as a native LiteGraph widget click)
|
||||
- **Programmatic graph mutations** — Any code that modifies the graph outside
|
||||
of user interaction (e.g., applying a template, pasting nodes, aligning)
|
||||
- **Async operations** — File uploads, API calls that change widget values
|
||||
after the initial user gesture
|
||||
|
||||
### Pattern for Manual Calls
|
||||
|
||||
```typescript
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
// After mutating the graph:
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
```
|
||||
|
||||
### Existing Manual Call Sites
|
||||
|
||||
These locations already call `checkState()` explicitly:
|
||||
|
||||
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
|
||||
- `ColorPickerButton.vue` — After changing node colors
|
||||
- `NodeSearchBoxPopover.vue` — After adding a node from search
|
||||
- `useAppSetDefaultView.ts` — After setting default view
|
||||
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
|
||||
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
|
||||
- `useGroupMenuOptions.ts` — After group operations
|
||||
- `useSubgraphOperations.ts` — After subgraph enter/exit
|
||||
- `useCanvasRefresh.ts` — After canvas refresh
|
||||
- `useCoreCommands.ts` — After metadata/subgraph commands
|
||||
- `workflowService.ts` — After workflow service operations
|
||||
|
||||
## Transaction Guards
|
||||
|
||||
For operations that make multiple changes that should be a single undo entry:
|
||||
|
||||
```typescript
|
||||
changeTracker.beforeChange()
|
||||
// ... multiple graph mutations ...
|
||||
changeTracker.afterChange() // calls checkState() when nesting count hits 0
|
||||
```
|
||||
|
||||
The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
`after-change` sub-types.
|
||||
|
||||
## Key Invariants
|
||||
|
||||
- `checkState()` is a no-op during `loadGraphData` (guarded by
|
||||
`isLoadingGraph`) to prevent cross-workflow corruption
|
||||
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
|
||||
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
|
||||
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing
|
||||
62
docs/release-process.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Release Process
|
||||
|
||||
## Bump Types
|
||||
|
||||
All releases use `release-version-bump.yaml`. Effects differ by bump type:
|
||||
|
||||
| Bump | Target | Creates branches? | GitHub release |
|
||||
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
|
||||
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
|
||||
| Patch | `main` | No | Published, "latest" |
|
||||
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
|
||||
| Prerelease | any | No | Draft + prerelease |
|
||||
|
||||
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
|
||||
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
|
||||
bumps on `main` are convenience snapshots — no branches created.
|
||||
|
||||
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
|
||||
"latest" so `main` stays current.
|
||||
|
||||
### Dual-homed commits
|
||||
|
||||
When a minor bump happens, unreleased commits appear in both places:
|
||||
|
||||
```
|
||||
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
|
||||
│
|
||||
└── core/1.40
|
||||
```
|
||||
|
||||
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
|
||||
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
|
||||
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
|
||||
|
||||
## Backporting
|
||||
|
||||
1. Add `needs-backport` + version label to the merged PR
|
||||
2. `pr-backport.yaml` cherry-picks and creates a backport PR
|
||||
3. Conflicts produce a comment with details and an agent prompt
|
||||
|
||||
## Publishing
|
||||
|
||||
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
|
||||
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
|
||||
and npm (`@comfyorg/comfyui-frontend-types`).
|
||||
|
||||
## Bi-weekly ComfyUI Integration
|
||||
|
||||
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
|
||||
branch has unreleased commits, it triggers a patch bump and drafts a PR to
|
||||
`Comfy-Org/ComfyUI` updating `requirements.txt`.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------ |
|
||||
| `release-version-bump.yaml` | Bump version, create Release PR |
|
||||
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
|
||||
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
|
||||
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
|
||||
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
|
||||
| `cloud-backport-tag.yaml` | Tag cloud branch merges |
|
||||
1
global.d.ts
vendored
@@ -35,6 +35,7 @@ interface Window {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Record<string, unknown>
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
56
index.html
@@ -20,6 +20,10 @@ const config: KnipConfig = {
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/shared-frontend-utils': {
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
|
||||
},
|
||||
'packages/registry-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
@@ -32,7 +36,9 @@ const config: KnipConfig = {
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
'@primevue/icons',
|
||||
// Used by lucideStrokePlugin.js (CSS @plugin)
|
||||
'@iconify/utils'
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated manager types
|
||||
@@ -47,7 +53,13 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Loaded via @plugin directive in CSS, not detected by knip
|
||||
'packages/design-system/src/css/lucideStrokePlugin.js',
|
||||
// Pending integration in stacked PR (#9744)
|
||||
'src/components/ui/chart/ChartBar.vue',
|
||||
'src/components/ui/chart/ChartLine.vue',
|
||||
'src/components/ui/chart/useChart.ts'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.14",
|
||||
"version": "1.42.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -195,6 +195,9 @@
|
||||
"zip-dir": "^2.0.0",
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "catalog:"
|
||||
|
||||
51
public/splash.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Pre-Vue splash loader — colors set by inline script */
|
||||
#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);
|
||||
}
|
||||
}
|
||||
19
src/App.vue
@@ -2,20 +2,13 @@
|
||||
<router-view />
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
|
||||
>
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -31,6 +24,16 @@ app.extensionManager = useWorkspaceStore()
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
|
||||
watch(
|
||||
isLoading,
|
||||
(loading, prevLoading) => {
|
||||
if (prevLoading && !loading) {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
const { target } = event
|
||||
switch (true) {
|
||||
|
||||
@@ -166,13 +166,22 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
function createLegacyTabBarWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
|
||||
)
|
||||
return createWrapper({ pinia })
|
||||
}
|
||||
|
||||
describe('when user is logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = true
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -186,7 +195,7 @@ describe('TopMenuSection', () => {
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
mockData.isDesktop = true
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -194,7 +203,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
describe('on web platform', () => {
|
||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -34,17 +34,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
@@ -55,6 +45,7 @@
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
:has-any-error="hasAnyError"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
@@ -70,7 +61,7 @@
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--share-2] size-4" />
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
@@ -123,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -145,6 +136,7 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -168,6 +160,7 @@ const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const actionBarButtonStore = useActionBarButtonStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
@@ -182,8 +175,45 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
/**
|
||||
* Whether the actionbar container has any visible docked buttons
|
||||
* (excluding ComfyActionbar, which uses position:fixed when floating
|
||||
* and does not contribute to the container's visual layout).
|
||||
*/
|
||||
const hasDockedButtons = computed(() => {
|
||||
if (actionBarButtonStore.buttons.length > 0) return true
|
||||
if (hasLegacyContent.value) return true
|
||||
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
|
||||
if (isDesktop && !isIntegratedTabBar.value) return true
|
||||
if (isCloud && flags.workflowSharingEnabled) return true
|
||||
if (!isRightSidePanelOpen.value) return true
|
||||
return false
|
||||
})
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
const borderClass =
|
||||
!isActionbarFloating.value && hasAnyError.value
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
|
||||
return cn(base, 'px-2', borderClass)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
@@ -233,6 +263,25 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
|
||||
function checkLegacyContent() {
|
||||
const el = legacyCommandsContainerRef.value
|
||||
if (!el) {
|
||||
hasLegacyContent.value = false
|
||||
return
|
||||
}
|
||||
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
|
||||
hasLegacyContent.value =
|
||||
el.querySelector(':scope > * > *:not(:empty)') !== null
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (legacyCommandsContainerRef.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
|
||||
@@ -119,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
|
||||
const {
|
||||
topMenuContainer,
|
||||
queueOverlayExpanded = false,
|
||||
hasAnyError = false
|
||||
} = defineProps<{
|
||||
topMenuContainer?: HTMLElement | null
|
||||
queueOverlayExpanded?: boolean
|
||||
hasAnyError?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
|
||||
isDragging.value && 'pointer-events-none select-none',
|
||||
isDocked.value
|
||||
? 'static border-none bg-transparent p-0'
|
||||
: 'fixed shadow-interface'
|
||||
: [
|
||||
'fixed shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
]
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -25,15 +25,13 @@
|
||||
class: {
|
||||
'p-3 rounded-lg': true,
|
||||
'pointer-events-none':
|
||||
bottomPanelStore.bottomPanelTabs.length === 1
|
||||
},
|
||||
style: {
|
||||
color: 'var(--fg-color)',
|
||||
backgroundColor:
|
||||
bottomPanelStore.bottomPanelTabs.length === 1,
|
||||
'bg-secondary-background text-secondary-foreground':
|
||||
x.context.active &&
|
||||
bottomPanelStore.bottomPanelTabs.length > 1,
|
||||
'text-muted-foreground':
|
||||
!x.context.active ||
|
||||
bottomPanelStore.bottomPanelTabs.length === 1
|
||||
? ''
|
||||
: 'var(--bg-color)'
|
||||
bottomPanelStore.bottomPanelTabs.length <= 1
|
||||
}
|
||||
})
|
||||
"
|
||||
@@ -127,4 +125,8 @@ const closeBottomPanel = () => {
|
||||
:deep(.p-tablist-active-bar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.p-tab-active) {
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -42,6 +43,9 @@ export function useAppSetDefaultView() {
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
@@ -54,6 +58,7 @@ export function useAppSetDefaultView() {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
|
||||
160
src/components/common/ScrubableNumberInput.stories.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref, toRefs } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
|
||||
import ScrubableNumberInput from './ScrubableNumberInput.vue'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof ScrubableNumberInput>
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Input/Number',
|
||||
component: ScrubableNumberInput,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
min: { control: 'number' },
|
||||
max: { control: 'number' },
|
||||
step: { control: 'number' },
|
||||
disabled: { control: 'boolean' },
|
||||
hideButtons: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
hideButtons: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-60"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { min, max, step, disabled, hideButtons } = toRefs(args)
|
||||
const value = ref(42)
|
||||
return { value, min, max, step, disabled, hideButtons }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min :max :step :disabled :hideButtons />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { disabled } = toRefs(args)
|
||||
const value = ref(50)
|
||||
return { value, disabled }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :disabled />'
|
||||
})
|
||||
}
|
||||
|
||||
export const AtMinimum: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(0)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const AtMaximum: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(100)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const FloatPrecision: Story = {
|
||||
args: { min: 0, max: 1, step: 0.01 },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { min, max, step } = toRefs(args)
|
||||
const value = ref(0.75)
|
||||
return { value, min, max, step }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min :max :step display-value="0.75" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeNumber: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(1809000312992)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const HiddenButtons: Story = {
|
||||
args: { hideButtons: true },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { hideButtons } = toRefs(args)
|
||||
const value = ref(42)
|
||||
return { value, hideButtons }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :hideButtons />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithControlButton: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput, Button, Popover },
|
||||
setup() {
|
||||
const value = ref(1809000312992)
|
||||
return { value }
|
||||
},
|
||||
template: `
|
||||
<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1">
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-4 w-7 self-center rounded-xl bg-primary-background/30 p-0 hover:bg-primary-background-hover/30"
|
||||
>
|
||||
<i class="icon-[lucide--shuffle] w-full text-xs text-primary-background" />
|
||||
</Button>
|
||||
</template>
|
||||
<div class="p-4 text-sm">Control popover content</div>
|
||||
</Popover>
|
||||
</ScrubableNumberInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: Omit<ComponentExposed<C>, 'focus'>
|
||||
}
|
||||
|
||||
const meta: GenericMeta<typeof SearchBox> = {
|
||||
title: 'Components/Input/SearchBox',
|
||||
component: SearchBox,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
showBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border prop'
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['md', 'lg'],
|
||||
description: 'Size variant of the search box'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' },
|
||||
onSearch: { action: 'search' }
|
||||
},
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Search...',
|
||||
showBorder: false,
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { SearchBox },
|
||||
setup() {
|
||||
const searchText = ref('')
|
||||
return { searchText, args }
|
||||
},
|
||||
template: `
|
||||
<div style="max-width: 320px;">
|
||||
<SearchBox v-bind="args" v-model="searchText" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const MediumSize: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'md',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSize: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'lg',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSizeWithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'lg',
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWidgets: {
|
||||
sort: {
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(SearchBox, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('debounced search functionality', () => {
|
||||
it('should debounce search input by 300ms', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Type search query
|
||||
await input.setValue('test')
|
||||
|
||||
// Model should not update immediately
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 299ms (just before debounce delay)
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 1ms more (reaching 300ms)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
// Model should now be updated
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
|
||||
})
|
||||
|
||||
it('should reset debounce timer on each keystroke', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Type first character
|
||||
await input.setValue('t')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
// Type second character (should reset timer)
|
||||
await input.setValue('te')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
// Type third character (should reset timer again)
|
||||
await input.setValue('tes')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet (only 200ms passed since last keystroke)
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance final 100ms to reach 300ms
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
// Should now emit with final value
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Simulate rapid typing
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Complete the debounce delay
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
// Should emit only once with final value
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
|
||||
})
|
||||
|
||||
describe('bidirectional model sync', () => {
|
||||
it('should sync external model changes to internal state', async () => {
|
||||
const wrapper = createWrapper({ modelValue: 'initial' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.element.value).toBe('initial')
|
||||
|
||||
// Update model externally
|
||||
await wrapper.setProps({ modelValue: 'external update' })
|
||||
await nextTick()
|
||||
|
||||
// Internal state should sync
|
||||
expect(input.element.value).toBe('external update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder', () => {
|
||||
it('should use custom placeholder when provided', () => {
|
||||
const wrapper = createWrapper({ placeholder: 'Custom search...' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Custom search...')
|
||||
expect(input.attributes('aria-label')).toBe('Custom search...')
|
||||
})
|
||||
|
||||
it('should use default placeholder when not provided', () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
expect(input.attributes('aria-label')).toBe('Search...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofocus', () => {
|
||||
it('should focus input when autofocus is true', async () => {
|
||||
const wrapper = createWrapper({ autofocus: true })
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
const inputElement = input.element as HTMLInputElement
|
||||
|
||||
// Note: In JSDOM, focus() doesn't actually set document.activeElement
|
||||
// We can only verify that the focus method exists and doesn't throw
|
||||
expect(inputElement.focus).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not autofocus when autofocus is false', () => {
|
||||
const wrapper = createWrapper({ autofocus: false })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(document.activeElement).not.toBe(input.element)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click to focus', () => {
|
||||
it('should focus input when wrapper is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapperDiv = wrapper.find('[class*="flex"]')
|
||||
|
||||
await wrapperDiv.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Input should receive focus
|
||||
const input = wrapper.find('input').element as HTMLInputElement
|
||||
expect(input.focus).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
|
||||
customClass,
|
||||
wrapperStyle
|
||||
)
|
||||
"
|
||||
>
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="modelValue"
|
||||
:placeholder
|
||||
:autofocus
|
||||
unstyled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
|
||||
isLarge ? 'pl-11' : 'pl-8'
|
||||
)
|
||||
"
|
||||
:aria-label="placeholder"
|
||||
/>
|
||||
<Button
|
||||
v-if="filterIcon"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
|
||||
@click="$emit('showFilter', $event)"
|
||||
>
|
||||
<i :class="filterIcon" />
|
||||
</Button>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
:class="cn('clear-button absolute', isLarge ? 'left-2' : 'left-0')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@click="modelValue = ''"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="$emit('removeFilter', filter)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="TFilter extends SearchFilter">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import type { SearchFilter } from './SearchFilterChip.vue'
|
||||
import SearchFilterChip from './SearchFilterChip.vue'
|
||||
|
||||
const {
|
||||
placeholder = 'Search...',
|
||||
icon = 'pi pi-search',
|
||||
debounceTime = 300,
|
||||
filterIcon,
|
||||
filters = [],
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
filterIcon?: string
|
||||
filters?: TFilter[]
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const isLarge = computed(() => size === 'lg')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'search', value: string, filters: TFilter[]): void
|
||||
(e: 'showFilter', event: Event): void
|
||||
(e: 'removeFilter', filter: TFilter): void
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref()
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
modelValue,
|
||||
(value: string) => {
|
||||
emit('search', value, filters)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
if (showBorder) {
|
||||
return cn(
|
||||
'box-border rounded-sm border border-solid border-border-default p-2',
|
||||
isLarge.value ? 'h-10' : 'h-8'
|
||||
)
|
||||
}
|
||||
|
||||
// Size-specific classes matching button sizes for consistency
|
||||
const sizeClasses = {
|
||||
md: 'h-8 px-2 py-1.5', // Matches button sm size
|
||||
lg: 'h-10 px-4 py-2' // Matches button md size
|
||||
}[size]
|
||||
|
||||
return cn('rounded-lg', sizeClasses)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBoxV2 from './SearchBoxV2.vue'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
watchDebounced: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
describe('SearchBoxV2', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
clear: 'Clear',
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(SearchBoxV2, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxAnchor: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['placeholder', 'modelValue', 'autoFocus']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('uses i18n placeholder when no placeholder prop provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
})
|
||||
|
||||
it('uses custom placeholder when provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
placeholder: 'Custom placeholder'
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('shows search icon when search term is empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: '' })
|
||||
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows clear button when search term is not empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears search term when clear button is clicked', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
const clearButton = wrapper.find('button')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
})
|
||||
|
||||
it('applies large size classes when size is lg', () => {
|
||||
const wrapper = mountComponent({ size: 'lg' })
|
||||
expect(wrapper.html()).toContain('size-5')
|
||||
})
|
||||
|
||||
it('applies medium size classes when size is md', () => {
|
||||
const wrapper = mountComponent({ size: 'md' })
|
||||
expect(wrapper.html()).toContain('size-4')
|
||||
})
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-auto flex-col gap-2">
|
||||
<ComboboxRoot :ignore-filter="true" :open="false">
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-text items-center',
|
||||
'rounded-lg bg-comfy-input text-comfy-input-foreground',
|
||||
showBorder &&
|
||||
'box-border border border-solid border-border-default',
|
||||
sizeClasses,
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="!searchTerm"
|
||||
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="absolute left-2"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.clear')"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
|
||||
<ComboboxInput
|
||||
ref="inputRef"
|
||||
v-model="searchTerm"
|
||||
:class="
|
||||
cn(
|
||||
'size-full border-none bg-transparent text-sm outline-none',
|
||||
inputPadding
|
||||
)
|
||||
"
|
||||
:placeholder="placeholderText"
|
||||
:auto-focus="autofocus"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
icon = 'icon-[lucide--search]',
|
||||
debounceTime = 300,
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [value: string]
|
||||
}>()
|
||||
|
||||
const searchTerm = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const isLarge = computed(() => size === 'lg')
|
||||
const placeholderText = computed(
|
||||
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
|
||||
)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
if (showBorder) {
|
||||
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
|
||||
}
|
||||
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
|
||||
})
|
||||
|
||||
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
|
||||
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
|
||||
|
||||
function clearSearch() {
|
||||
searchTerm.value = ''
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchTerm,
|
||||
(value: string) => {
|
||||
emit('search', value)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
</script>
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="system-stats">
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<Button variant="secondary" @click="copySystemInfo">
|
||||
<i class="pi pi-copy" />
|
||||
{{ $t('g.copySystemInfo') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
@@ -46,6 +52,8 @@ import TabView from 'primevue/tabview'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
@@ -55,6 +63,8 @@ const props = defineProps<{
|
||||
stats: SystemStats
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const systemInfo = computed(() => ({
|
||||
...props.stats.system,
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
@@ -108,7 +118,7 @@ function isOutdated(column: ColumnDef): boolean {
|
||||
return !!installed && !!required && installed !== required
|
||||
}
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
function getDisplayValue(column: ColumnDef) {
|
||||
const value = systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
@@ -118,4 +128,33 @@ const getDisplayValue = (column: ColumnDef) => {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function formatSystemInfoText(): string {
|
||||
const lines: string[] = ['## System Info']
|
||||
|
||||
for (const col of systemColumns.value) {
|
||||
const display = getDisplayValue(col)
|
||||
if (display !== undefined && display !== '') {
|
||||
lines.push(`${col.header}: ${display}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDevices.value) {
|
||||
lines.push('')
|
||||
lines.push('## Devices')
|
||||
for (const device of props.stats.devices) {
|
||||
lines.push(`- ${device.name} (${device.type})`)
|
||||
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
|
||||
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
|
||||
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
|
||||
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function copySystemInfo() {
|
||||
copyToClipboard(formatSystemInfoText())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<!-- Node -->
|
||||
<div
|
||||
v-if="item.value.type === 'node'"
|
||||
v-bind="$attrs"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@@ -48,6 +49,7 @@
|
||||
<!-- Folder -->
|
||||
<div
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@@ -98,6 +100,10 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const ROW_CLASS =
|
||||
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Avatar
|
||||
class="bg-interface-panel-selected-surface"
|
||||
class="aspect-square bg-interface-panel-selected-surface"
|
||||
:image="photoUrl ?? undefined"
|
||||
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
|
||||
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
||||
|
||||
@@ -155,6 +155,93 @@ describe('VirtualGrid', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits approach-end for single-column list when scrolled near bottom', async () => {
|
||||
const items = createItems(50)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)'
|
||||
},
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
maxColumns: 1,
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('approach-end')).toBeUndefined()
|
||||
|
||||
// Scroll near the end: 50 items * 48px = 2400px total
|
||||
// viewRows = ceil(600/48) = 13, buffer = 1
|
||||
// Need toCol >= items.length - cols*bufferRows = 50 - 1 = 49
|
||||
// toCol = (offsetRows + bufferRows + viewRows) * cols
|
||||
// offsetRows = floor(scrollY / 48)
|
||||
// Need (offsetRows + 1 + 13) * 1 >= 49 → offsetRows >= 35
|
||||
// scrollY = 35 * 48 = 1680
|
||||
mockedScrollY.value = 1680
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('approach-end')).toBeDefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit approach-end without maxColumns in single-column layout', async () => {
|
||||
// Demonstrates the bug: without maxColumns=1, cols is calculated
|
||||
// from width/itemWidth (400/200 = 2), causing incorrect row math
|
||||
const items = createItems(50)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)'
|
||||
},
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
// No maxColumns — cols will be floor(400/200) = 2
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Same scroll position as the passing test
|
||||
mockedScrollY.value = 1680
|
||||
await nextTick()
|
||||
|
||||
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
|
||||
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
|
||||
// The approach-end never fires at the correct scroll position
|
||||
expect(wrapper.emitted('approach-end')).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||
mockedWidth.value = 100
|
||||
mockedHeight.value = 200
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
class="max-w-[384px]"
|
||||
class="max-w-96 flex-1"
|
||||
autofocus
|
||||
/>
|
||||
</template>
|
||||
@@ -403,7 +403,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
@@ -59,7 +59,15 @@
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="truncate" :title="slotProps.data.id">
|
||||
<div
|
||||
class="flex items-center gap-1.5 truncate"
|
||||
:title="slotProps.data.id"
|
||||
>
|
||||
<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>
|
||||
@@ -93,44 +101,6 @@
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="editDialogVisible"
|
||||
class="min-w-96"
|
||||
modal
|
||||
:header="currentEditingCommand?.label"
|
||||
@hide="cancelEdit"
|
||||
>
|
||||
<div>
|
||||
<InputText
|
||||
ref="keybindingInput"
|
||||
class="mb-2 text-center"
|
||||
:model-value="newBindingKeyCombo?.toString() ?? ''"
|
||||
:placeholder="$t('g.pressKeysForNewBinding')"
|
||||
autocomplete="off"
|
||||
fluid
|
||||
@keydown.stop.prevent="captureKeybinding"
|
||||
/>
|
||||
<Message v-if="existingKeybindingOnCombo" severity="warn">
|
||||
{{ $t('g.keybindingAlreadyExists') }}
|
||||
<Tag
|
||||
severity="secondary"
|
||||
:value="existingKeybindingOnCombo.commandId"
|
||||
/>
|
||||
</Message>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
:variant="existingKeybindingOnCombo ? 'destructive' : 'primary'"
|
||||
autofocus
|
||||
@click="saveKeybinding"
|
||||
>
|
||||
<i
|
||||
:class="existingKeybindingOnCombo ? 'pi pi-pencil' : 'pi pi-check'"
|
||||
/>
|
||||
{{ existingKeybindingOnCombo ? $t('g.overwrite') : $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
<Button
|
||||
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
|
||||
class="mt-4 w-full"
|
||||
@@ -147,18 +117,14 @@
|
||||
import { FilterMatchMode } from '@primevue/core/api'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -195,50 +161,16 @@ const commandsData = computed<ICommandData[]>(() => {
|
||||
})
|
||||
|
||||
const selectedCommandData = ref<ICommandData | null>(null)
|
||||
const editDialogVisible = ref(false)
|
||||
const newBindingKeyCombo = ref<KeyComboImpl | null>(null)
|
||||
const currentEditingCommand = ref<ICommandData | null>(null)
|
||||
const keybindingInput = ref<InstanceType<typeof InputText> | null>(null)
|
||||
|
||||
const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
|
||||
if (!currentEditingCommand.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If the new keybinding is the same as the current editing command, then don't show the error
|
||||
if (
|
||||
currentEditingCommand.value.keybinding?.combo?.equals(
|
||||
newBindingKeyCombo.value
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!newBindingKeyCombo.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return keybindingStore.getKeybinding(newBindingKeyCombo.value)
|
||||
})
|
||||
const editKeybindingDialog = useEditKeybindingDialog()
|
||||
|
||||
function editKeybinding(commandData: ICommandData) {
|
||||
currentEditingCommand.value = commandData
|
||||
newBindingKeyCombo.value = commandData.keybinding
|
||||
? commandData.keybinding.combo
|
||||
: null
|
||||
editDialogVisible.value = true
|
||||
editKeybindingDialog.show({
|
||||
commandId: commandData.id,
|
||||
commandLabel: commandData.label,
|
||||
currentCombo: commandData.keybinding?.combo ?? null
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (editDialogVisible.value) {
|
||||
// nextTick doesn't work here, so we use a timeout instead
|
||||
setTimeout(() => {
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
keybindingInput.value?.$el?.focus()
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
async function removeKeybinding(commandData: ICommandData) {
|
||||
if (commandData.keybinding) {
|
||||
keybindingStore.unsetKeybinding(commandData.keybinding)
|
||||
@@ -246,40 +178,6 @@ async function removeKeybinding(commandData: ICommandData) {
|
||||
}
|
||||
}
|
||||
|
||||
async function captureKeybinding(event: KeyboardEvent) {
|
||||
// Allow the use of keyboard shortcuts when adding keyboard shortcuts
|
||||
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
cancelEdit()
|
||||
return
|
||||
case 'Enter':
|
||||
await saveKeybinding()
|
||||
return
|
||||
}
|
||||
}
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
newBindingKeyCombo.value = keyCombo
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editDialogVisible.value = false
|
||||
currentEditingCommand.value = null
|
||||
newBindingKeyCombo.value = null
|
||||
}
|
||||
|
||||
async function saveKeybinding() {
|
||||
const commandId = currentEditingCommand.value?.id
|
||||
const combo = newBindingKeyCombo.value
|
||||
cancelEdit()
|
||||
if (!combo || commandId == undefined) return
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
|
||||
async function resetKeybinding(commandData: ICommandData) {
|
||||
if (keybindingStore.resetKeybindingForCommand(commandData.id)) {
|
||||
await keybindingService.persistUserKeybindings()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex w-96 flex-col border-t border-border-default px-4">
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
{{ $t('g.setAKeybindingForTheFollowing') }}
|
||||
</p>
|
||||
<div class="mb-4 text-sm text-base-foreground">
|
||||
{{ commandLabel }}
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="text-foreground mb-4 w-full rounded-sm border border-border-default bg-secondary-background px-3 py-2 text-center shadow-none focus:outline-none"
|
||||
:value="dialogState.newCombo?.toString() ?? ''"
|
||||
:placeholder="$t('g.enterYourKeybind')"
|
||||
:aria-label="$t('g.enterYourKeybind')"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
@keydown.stop.prevent="captureKeybinding"
|
||||
/>
|
||||
<div class="min-h-12">
|
||||
<p
|
||||
v-if="dialogState.newCombo?.isBrowserReserved"
|
||||
class="m-0 text-sm text-destructive-background"
|
||||
>
|
||||
{{ $t('g.browserReservedKeybinding') }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="existingKeybindingOnCombo"
|
||||
class="m-0 text-sm text-destructive-background"
|
||||
>
|
||||
{{ $t('g.keybindingAlreadyExists') }}
|
||||
{{ existingKeybindingOnCombo.commandId }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
|
||||
import type { EditKeybindingDialogState } from '@/composables/useEditKeybindingDialog'
|
||||
|
||||
const { dialogState, onUpdateCombo, existingKeybindingOnCombo } = defineProps<{
|
||||
dialogState: EditKeybindingDialogState
|
||||
commandLabel: string
|
||||
onUpdateCombo: (combo: KeyComboImpl) => void
|
||||
existingKeybindingOnCombo: KeybindingImpl | null
|
||||
}>()
|
||||
|
||||
function captureKeybinding(event: KeyboardEvent) {
|
||||
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
if (event.key === 'Escape') return
|
||||
}
|
||||
onUpdateCombo(KeyComboImpl.fromEvent(event))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex w-full justify-end gap-2 px-4 py-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="md"
|
||||
class="text-muted-foreground"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="
|
||||
existingKeybindingOnCombo
|
||||
? 'destructive'
|
||||
: dialogState.newCombo?.isBrowserReserved
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
"
|
||||
size="md"
|
||||
:disabled="!dialogState.newCombo"
|
||||
class="px-4 py-2"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{
|
||||
existingKeybindingOnCombo
|
||||
? $t('g.overwrite')
|
||||
: dialogState.newCombo?.isBrowserReserved
|
||||
? $t('g.saveAnyway')
|
||||
: $t('g.save')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Reactive } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import type { EditKeybindingDialogState } from '@/composables/useEditKeybindingDialog'
|
||||
import { DIALOG_KEY } from '@/composables/useEditKeybindingDialog'
|
||||
|
||||
const { dialogState, existingKeybindingOnCombo } = defineProps<{
|
||||
dialogState: Reactive<EditKeybindingDialogState>
|
||||
existingKeybindingOnCombo: KeybindingImpl | null
|
||||
}>()
|
||||
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybindingService = useKeybindingService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function handleCancel() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const combo = dialogState.newCombo
|
||||
const commandId = dialogState.commandId
|
||||
if (!combo || !commandId) return
|
||||
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center gap-2 p-4">
|
||||
<p class="m-0 font-semibold">{{ $t('g.modifyKeybinding') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
153
src/components/graph/CanvasModeSelector.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
|
||||
|
||||
const mockExecute = vi.fn()
|
||||
const mockGetCommand = vi.fn().mockReturnValue({
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['V']
|
||||
}
|
||||
}
|
||||
})
|
||||
const mockFormatKeySequence = vi.fn().mockReturnValue('V')
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: mockExecute,
|
||||
getCommand: mockGetCommand,
|
||||
formatKeySequence: mockFormatKeySequence
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: { read_only: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
graphCanvasMenu: {
|
||||
select: 'Select',
|
||||
hand: 'Hand',
|
||||
canvasMode: 'Canvas Mode'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockPopoverHide = vi.fn()
|
||||
|
||||
function createWrapper() {
|
||||
return mount(CanvasModeSelector, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot /></div>',
|
||||
methods: {
|
||||
toggle: vi.fn(),
|
||||
hide: mockPopoverHide
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('CanvasModeSelector', () => {
|
||||
it('should render menu with menuitemradio roles and aria-checked', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menu = wrapper.find('[role="menu"]')
|
||||
expect(menu.exists()).toBe(true)
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
expect(menuItems).toHaveLength(2)
|
||||
|
||||
// Select mode is active (read_only: false), so select is checked
|
||||
expect(menuItems[0].attributes('aria-checked')).toBe('true')
|
||||
expect(menuItems[1].attributes('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('should render menu items as buttons with aria-labels', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
menuItems.forEach((btn) => {
|
||||
expect(btn.element.tagName).toBe('BUTTON')
|
||||
expect(btn.attributes('type')).toBe('button')
|
||||
})
|
||||
expect(menuItems[0].attributes('aria-label')).toBe('Select')
|
||||
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
|
||||
})
|
||||
|
||||
it('should use roving tabindex based on active mode', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
// Select is active (read_only: false) → tabindex 0
|
||||
expect(menuItems[0].attributes('tabindex')).toBe('0')
|
||||
// Hand is inactive → tabindex -1
|
||||
expect(menuItems[1].attributes('tabindex')).toBe('-1')
|
||||
})
|
||||
|
||||
it('should mark icons as aria-hidden', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const icons = wrapper.findAll('[role="menuitemradio"] i')
|
||||
icons.forEach((icon) => {
|
||||
expect(icon.attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]')
|
||||
expect(trigger.exists()).toBe(true)
|
||||
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
|
||||
expect(trigger.attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('should call focus on next item when ArrowDown is pressed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const secondItemEl = menuItems[1].element as HTMLElement
|
||||
const focusSpy = vi.spyOn(secondItemEl, 'focus')
|
||||
|
||||
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call focus on previous item when ArrowUp is pressed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const firstItemEl = menuItems[0].element as HTMLElement
|
||||
const focusSpy = vi.spyOn(firstItemEl, 'focus')
|
||||
|
||||
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close popover on Escape and restore focus to trigger', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]')
|
||||
const triggerEl = trigger.element as HTMLElement
|
||||
const focusSpy = vi.spyOn(triggerEl, 'focus')
|
||||
|
||||
await menuItems[0].trigger('keydown', { key: 'Escape' })
|
||||
expect(mockPopoverHide).toHaveBeenCalled()
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,21 @@
|
||||
variant="secondary"
|
||||
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
|
||||
:style="buttonStyles"
|
||||
:aria-label="$t('graphCanvasMenu.canvasMode')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="isOpen"
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex items-center gap-1 pr-0.5">
|
||||
<div
|
||||
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
|
||||
>
|
||||
<i :class="currentModeIcon" class="block size-4" />
|
||||
<i :class="currentModeIcon" class="block size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<i class="icon-[lucide--chevron-down] block size-4 pr-1.5" />
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] block size-4 pr-1.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -24,31 +30,54 @@
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="popoverPt"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="flex flex-col gap-1"
|
||||
role="menu"
|
||||
:aria-label="$t('graphCanvasMenu.canvasMode')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
:aria-checked="!isCanvasReadOnly"
|
||||
:tabindex="!isCanvasReadOnly ? 0 : -1"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
|
||||
:aria-label="$t('graphCanvasMenu.select')"
|
||||
@click="setMode('select')"
|
||||
@keydown.arrow-down.prevent="focusNextItem"
|
||||
@keydown.arrow-up.prevent="focusPrevItem"
|
||||
@keydown.escape.prevent="closeAndRestoreFocus"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--mouse-pointer-2] size-4" />
|
||||
<i class="icon-[lucide--mouse-pointer-2] size-4" aria-hidden="true" />
|
||||
<span>{{ $t('graphCanvasMenu.select') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
unlockCommandText
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
:aria-checked="isCanvasReadOnly"
|
||||
:tabindex="isCanvasReadOnly ? 0 : -1"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
|
||||
:aria-label="$t('graphCanvasMenu.hand')"
|
||||
@click="setMode('hand')"
|
||||
@keydown.arrow-down.prevent="focusNextItem"
|
||||
@keydown.arrow-up.prevent="focusPrevItem"
|
||||
@keydown.escape.prevent="closeAndRestoreFocus"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--hand] size-4" />
|
||||
<i class="icon-[lucide--hand] size-4" aria-hidden="true" />
|
||||
<span>{{ $t('graphCanvasMenu.hand') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -56,7 +85,7 @@
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -69,6 +98,8 @@ interface Props {
|
||||
defineProps<Props>()
|
||||
const buttonRef = ref<ComponentPublicInstance | null>(null)
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -106,6 +137,43 @@ const setMode = (mode: 'select' | 'hand') => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
async function onPopoverShow() {
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
const checkedItem = menuRef.value?.querySelector<HTMLElement>(
|
||||
'[aria-checked="true"]'
|
||||
)
|
||||
checkedItem?.focus()
|
||||
}
|
||||
|
||||
function onPopoverHide() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function closeAndRestoreFocus() {
|
||||
popover.value?.hide()
|
||||
const el = buttonRef.value?.$el || buttonRef.value
|
||||
;(el as HTMLElement)?.focus()
|
||||
}
|
||||
|
||||
function focusNextItem(event: KeyboardEvent) {
|
||||
const items = getMenuItems(event)
|
||||
const index = items.indexOf(event.target as HTMLElement)
|
||||
items[(index + 1) % items.length]?.focus()
|
||||
}
|
||||
|
||||
function focusPrevItem(event: KeyboardEvent) {
|
||||
const items = getMenuItems(event)
|
||||
const index = items.indexOf(event.target as HTMLElement)
|
||||
items[(index - 1 + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
function getMenuItems(event: KeyboardEvent): HTMLElement[] {
|
||||
const menu = (event.target as HTMLElement).closest('[role="menu"]')
|
||||
if (!menu) return []
|
||||
return Array.from(menu.querySelectorAll('[role="menuitemradio"]'))
|
||||
}
|
||||
|
||||
const popoverPt = computed(() => ({
|
||||
root: {
|
||||
class: 'absolute z-50 -translate-y-2'
|
||||
|
||||
@@ -479,50 +479,53 @@ useEventListener(
|
||||
onMounted(async () => {
|
||||
comfyApp.vueAppReady = true
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
try {
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
return
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
throw settingsError.value
|
||||
}
|
||||
throw settingsError.value
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
}
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => canvasStore.updateSelectedItems()
|
||||
@@ -535,7 +538,7 @@ onMounted(async () => {
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
workflowPersistence.restoreWorkflowTabsState()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
const sharedWorkflowLoadStatus =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
></div>
|
||||
|
||||
<ButtonGroup
|
||||
role="toolbar"
|
||||
:aria-label="t('graphCanvasMenu.canvasToolbar')"
|
||||
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border border-interface-stroke bg-comfy-menu-bg p-2"
|
||||
:style="{
|
||||
...stringifiedMinimapStyles.buttonGroupStyles
|
||||
@@ -30,7 +32,7 @@
|
||||
class="size-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
<i class="icon-[lucide--focus] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -44,7 +46,7 @@
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 px-2 text-xs">
|
||||
<span>{{ canvasStore.appScalePercentage }}%</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4" />
|
||||
<i class="icon-[lucide--chevron-down] size-4" aria-hidden="true" />
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -59,7 +61,7 @@
|
||||
:class="minimapButtonClass"
|
||||
@click="onMinimapToggleClick"
|
||||
>
|
||||
<i class="icon-[lucide--map] size-4" />
|
||||
<i class="icon-[lucide--map] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -78,7 +80,7 @@
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="onLinkVisibilityToggleClick"
|
||||
>
|
||||
<i class="icon-[lucide--route-off] size-4" />
|
||||
<i class="icon-[lucide--route-off] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
||||
'sidebar-right':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
||||
'topbar-right': triggerLocation === 'topbar',
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
@@ -63,7 +60,6 @@ const { isSmall = false } = defineProps<{
|
||||
|
||||
const {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
sidebarLocation,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
@@ -101,25 +97,6 @@ const {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.topbar-right {
|
||||
top: 2rem;
|
||||
right: 1rem;
|
||||
bottom: auto;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -26,15 +26,18 @@ function toggle() {
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0 sm:w-min sm:min-w-0"
|
||||
:class="
|
||||
cn(
|
||||
'fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0',
|
||||
isExpanded ? 'sm:w-[max(400px,40vw)]' : 'sm:w-fit'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'max-w-full min-w-0 overflow-hidden transition-all duration-300',
|
||||
isExpanded
|
||||
? 'max-h-100 w-full sm:w-[max(400px,40vw)]'
|
||||
: 'max-h-0 w-0'
|
||||
isExpanded ? 'max-h-100 w-full' : 'max-h-0 w-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -1,73 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
// Combine our component props with PrimeVue MultiSelect props
|
||||
// Since we use v-bind="$attrs", all PrimeVue props are available
|
||||
interface ExtendedProps extends Partial<MultiSelectProps> {
|
||||
// Our custom props
|
||||
label?: string
|
||||
showSearchBox?: boolean
|
||||
showSelectedCount?: boolean
|
||||
showClearButton?: boolean
|
||||
searchPlaceholder?: string
|
||||
listMaxHeight?: string
|
||||
popoverMinWidth?: string
|
||||
popoverMaxWidth?: string
|
||||
// Override modelValue type to match our Option type
|
||||
modelValue?: SelectOption[]
|
||||
}
|
||||
|
||||
const meta: Meta<ExtendedProps> = {
|
||||
title: 'Components/Input/MultiSelect',
|
||||
const meta: Meta<typeof MultiSelect> = {
|
||||
title: 'Components/Select/MultiSelect',
|
||||
component: MultiSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text'
|
||||
label: { control: 'text' },
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['lg', 'md']
|
||||
},
|
||||
options: {
|
||||
control: 'object'
|
||||
},
|
||||
showSearchBox: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle searchBar visibility'
|
||||
},
|
||||
showSelectedCount: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle selected count visibility'
|
||||
},
|
||||
showClearButton: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle clear button visibility'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
control: 'text'
|
||||
},
|
||||
listMaxHeight: {
|
||||
control: 'text',
|
||||
description: 'Maximum height of the dropdown list'
|
||||
},
|
||||
popoverMinWidth: {
|
||||
control: 'text',
|
||||
description: 'Minimum width of the popover'
|
||||
},
|
||||
popoverMaxWidth: {
|
||||
control: 'text',
|
||||
description: 'Maximum width of the popover'
|
||||
}
|
||||
showSearchBox: { control: 'boolean' },
|
||||
showSelectedCount: { control: 'boolean' },
|
||||
showClearButton: { control: 'boolean' },
|
||||
searchPlaceholder: { control: 'text' }
|
||||
},
|
||||
args: {
|
||||
label: 'Select',
|
||||
options: [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
],
|
||||
label: 'Category',
|
||||
size: 'lg',
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
@@ -78,352 +38,125 @@ const meta: Meta<ExtendedProps> = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleOptions: SelectOption[] = [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref([])
|
||||
const options = args.options || [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
]
|
||||
return { selected, options, args }
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" :label="args.label" :size="args.size" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: (args) => ({
|
||||
export const MediumSize: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const options = args.options || [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
]
|
||||
const selected = ref([options[0], options[1]])
|
||||
return { selected, options, args }
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
|
||||
}),
|
||||
args: {
|
||||
label: 'Select Languages',
|
||||
options: [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
],
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const MultipleSelectors: Story = {
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0], sampleOptions[1]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const WithSearchBox: Story = {
|
||||
args: { showSearchBox: true },
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const frameworkOptions = ref([
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
])
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" />'
|
||||
})
|
||||
}
|
||||
|
||||
const projectOptions = ref([
|
||||
{ name: 'Project A', value: 'proj-a' },
|
||||
{ name: 'Project B', value: 'proj-b' },
|
||||
{ name: 'Project C', value: 'proj-c' },
|
||||
{ name: 'Project D', value: 'proj-d' }
|
||||
])
|
||||
export const AllHeaderFeatures: Story = {
|
||||
args: {
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
|
||||
})
|
||||
}
|
||||
|
||||
const tagOptions = ref([
|
||||
{ name: 'Frontend', value: 'frontend' },
|
||||
{ name: 'Backend', value: 'backend' },
|
||||
{ name: 'Database', value: 'database' },
|
||||
{ name: 'DevOps', value: 'devops' },
|
||||
{ name: 'Testing', value: 'testing' }
|
||||
])
|
||||
|
||||
const selectedFrameworks = ref([])
|
||||
const selectedProjects = ref([])
|
||||
const selectedTags = ref([])
|
||||
|
||||
return {
|
||||
frameworkOptions,
|
||||
projectOptions,
|
||||
tagOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedTags,
|
||||
args
|
||||
}
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const a = ref<SelectOption[]>([])
|
||||
const b = ref<SelectOption[]>([sampleOptions[0]])
|
||||
const c = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { sampleOptions, a, b, c }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
:options="frameworkOptions"
|
||||
label="Select Frameworks"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
:options="projectOptions"
|
||||
label="Select Projects"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedTags"
|
||||
:options="tagOptions"
|
||||
label="Select Tags"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<MultiSelect v-model="a" :options="sampleOptions" label="Default" />
|
||||
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" />
|
||||
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-base-background rounded">
|
||||
<h4 class="font-medium mt-0">Current Selection:</h4>
|
||||
<div class="flex flex-col text-sm">
|
||||
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<MultiSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
|
||||
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" size="md" />
|
||||
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" size="md" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSearchBox: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSelectedCount: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSelectedCount: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithClearButton: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AllHeaderFeatures: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomSearchPlaceholder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
searchPlaceholder: 'Filter packages...'
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxHeight: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `Option ${i + 1}`,
|
||||
value: `option${i + 1}`
|
||||
}))
|
||||
return { selected1, selected2, selected3, manyOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected1"
|
||||
:options="manyOptions"
|
||||
label="Small Dropdown"
|
||||
list-max-height="10rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected2"
|
||||
:options="manyOptions"
|
||||
label="Default Dropdown"
|
||||
list-max-height="28rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected3"
|
||||
:options="manyOptions"
|
||||
label="Large Dropdown"
|
||||
list-max-height="32rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMinWidth: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const options = [
|
||||
{ name: 'A', value: 'a' },
|
||||
{ name: 'B', value: 'b' },
|
||||
{ name: 'Very Long Option Name Here', value: 'long' }
|
||||
]
|
||||
return { selected1, selected2, selected3, options }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<MultiSelect v-model="selected1" :options="options" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
|
||||
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
|
||||
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxWidth: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const longOptions = [
|
||||
{ name: 'Short', value: 'short' },
|
||||
{
|
||||
name: 'This is a very long option name that would normally expand the dropdown',
|
||||
value: 'long1'
|
||||
},
|
||||
{
|
||||
name: 'Another extremely long option that demonstrates max-width constraint',
|
||||
value: 'long2'
|
||||
}
|
||||
]
|
||||
return { selected1, selected2, selected3, longOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
|
||||
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
|
||||
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
controls: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,20 +16,23 @@
|
||||
:pt="{
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'relative inline-flex h-10 cursor-pointer select-none',
|
||||
'relative inline-flex cursor-pointer select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount > 0
|
||||
? 'border-node-component-border'
|
||||
: 'border-transparent',
|
||||
'focus-within:border-node-component-border',
|
||||
{ 'cursor-default opacity-60': props.disabled }
|
||||
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
|
||||
'focus-within:border-base-foreground',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
labelContainer: {
|
||||
class:
|
||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
|
||||
class: cn(
|
||||
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
label: {
|
||||
class: 'p-0'
|
||||
@@ -93,13 +96,12 @@
|
||||
#header
|
||||
>
|
||||
<div class="flex flex-col px-2 pt-2 pb-0">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:show-order="true"
|
||||
:show-border="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
:placeholder="searchPlaceholder"
|
||||
size="sm"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
@@ -130,12 +132,12 @@
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm">
|
||||
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-primary-background text-xs font-semibold text-base-foreground"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
@@ -182,7 +184,7 @@ import MultiSelect from 'primevue/multiselect'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -198,6 +200,8 @@ defineOptions({
|
||||
interface Props {
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Show search box in the panel header */
|
||||
showSearchBox?: boolean
|
||||
/** Show selected count text in the panel header */
|
||||
@@ -217,6 +221,7 @@ interface Props {
|
||||
}
|
||||
const {
|
||||
label,
|
||||
size = 'lg',
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
|
||||
219
src/components/input/SelectDropdown.stories.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Select/SelectDropdown',
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const modelOptions: SelectOption[] = [
|
||||
{ name: 'ACE-Step', value: 'ace-step' },
|
||||
{ name: 'Anima', value: 'anima' },
|
||||
{ name: 'BRIA', value: 'bria' },
|
||||
{ name: 'ByteDance', value: 'bytedance' },
|
||||
{ name: 'Capybara', value: 'capybara' },
|
||||
{ name: 'Chatter Box', value: 'chatter-box' },
|
||||
{ name: 'Chroma', value: 'chroma' },
|
||||
{ name: 'ChronoEdit', value: 'chronoedit' },
|
||||
{ name: 'DWPose', value: 'dwpose' },
|
||||
{ name: 'Depth Anything v2', value: 'depth-anything-v2' },
|
||||
{ name: 'ElevenLabs', value: 'elevenlabs' },
|
||||
{ name: 'Flux', value: 'flux' },
|
||||
{ name: 'HunyuanVideo', value: 'hunyuan-video' },
|
||||
{ name: 'Stable Diffusion', value: 'stable-diffusion' },
|
||||
{ name: 'SDXL', value: 'sdxl' }
|
||||
]
|
||||
|
||||
const useCaseOptions: SelectOption[] = [
|
||||
{ name: 'Text to Image', value: 'text-to-image' },
|
||||
{ name: 'Image to Image', value: 'image-to-image' },
|
||||
{ name: 'Inpainting', value: 'inpainting' },
|
||||
{ name: 'Upscaling', value: 'upscaling' },
|
||||
{ name: 'Video Generation', value: 'video-generation' },
|
||||
{ name: 'Audio Generation', value: 'audio-generation' },
|
||||
{ name: '3D Generation', value: '3d-generation' }
|
||||
]
|
||||
|
||||
const sortOptions: SelectOption[] = [
|
||||
{ name: 'Default', value: 'default' },
|
||||
{ name: 'Recommended', value: 'recommended' },
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
|
||||
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
|
||||
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
|
||||
]
|
||||
|
||||
export const ModelFilter: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([
|
||||
modelOptions[1],
|
||||
modelOptions[2],
|
||||
modelOptions[3]
|
||||
])
|
||||
return { selected, modelOptions }
|
||||
},
|
||||
template: `
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="modelOptions"
|
||||
:label="selected.length === 0 ? 'Models' : selected.length === 1 ? selected[0].name : selected.length + ' Models'"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
class="w-[250px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const UseCaseFilter: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, useCaseOptions }
|
||||
},
|
||||
template: `
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="useCaseOptions"
|
||||
:label="selected.length === 0 ? 'Use Case' : selected.length === 1 ? selected[0].name : selected.length + ' Use Cases'"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--target]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const SortDropdown: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | undefined>('default')
|
||||
return { selected, sortOptions }
|
||||
},
|
||||
template: `
|
||||
<SingleSelect
|
||||
v-model="selected"
|
||||
:options="sortOptions"
|
||||
label="Sort by"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const TemplateFilterBar: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect, SingleSelect },
|
||||
setup() {
|
||||
const selectedModels = ref<SelectOption[]>([
|
||||
modelOptions[1],
|
||||
modelOptions[2],
|
||||
modelOptions[3]
|
||||
])
|
||||
const selectedUseCases = ref<SelectOption[]>([])
|
||||
const sortBy = ref<string | undefined>('default')
|
||||
|
||||
const modelLabel = () => {
|
||||
if (selectedModels.value.length === 0) return 'Models'
|
||||
if (selectedModels.value.length === 1)
|
||||
return selectedModels.value[0].name
|
||||
return selectedModels.value.length + ' Models'
|
||||
}
|
||||
const useCaseLabel = () => {
|
||||
if (selectedUseCases.value.length === 0) return 'Use Case'
|
||||
if (selectedUseCases.value.length === 1)
|
||||
return selectedUseCases.value[0].name
|
||||
return selectedUseCases.value.length + ' Use Cases'
|
||||
}
|
||||
|
||||
return {
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
sortBy,
|
||||
modelOptions,
|
||||
useCaseOptions,
|
||||
sortOptions,
|
||||
modelLabel,
|
||||
useCaseLabel
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-wrap items-center justify-between gap-2" style="min-width: 700px;">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedModels"
|
||||
:options="modelOptions"
|
||||
:label="modelLabel()"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
class="w-[250px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<MultiSelect
|
||||
v-model="selectedUseCases"
|
||||
:options="useCaseOptions"
|
||||
:label="useCaseLabel()"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--target]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
:options="sortOptions"
|
||||
label="Sort by"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
@@ -3,29 +3,31 @@ import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
// SingleSelect already includes options prop, so no need to extend
|
||||
const meta: Meta<typeof SingleSelect> = {
|
||||
title: 'Components/Input/SingleSelect',
|
||||
title: 'Components/Select/SingleSelect',
|
||||
component: SingleSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
options: { control: 'object' },
|
||||
listMaxHeight: {
|
||||
control: 'text',
|
||||
description: 'Maximum height of the dropdown list'
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['lg', 'md']
|
||||
},
|
||||
popoverMinWidth: {
|
||||
control: 'text',
|
||||
description: 'Minimum width of the popover'
|
||||
},
|
||||
popoverMaxWidth: {
|
||||
control: 'text',
|
||||
description: 'Maximum width of the popover'
|
||||
}
|
||||
invalid: { control: 'boolean' },
|
||||
loading: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
label: 'Sorting Type',
|
||||
label: 'Category',
|
||||
size: 'lg',
|
||||
invalid: false,
|
||||
loading: false,
|
||||
options: [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
@@ -37,7 +39,7 @@ const meta: Meta<typeof SingleSelect> = {
|
||||
}
|
||||
|
||||
export default meta
|
||||
export type Story = StoryObj<typeof meta>
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleOptions = [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
@@ -52,205 +54,118 @@ export const Default: Story = {
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const options = args.options || sampleOptions
|
||||
return { selected, options, args }
|
||||
return { selected, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label" />
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="args.options" :label="args.label" :size="args.size" :invalid="args.invalid" :loading="args.loading" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const MediumSize: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
const options = sampleOptions
|
||||
return { selected, options }
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SingleSelect v-model="selected" :options="sampleOptions" label="Sorting Type">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] size-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
`
|
||||
})
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Preselected: Story = {
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('newest')
|
||||
const options = sampleOptions
|
||||
return { selected, options }
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
|
||||
`
|
||||
})
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
export const Invalid: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const options = sampleOptions
|
||||
const a = ref<string | null>(null)
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" invalid />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" loading />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const a = ref<string | null>('popular')
|
||||
const b = ref<string | null>('popular')
|
||||
const c = ref<string | null>('az')
|
||||
return { options, a, b, c }
|
||||
const c = ref<string | null>('popular')
|
||||
const d = ref<string | null>('popular')
|
||||
const e = ref<string | null>('popular')
|
||||
return { sampleOptions, a, b, c, d, e }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="a" :options="options" label="No Icon" />
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<SingleSelect v-model="a" :options="sampleOptions" label="Default" />
|
||||
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" disabled />
|
||||
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" invalid />
|
||||
<SingleSelect v-model="d" :options="sampleOptions" label="Loading" loading />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="b" :options="options" label="With Icon">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<SingleSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
|
||||
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" size="md" disabled />
|
||||
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" size="md" invalid />
|
||||
<SingleSelect v-model="e" :options="sampleOptions" label="Loading" size="md" loading />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxHeight: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `Option ${i + 1}`,
|
||||
value: `option${i + 1}`
|
||||
}))
|
||||
return { selected, manyOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMinWidth: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected1 = ref<string | null>(null)
|
||||
const selected2 = ref<string | null>(null)
|
||||
const selected3 = ref<string | null>(null)
|
||||
const options = [
|
||||
{ name: 'A', value: 'a' },
|
||||
{ name: 'B', value: 'b' },
|
||||
{ name: 'Very Long Option Name Here', value: 'long' }
|
||||
]
|
||||
return { selected1, selected2, selected3, options }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<SingleSelect v-model="selected1" :options="options" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
|
||||
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
|
||||
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxWidth: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected1 = ref<string | null>(null)
|
||||
const selected2 = ref<string | null>(null)
|
||||
const selected3 = ref<string | null>(null)
|
||||
const longOptions = [
|
||||
{ name: 'Short', value: 'short' },
|
||||
{
|
||||
name: 'This is a very long option name that would normally expand the dropdown',
|
||||
value: 'long1'
|
||||
},
|
||||
{
|
||||
name: 'Another extremely long option that demonstrates max-width constraint',
|
||||
value: 'long2'
|
||||
}
|
||||
]
|
||||
return { selected1, selected2, selected3, longOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
|
||||
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
|
||||
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
controls: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,23 +15,26 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: [
|
||||
// container
|
||||
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
class: cn(
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus-within:border-node-component-border',
|
||||
// disabled
|
||||
{ 'opacity-60 cursor-default': props.disabled }
|
||||
]
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
invalid
|
||||
? 'border-destructive-background'
|
||||
: 'border-transparent focus-within:border-node-component-border',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
label: {
|
||||
class:
|
||||
// Align with MultiSelect labelContainer spacing
|
||||
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
class: cn(
|
||||
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
@@ -77,6 +80,8 @@
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
:aria-busy="loading || undefined"
|
||||
:aria-invalid="invalid || undefined"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
@@ -84,8 +89,16 @@
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<div
|
||||
:class="
|
||||
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="loading"
|
||||
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
|
||||
/>
|
||||
<slot v-else name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-base-foreground"
|
||||
@@ -98,9 +111,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<!-- Trigger caret (hidden when loading) -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
<i
|
||||
v-if="!loading"
|
||||
class="icon-[lucide--chevron-down] text-muted-foreground"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
@@ -133,6 +149,9 @@ defineOptions({
|
||||
const {
|
||||
label,
|
||||
options,
|
||||
size = 'lg',
|
||||
invalid = false,
|
||||
loading = false,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
@@ -144,6 +163,12 @@ const {
|
||||
* in getLabel() to map values to their display names.
|
||||
*/
|
||||
options?: SelectOption[]
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Show invalid (destructive) border */
|
||||
invalid?: boolean
|
||||
/** Show loading spinner instead of chevron */
|
||||
loading?: boolean
|
||||
/** Maximum height of the dropdown panel (default: 28rem) */
|
||||
listMaxHeight?: string
|
||||
/** Minimum width of the popover (default: auto) */
|
||||
|
||||
@@ -141,7 +141,7 @@ onMounted(async () => {
|
||||
if (isStandaloneMode && props.modelUrl) {
|
||||
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
|
||||
} else if (props.node) {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
const source = await useLoad3dService().getLoad3dAsync(props.node)
|
||||
if (source) {
|
||||
await viewer.initializeViewer(containerRef.value, source)
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import LogoCFillLoader from './LogoCFillLoader.vue'
|
||||
|
||||
const meta: Meta<typeof LogoCFillLoader> = {
|
||||
title: 'Components/Loader/LogoCFillLoader',
|
||||
component: LogoCFillLoader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg', 'xl']
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['yellow', 'blue', 'white', 'black']
|
||||
},
|
||||
bordered: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disableAnimation: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { size: 'sm' }
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { size: 'lg' }
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: { size: 'xl' }
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
args: { bordered: false }
|
||||
}
|
||||
|
||||
export const Static: Story = {
|
||||
args: { disableAnimation: true }
|
||||
}
|
||||
|
||||
export const BrandColors: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-12">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Yellow</span>
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Blue</span>
|
||||
<LogoCFillLoader size="lg" color="blue" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">White</span>
|
||||
<LogoCFillLoader size="lg" color="white" />
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded" style="background: white">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-600">Black</span>
|
||||
<LogoCFillLoader size="lg" color="black" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-8">
|
||||
<LogoCFillLoader size="sm" color="yellow" />
|
||||
<LogoCFillLoader size="md" color="yellow" />
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
<LogoCFillLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||