mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-29 02:57:18 +00:00
Compare commits
13 Commits
feat/tab-e
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b63fde70 | ||
|
|
c602dce375 | ||
|
|
34b1799b21 | ||
|
|
bacb5570c8 | ||
|
|
8b53d5c807 | ||
|
|
39ce4a23cc | ||
|
|
ef477d0381 | ||
|
|
37c6ddfcd9 | ||
|
|
55c42ee484 | ||
|
|
b04db536a1 | ||
|
|
975d6a360d | ||
|
|
7e137d880b | ||
|
|
8db6fb7733 |
150
.claude/skills/backport-management/SKILL.md
Normal file
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
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
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
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
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/`.
|
||||
1
.gitignore
vendored
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
|
||||
|
||||
47
browser_tests/assets/nodes/load_image_with_ksampler.json
Normal file
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()
|
||||
|
||||
|
||||
58
browser_tests/tests/imagePastePriority.spec.ts
Normal file
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)
|
||||
})
|
||||
}
|
||||
)
|
||||
91
docs/architecture/change-tracker.md
Normal file
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
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -35,7 +35,7 @@ interface Window {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_debug?: boolean
|
||||
posthog_config?: Record<string, unknown>
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -55,7 +55,11 @@ const config: KnipConfig = {
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Loaded via @plugin directive in CSS, not detected by knip
|
||||
'packages/design-system/src/css/lucideStrokePlugin.js'
|
||||
'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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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>
|
||||
@@ -538,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()
|
||||
|
||||
@@ -48,13 +48,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
<Suspense v-if="NightlySurveyController">
|
||||
<component :is="NightlySurveyController" />
|
||||
</Suspense>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
@@ -62,7 +73,7 @@ import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -78,6 +89,13 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const NightlySurveyController =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
() => import('@/platform/surveys/NightlySurveyController.vue')
|
||||
)
|
||||
: undefined
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
34
src/components/ui/chart/ChartBar.vue
Normal file
34
src/components/ui/chart/ChartBar.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
|
||||
"
|
||||
>
|
||||
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useChart } from './useChart'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ChartData<'bar'>
|
||||
options?: ChartOptions<'bar'>
|
||||
ariaLabel: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useChart(
|
||||
canvasRef,
|
||||
ref('bar'),
|
||||
toRef(() => props.data),
|
||||
computed(() => props.options as ChartOptions | undefined)
|
||||
)
|
||||
</script>
|
||||
34
src/components/ui/chart/ChartLine.vue
Normal file
34
src/components/ui/chart/ChartLine.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
|
||||
"
|
||||
>
|
||||
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useChart } from './useChart'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ChartData<'line'>
|
||||
options?: ChartOptions<'line'>
|
||||
ariaLabel: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useChart(
|
||||
canvasRef,
|
||||
ref('line'),
|
||||
toRef(() => props.data),
|
||||
computed(() => props.options as ChartOptions | undefined)
|
||||
)
|
||||
</script>
|
||||
196
src/components/ui/chart/useChart.ts
Normal file
196
src/components/ui/chart/useChart.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
)
|
||||
|
||||
function getCssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getDefaultOptions(type: ChartType): ChartOptions {
|
||||
const foreground = getCssVar('--color-base-foreground') || '#ffffff'
|
||||
const muted = getCssVar('--color-muted-foreground') || '#8a8a8a'
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
align: 'start',
|
||||
labels: {
|
||||
color: foreground,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 16,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
generateLabels(chart) {
|
||||
const datasets = chart.data.datasets
|
||||
return datasets.map((dataset, i) => {
|
||||
const color =
|
||||
(dataset as { borderColor?: string }).borderColor ??
|
||||
(dataset as { backgroundColor?: string }).backgroundColor ??
|
||||
'#888'
|
||||
return {
|
||||
text: dataset.label ?? '',
|
||||
fillStyle: color as string,
|
||||
strokeStyle: color as string,
|
||||
lineWidth: 0,
|
||||
pointStyle: 'circle' as const,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
hoverRadius: 4
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
padding: 8
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: muted + '33',
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: true, color: muted }
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
padding: 4
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: true, color: muted }
|
||||
}
|
||||
},
|
||||
...(type === 'bar' && {
|
||||
datasets: {
|
||||
bar: {
|
||||
borderRadius: { topLeft: 4, topRight: 4 },
|
||||
borderSkipped: false,
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.8
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useChart(
|
||||
canvasRef: Ref<HTMLCanvasElement | null>,
|
||||
type: Ref<ChartType>,
|
||||
data: Ref<ChartData>,
|
||||
options?: Ref<ChartOptions | undefined>
|
||||
) {
|
||||
const chartInstance = ref<Chart | null>(null)
|
||||
|
||||
function createChart() {
|
||||
if (!canvasRef.value) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
const defaults = getDefaultOptions(type.value)
|
||||
const merged = options?.value
|
||||
? deepMerge(defaults, options.value)
|
||||
: defaults
|
||||
|
||||
chartInstance.value = new Chart(canvasRef.value, {
|
||||
type: type.value,
|
||||
data: data.value,
|
||||
options: merged
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(createChart)
|
||||
|
||||
watch([type, data, options ?? ref(undefined)], () => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.data = data.value
|
||||
chartInstance.value.options = options?.value
|
||||
? deepMerge(getDefaultOptions(type.value), options.value)
|
||||
: getDefaultOptions(type.value)
|
||||
chartInstance.value.update()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance.value?.destroy()
|
||||
chartInstance.value = null
|
||||
})
|
||||
|
||||
return { chartInstance }
|
||||
}
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: Record<string, unknown>
|
||||
): T {
|
||||
const result = { ...target } as Record<string, unknown>
|
||||
for (const key of Object.keys(source)) {
|
||||
const srcVal = source[key]
|
||||
const tgtVal = result[key]
|
||||
if (
|
||||
srcVal &&
|
||||
typeof srcVal === 'object' &&
|
||||
!Array.isArray(srcVal) &&
|
||||
tgtVal &&
|
||||
typeof tgtVal === 'object' &&
|
||||
!Array.isArray(tgtVal)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
tgtVal as Record<string, unknown>,
|
||||
srcVal as Record<string, unknown>
|
||||
)
|
||||
} else {
|
||||
result[key] = srcVal
|
||||
}
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
@@ -924,7 +924,8 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}
|
||||
|
||||
// Calculate target spacing based on step size percentage
|
||||
const stepPercentage = store.brushSettings.stepSize / 100
|
||||
const stepPercentage =
|
||||
Math.pow(100, store.brushSettings.stepSize / 100) / 100
|
||||
const targetSpacing = Math.max(
|
||||
1.0,
|
||||
store.brushSettings.size * stepPercentage
|
||||
@@ -1483,7 +1484,8 @@ export function useBrushDrawing(initialSettings?: {
|
||||
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
|
||||
|
||||
// Calculate target spacing based on stepSize
|
||||
const stepPercentage = store.brushSettings.stepSize / 100
|
||||
const stepPercentage =
|
||||
Math.pow(100, store.brushSettings.stepSize / 100) / 100
|
||||
const stepSize = Math.max(
|
||||
1.0,
|
||||
store.brushSettings.size * stepPercentage
|
||||
|
||||
60
src/composables/useEditKeybindingDialog.ts
Normal file
60
src/composables/useEditKeybindingDialog.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import EditKeybindingContent from '@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue'
|
||||
import EditKeybindingFooter from '@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue'
|
||||
import EditKeybindingHeader from '@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue'
|
||||
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
export const DIALOG_KEY = 'edit-keybinding'
|
||||
|
||||
export interface EditKeybindingDialogState {
|
||||
commandId: string
|
||||
newCombo: KeyComboImpl | null
|
||||
currentCombo: KeyComboImpl | null
|
||||
}
|
||||
|
||||
export function useEditKeybindingDialog() {
|
||||
const { showSmallLayoutDialog } = useDialogService()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
function show(options: {
|
||||
commandId: string
|
||||
commandLabel: string
|
||||
currentCombo: KeyComboImpl | null
|
||||
}) {
|
||||
const dialogState = reactive<EditKeybindingDialogState>({
|
||||
commandId: options.commandId,
|
||||
newCombo: options.currentCombo,
|
||||
currentCombo: options.currentCombo
|
||||
})
|
||||
|
||||
const existingKeybindingOnCombo = computed(() => {
|
||||
if (!dialogState.newCombo) return null
|
||||
if (dialogState.currentCombo?.equals(dialogState.newCombo)) return null
|
||||
return keybindingStore.getKeybinding(dialogState.newCombo)
|
||||
})
|
||||
|
||||
function onUpdateCombo(combo: KeyComboImpl) {
|
||||
dialogState.newCombo = combo
|
||||
}
|
||||
|
||||
showSmallLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
headerComponent: EditKeybindingHeader,
|
||||
footerComponent: EditKeybindingFooter,
|
||||
component: EditKeybindingContent,
|
||||
props: {
|
||||
dialogState,
|
||||
onUpdateCombo,
|
||||
commandLabel: options.commandLabel,
|
||||
existingKeybindingOnCombo
|
||||
},
|
||||
headerProps: {},
|
||||
footerProps: { dialogState, existingKeybindingOnCombo }
|
||||
})
|
||||
}
|
||||
|
||||
return { show }
|
||||
}
|
||||
@@ -595,6 +595,34 @@ describe('usePaste', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip node metadata paste when a media node is selected', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isImageNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const nodeData = { nodes: [{ type: 'KSampler' }] }
|
||||
const encoded = btoa(JSON.stringify(nodeData))
|
||||
const html = `<div data-metadata="${encoded}"></div>`
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
dataTransfer.setData('text/plain', 'some text')
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloneDataTransfer', () => {
|
||||
|
||||
@@ -229,7 +229,10 @@ export const usePaste = () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (pasteClipboardItems(data)) return
|
||||
|
||||
const isMediaNodeSelected =
|
||||
isImageNodeSelected || isVideoNodeSelected || isAudioNodeSelected
|
||||
if (!isMediaNodeSelected && pasteClipboardItems(data)) return
|
||||
|
||||
// No image found. Look for node data
|
||||
data = data.getData('text/plain')
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
|
||||
"galleryImage": "Gallery image",
|
||||
"galleryThumbnail": "Gallery thumbnail",
|
||||
"previousImage": "Previous image",
|
||||
"nextImage": "Next image",
|
||||
"switchToGridView": "Switch to grid view",
|
||||
"switchToSingleView": "Switch to single view",
|
||||
"errorLoadingImage": "Error loading image",
|
||||
"errorLoadingVideo": "Error loading video",
|
||||
"failedToDownloadImage": "Failed to download image",
|
||||
@@ -106,6 +110,7 @@
|
||||
"delete": "Delete",
|
||||
"rename": "Rename",
|
||||
"save": "Save",
|
||||
"saveAnyway": "Save Anyway",
|
||||
"saving": "Saving",
|
||||
"no": "No",
|
||||
"cancel": "Cancel",
|
||||
@@ -116,7 +121,6 @@
|
||||
"showRightPanel": "Show right panel",
|
||||
"hideRightPanel": "Hide right panel",
|
||||
"or": "or",
|
||||
"pressKeysForNewBinding": "Press keys for new binding",
|
||||
"defaultBanner": "default banner",
|
||||
"enableOrDisablePack": "Enable or disable pack",
|
||||
"openManager": "Open Manager",
|
||||
@@ -261,6 +265,11 @@
|
||||
"multiSelectDropdown": "Multi-select dropdown",
|
||||
"singleSelectDropdown": "Single-select dropdown",
|
||||
"progressCountOf": "of",
|
||||
"modifyKeybinding": "Modify keybinding",
|
||||
"setAKeybindingForTheFollowing": "Set a keybinding for the following:",
|
||||
"enterYourKeybind": "Enter your keybind",
|
||||
"browserReservedKeybinding": "This shortcut is reserved by some browsers and may have unexpected results.",
|
||||
"browserReservedKeybindingTooltip": "This shortcut conflicts with browser-reserved shortcuts",
|
||||
"keybindingAlreadyExists": "Keybinding already exists on",
|
||||
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
|
||||
"startRecording": "Start Recording",
|
||||
|
||||
39
src/platform/keybindings/keyCombo.test.ts
Normal file
39
src/platform/keybindings/keyCombo.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { KeyComboImpl } from './keyCombo'
|
||||
|
||||
describe('KeyComboImpl', () => {
|
||||
describe('isBrowserReserved', () => {
|
||||
it.each([
|
||||
{ key: 't', ctrl: true, label: 'Ctrl + t' },
|
||||
{ key: 'w', ctrl: true, label: 'Ctrl + w' },
|
||||
{ key: 'F12', label: 'F12' },
|
||||
{ key: 'n', ctrl: true, shift: true, label: 'Ctrl + Shift + n' },
|
||||
{ key: 'r', ctrl: true, label: 'Ctrl + r' },
|
||||
{ key: 'F5', label: 'F5' }
|
||||
])('returns true for $label', ({ key, ctrl, shift }) => {
|
||||
const combo = new KeyComboImpl({
|
||||
key,
|
||||
ctrl: ctrl ?? false,
|
||||
alt: false,
|
||||
shift: shift ?? false
|
||||
})
|
||||
expect(combo.isBrowserReserved).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ key: 'k', ctrl: true, label: 'Ctrl + k' },
|
||||
{ key: 's', alt: true, label: 'Alt + s' },
|
||||
{ key: 'z', ctrl: true, label: 'Ctrl + z' },
|
||||
{ key: 'F6', label: 'F6' }
|
||||
])('returns false for $label', ({ key, ctrl, alt }) => {
|
||||
const combo = new KeyComboImpl({
|
||||
key,
|
||||
ctrl: ctrl ?? false,
|
||||
alt: alt ?? false,
|
||||
shift: false
|
||||
})
|
||||
expect(combo.isBrowserReserved).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { RESERVED_BY_TEXT_INPUT } from './reserved'
|
||||
import { RESERVED_BY_BROWSER, RESERVED_BY_TEXT_INPUT } from './reserved'
|
||||
import type { KeyCombo } from './types'
|
||||
|
||||
export class KeyComboImpl implements KeyCombo {
|
||||
@@ -61,11 +61,15 @@ export class KeyComboImpl implements KeyCombo {
|
||||
return this.shift && this.modifierCount === 1
|
||||
}
|
||||
|
||||
get isBrowserReserved(): boolean {
|
||||
return RESERVED_BY_BROWSER.has(toNormalizedString(this))
|
||||
}
|
||||
|
||||
get isReservedByTextInput(): boolean {
|
||||
return (
|
||||
!this.hasModifier ||
|
||||
this.isShiftOnly ||
|
||||
RESERVED_BY_TEXT_INPUT.has(this.toString())
|
||||
RESERVED_BY_TEXT_INPUT.has(toNormalizedString(this))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,3 +88,12 @@ export class KeyComboImpl implements KeyCombo {
|
||||
return sequences
|
||||
}
|
||||
}
|
||||
|
||||
function toNormalizedString(combo: KeyComboImpl): string {
|
||||
const sequences: string[] = []
|
||||
if (combo.ctrl) sequences.push('Ctrl')
|
||||
if (combo.alt) sequences.push('Alt')
|
||||
if (combo.shift) sequences.push('Shift')
|
||||
sequences.push(combo.key.length === 1 ? combo.key.toLowerCase() : combo.key)
|
||||
return sequences.join(' + ')
|
||||
}
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
export const RESERVED_BY_BROWSER = new Set([
|
||||
'Ctrl + t', // New tab (all browsers)
|
||||
'Ctrl + w', // Close tab (all browsers)
|
||||
'Ctrl + n', // New window (all browsers)
|
||||
'Ctrl + Shift + n', // New incognito/private window (all browsers)
|
||||
'Ctrl + Tab', // Next tab (all browsers)
|
||||
'Ctrl + Shift + Tab', // Previous tab (all browsers)
|
||||
'Ctrl + Shift + Delete', // Clear browsing data (Chrome, Edge, Firefox)
|
||||
'Ctrl + h', // History (all browsers)
|
||||
'Ctrl + j', // Downloads (Chrome, Edge)
|
||||
'Ctrl + d', // Bookmark current page (all browsers)
|
||||
'Ctrl + Shift + b', // Toggle bookmarks bar (Chrome, Edge)
|
||||
'Ctrl + Shift + o', // Bookmarks manager (Chrome, Edge)
|
||||
'Ctrl + Shift + i', // DevTools (all browsers)
|
||||
'Ctrl + Shift + j', // DevTools console (Chrome, Edge)
|
||||
'F5', // Reload page (all browsers)
|
||||
'Ctrl + F5', // Hard reload (all browsers)
|
||||
'Ctrl + r', // Reload page (all browsers)
|
||||
'Ctrl + Shift + r', // Hard reload (all browsers)
|
||||
'F7', // Caret browsing (Firefox, Edge)
|
||||
'F11', // Toggle fullscreen (all browsers)
|
||||
'F12', // DevTools (all browsers)
|
||||
'Alt + F4' // Close window (Windows, all browsers)
|
||||
])
|
||||
|
||||
export const RESERVED_BY_TEXT_INPUT = new Set([
|
||||
'Ctrl + a',
|
||||
'Ctrl + c',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PostHogConfig } from 'posthog-js'
|
||||
|
||||
import type { TelemetryEventName } from '@/platform/telemetry/types'
|
||||
|
||||
/**
|
||||
@@ -31,7 +33,7 @@ export type RemoteConfig = {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_debug?: boolean
|
||||
posthog_config?: Partial<PostHogConfig>
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
max_upload_size?: number
|
||||
|
||||
14
src/platform/surveys/NightlySurveyController.vue
Normal file
14
src/platform/surveys/NightlySurveyController.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NightlySurveyPopover from './NightlySurveyPopover.vue'
|
||||
import { getEnabledSurveys } from './surveyRegistry'
|
||||
|
||||
const enabledSurveys = computed(() => getEnabledSurveys())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="config in enabledSurveys" :key="config.featureId">
|
||||
<NightlySurveyPopover :config="config" />
|
||||
</template>
|
||||
</template>
|
||||
77
src/platform/surveys/useSurveyFeatureTracking.test.ts
Normal file
77
src/platform/surveys/useSurveyFeatureTracking.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getSurveyConfig = vi.hoisted(() =>
|
||||
vi.fn<(featureId: string) => { enabled: boolean } | undefined>()
|
||||
)
|
||||
|
||||
vi.mock('./surveyRegistry', () => ({
|
||||
getSurveyConfig
|
||||
}))
|
||||
|
||||
describe('useSurveyFeatureTracking', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.resetModules()
|
||||
getSurveyConfig.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('tracks usage when config is enabled', async () => {
|
||||
getSurveyConfig.mockReturnValue({ enabled: true })
|
||||
|
||||
const { useSurveyFeatureTracking } =
|
||||
await import('./useSurveyFeatureTracking')
|
||||
const { trackFeatureUsed, useCount } =
|
||||
useSurveyFeatureTracking('test-feature')
|
||||
|
||||
expect(useCount.value).toBe(0)
|
||||
|
||||
trackFeatureUsed()
|
||||
|
||||
expect(useCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('does not track when config is disabled', async () => {
|
||||
getSurveyConfig.mockReturnValue({ enabled: false })
|
||||
|
||||
const { useSurveyFeatureTracking } =
|
||||
await import('./useSurveyFeatureTracking')
|
||||
const { trackFeatureUsed, useCount } =
|
||||
useSurveyFeatureTracking('disabled-feature')
|
||||
|
||||
trackFeatureUsed()
|
||||
|
||||
expect(useCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('tracks usage when config exists without enabled field', async () => {
|
||||
getSurveyConfig.mockReturnValue({} as { enabled: boolean })
|
||||
|
||||
const { useSurveyFeatureTracking } =
|
||||
await import('./useSurveyFeatureTracking')
|
||||
const { trackFeatureUsed, useCount } = useSurveyFeatureTracking(
|
||||
'implicit-enabled-feature'
|
||||
)
|
||||
|
||||
trackFeatureUsed()
|
||||
|
||||
expect(useCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('does not track when config does not exist', async () => {
|
||||
getSurveyConfig.mockReturnValue(undefined)
|
||||
|
||||
const { useSurveyFeatureTracking } =
|
||||
await import('./useSurveyFeatureTracking')
|
||||
const { trackFeatureUsed, useCount } = useSurveyFeatureTracking(
|
||||
'nonexistent-feature'
|
||||
)
|
||||
|
||||
trackFeatureUsed()
|
||||
|
||||
expect(useCount.value).toBe(0)
|
||||
})
|
||||
})
|
||||
35
src/platform/surveys/useSurveyFeatureTracking.ts
Normal file
35
src/platform/surveys/useSurveyFeatureTracking.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getSurveyConfig } from './surveyRegistry'
|
||||
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
|
||||
|
||||
/**
|
||||
* Convenience composable for tracking feature usage for surveys.
|
||||
* Use this at the feature site to track when a feature is used.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { trackFeatureUsed } = useSurveyFeatureTracking('simple-mode')
|
||||
*
|
||||
* function onFeatureAction() {
|
||||
* trackFeatureUsed()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSurveyFeatureTracking(featureId: string) {
|
||||
const config = getSurveyConfig(featureId)
|
||||
|
||||
if (config?.enabled === false || !config) {
|
||||
return {
|
||||
trackFeatureUsed: () => {},
|
||||
useCount: computed(() => 0)
|
||||
}
|
||||
}
|
||||
|
||||
const { trackUsage, useCount } = useFeatureUsageTracker(featureId)
|
||||
|
||||
return {
|
||||
trackFeatureUsed: trackUsage,
|
||||
useCount
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,12 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockRemoteConfig = vi.hoisted(
|
||||
() => ({ value: null }) as { value: Record<string, unknown> | null }
|
||||
)
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: null }
|
||||
remoteConfig: mockRemoteConfig
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => hoisted.mockPosthog)
|
||||
@@ -61,6 +65,7 @@ function createProvider(
|
||||
describe('PostHogTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRemoteConfig.value = null
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token'
|
||||
} as typeof window.__CONFIG__
|
||||
@@ -76,7 +81,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls posthog.init with the token and default api_host', async () => {
|
||||
it('calls posthog.init with the token and default config', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -93,17 +98,22 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('enables debug mode when posthog_debug is true in config', async () => {
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token',
|
||||
posthog_debug: true
|
||||
} as typeof window.__CONFIG__
|
||||
new PostHogTelemetryProvider()
|
||||
it('applies posthog_config overrides from remote config', async () => {
|
||||
mockRemoteConfig.value = {
|
||||
posthog_config: {
|
||||
debug: true,
|
||||
api_host: 'https://custom.host.com'
|
||||
}
|
||||
}
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ debug: true })
|
||||
expect.objectContaining({
|
||||
debug: true,
|
||||
api_host: 'https://custom.host.com'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
void import('posthog-js')
|
||||
.then((posthogModule) => {
|
||||
this.posthog = posthogModule.default
|
||||
const serverConfig = remoteConfig.value?.posthog_config ?? {}
|
||||
this.posthog!.init(apiKey, {
|
||||
api_host:
|
||||
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
|
||||
@@ -109,9 +110,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug:
|
||||
window.__CONFIG__?.posthog_debug ??
|
||||
import.meta.env.VITE_POSTHOG_DEBUG === 'true'
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as I18n from 'vue-i18n'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
persistRef: null as { value: boolean } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
settingMocks.persistRef = ref(true)
|
||||
return {
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Workflow.Persist')
|
||||
return settingMocks.persistRef!.value
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader',
|
||||
() => ({
|
||||
useSharedWorkflowUrlLoader: () => ({
|
||||
loadSharedWorkflowFromUrl: vi.fn().mockResolvedValue('not-present')
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof I18n>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const openWorkflowMock = vi.fn()
|
||||
const loadBlankWorkflowMock = vi.fn()
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
openWorkflow: openWorkflowMock,
|
||||
loadBlankWorkflow: loadBlankWorkflowMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
|
||||
() => ({
|
||||
useTemplateUrlLoader: () => ({
|
||||
loadTemplateFromUrl: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: {}
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserLogout: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
|
||||
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('../migration/migrateV1toV2', () => ({
|
||||
migrateV1toV2: vi.fn()
|
||||
}))
|
||||
|
||||
type GraphChangedHandler = (() => void) | null
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
graphChangedHandler: null as GraphChangedHandler,
|
||||
currentGraph: {} as Record<string, unknown>
|
||||
}
|
||||
const serializeMock = vi.fn(() => state.currentGraph)
|
||||
const loadGraphDataMock = vi.fn()
|
||||
const apiMock = {
|
||||
clientId: 'test-client',
|
||||
initialClientId: 'test-client',
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
state.graphChangedHandler = handler
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
return { state, serializeMock, loadGraphDataMock, apiMock }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
rootGraph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
|
||||
canvas: {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mocks.apiMock
|
||||
}))
|
||||
|
||||
describe('useWorkflowPersistenceV2', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
settingMocks.persistRef!.value = true
|
||||
mocks.state.graphChangedHandler = null
|
||||
mocks.state.currentGraph = { initial: true }
|
||||
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
|
||||
mocks.loadGraphDataMock.mockReset()
|
||||
mocks.apiMock.clientId = 'test-client'
|
||||
mocks.apiMock.initialClientId = 'test-client'
|
||||
mocks.apiMock.addEventListener.mockImplementation(
|
||||
(event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
mocks.state.graphChangedHandler = handler
|
||||
}
|
||||
}
|
||||
)
|
||||
mocks.apiMock.removeEventListener.mockImplementation(() => {})
|
||||
openWorkflowMock.mockReset()
|
||||
loadBlankWorkflowMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function writeTabState(paths: string[], activeIndex: number) {
|
||||
const pointer = {
|
||||
workspaceId: 'personal',
|
||||
paths,
|
||||
activeIndex
|
||||
}
|
||||
sessionStorage.setItem(
|
||||
`Comfy.Workflow.OpenPaths:test-client`,
|
||||
JSON.stringify(pointer)
|
||||
)
|
||||
}
|
||||
|
||||
function writeActivePath(path: string) {
|
||||
const pointer = {
|
||||
workspaceId: 'personal',
|
||||
path
|
||||
}
|
||||
sessionStorage.setItem(
|
||||
`Comfy.Workflow.ActivePath:test-client`,
|
||||
JSON.stringify(pointer)
|
||||
)
|
||||
}
|
||||
|
||||
describe('loadPreviousWorkflowFromStorage', () => {
|
||||
it('loads saved workflow when draft is missing for session path', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const savedWorkflow = workflowStore.createTemporary('SavedWorkflow.json')
|
||||
|
||||
// Set session path to the saved workflow but do NOT create a draft
|
||||
writeActivePath(savedWorkflow.path)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should call workflowService.openWorkflow with the saved workflow
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(savedWorkflow)
|
||||
// Should NOT fall through to loadGraphData (fallbackToLatestDraft)
|
||||
expect(mocks.loadGraphDataMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers draft over saved workflow when draft exists', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
const workflow = workflowStore.createTemporary('DraftWorkflow.json')
|
||||
const draftData = JSON.stringify({ nodes: [], title: 'draft' })
|
||||
draftStore.saveDraft(workflow.path, draftData, {
|
||||
name: 'DraftWorkflow.json',
|
||||
isTemporary: true
|
||||
})
|
||||
writeActivePath(workflow.path)
|
||||
|
||||
mocks.loadGraphDataMock.mockResolvedValue(undefined)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should load draft via loadGraphData, not via workflowService.openWorkflow
|
||||
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to latest draft only when no session path exists', async () => {
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// No session path set, but a draft exists
|
||||
const draftData = JSON.stringify({ nodes: [], title: 'latest' })
|
||||
draftStore.saveDraft('workflows/Other.json', draftData, {
|
||||
name: 'Other.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
mocks.loadGraphDataMock.mockResolvedValue(undefined)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should load via fallbackToLatestDraft
|
||||
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreWorkflowTabsState', () => {
|
||||
it('activates the correct workflow at storedActiveIndex', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// Create two temporary workflows with drafts
|
||||
const workflowA = workflowStore.createTemporary('WorkflowA.json')
|
||||
const workflowB = workflowStore.createTemporary('WorkflowB.json')
|
||||
|
||||
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
|
||||
name: 'WorkflowA.json',
|
||||
isTemporary: true
|
||||
})
|
||||
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
|
||||
name: 'WorkflowB.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
// storedActiveIndex = 1 → WorkflowB should be activated
|
||||
writeTabState([workflowA.path, workflowB.path], 1)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(workflowB)
|
||||
})
|
||||
|
||||
it('activates first tab when storedActiveIndex is 0', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
const workflowA = workflowStore.createTemporary('WorkflowA.json')
|
||||
const workflowB = workflowStore.createTemporary('WorkflowB.json')
|
||||
|
||||
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
|
||||
name: 'WorkflowA.json',
|
||||
isTemporary: true
|
||||
})
|
||||
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
|
||||
name: 'WorkflowB.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
writeTabState([workflowA.path, workflowB.path], 0)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(workflowA)
|
||||
})
|
||||
|
||||
it('does not call openWorkflow when no restorable state', async () => {
|
||||
// No tab state written to sessionStorage
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores temporary workflows and adds them to tabs', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// Save a draft for a workflow that doesn't exist in the store yet
|
||||
const path = 'workflows/Unsaved.json'
|
||||
draftStore.saveDraft(path, JSON.stringify({ title: 'Unsaved' }), {
|
||||
name: 'Unsaved.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
writeTabState([path], 0)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
const restored = workflowStore.getWorkflowByPath(path)
|
||||
expect(restored).toBeTruthy()
|
||||
expect(restored?.isTemporary).toBe(true)
|
||||
expect(workflowStore.openWorkflows.map((w) => w?.path)).toContain(path)
|
||||
})
|
||||
|
||||
it('skips activation when persistence is disabled', async () => {
|
||||
settingMocks.persistRef!.value = false
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -132,19 +132,28 @@ export function useWorkflowPersistenceV2() {
|
||||
const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS)
|
||||
|
||||
const loadPreviousWorkflowFromStorage = async () => {
|
||||
// 1. Try session pointer (for tab restoration)
|
||||
const sessionPath = tabState.getActivePath()
|
||||
|
||||
// 1. Try draft for session path
|
||||
if (
|
||||
sessionPath &&
|
||||
(await draftStore.loadPersistedWorkflow({
|
||||
workflowName: null,
|
||||
preferredPath: sessionPath
|
||||
}))
|
||||
) {
|
||||
)
|
||||
return true
|
||||
|
||||
// 2. Try saved workflow by path (draft may not exist for saved+unmodified workflows)
|
||||
if (sessionPath) {
|
||||
const saved = workflowStore.getWorkflowByPath(sessionPath)
|
||||
if (saved) {
|
||||
await useWorkflowService().openWorkflow(saved)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to most recent draft
|
||||
// 3. Fall back to most recent draft
|
||||
return await draftStore.loadPersistedWorkflow({
|
||||
workflowName: null,
|
||||
fallbackToLatestDraft: true
|
||||
@@ -242,7 +251,7 @@ export function useWorkflowPersistenceV2() {
|
||||
}
|
||||
})
|
||||
|
||||
const restoreWorkflowTabsState = () => {
|
||||
const restoreWorkflowTabsState = async () => {
|
||||
if (!workflowPersistenceEnabled.value) {
|
||||
tabStateRestored = true
|
||||
return
|
||||
@@ -254,10 +263,11 @@ export function useWorkflowPersistenceV2() {
|
||||
const storedWorkflows = storedTabState?.paths ?? []
|
||||
const storedActiveIndex = storedTabState?.activeIndex ?? -1
|
||||
|
||||
tabStateRestored = true
|
||||
|
||||
const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0
|
||||
if (!isRestorable) return
|
||||
if (!isRestorable) {
|
||||
tabStateRestored = true
|
||||
return
|
||||
}
|
||||
|
||||
storedWorkflows.forEach((path: string) => {
|
||||
if (workflowStore.getWorkflowByPath(path)) return
|
||||
@@ -280,6 +290,17 @@ export function useWorkflowPersistenceV2() {
|
||||
left: storedWorkflows.slice(0, storedActiveIndex),
|
||||
right: storedWorkflows.slice(storedActiveIndex)
|
||||
})
|
||||
|
||||
tabStateRestored = true
|
||||
|
||||
// Activate the correct workflow at storedActiveIndex
|
||||
const activePath = storedWorkflows[storedActiveIndex]
|
||||
const workflow = activePath
|
||||
? workflowStore.getWorkflowByPath(activePath)
|
||||
: null
|
||||
if (workflow) {
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -365,6 +365,7 @@ describe('linearOutputStore', () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
@@ -387,12 +388,14 @@ describe('linearOutputStore', () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Direct transition: job-1 → job-2 (no null in between)
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-2'
|
||||
await nextTick()
|
||||
|
||||
@@ -631,7 +634,7 @@ describe('linearOutputStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('resets state when leaving app mode', async () => {
|
||||
it('preserves in-progress items when leaving app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
@@ -643,9 +646,65 @@ describe('linearOutputStore', () => {
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
expect(store.selectedId).toBeNull()
|
||||
expect(store.pendingResolve.size).toBe(0)
|
||||
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
||||
expect(store.selectedId).toBe('slot:some-id')
|
||||
})
|
||||
|
||||
it('completes stale tracked job when re-entering app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Switch away — job finishes while we're gone
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
activeJobIdRef.value = null
|
||||
await nextTick()
|
||||
|
||||
// Switch back — store should reconcile the stale tracked job
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers latent preview when re-entering app mode', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
// First node executes, consuming the skeleton
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
expect(store.inProgressItems[0].state).toBe('image')
|
||||
|
||||
// Switch away — latent preview arrives for next node while gone
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
previewsRef.value = {
|
||||
'job-1': { url: 'blob:preview-while-away', nodeId: 'node-2' }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Switch back — should recover the latent preview
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
const latentItems = store.inProgressItems.filter(
|
||||
(i) => i.state === 'latent'
|
||||
)
|
||||
expect(latentItems).toHaveLength(1)
|
||||
expect(latentItems[0].latentPreviewUrl).toBe('blob:preview-while-away')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not show in-progress items from another workflow', () => {
|
||||
@@ -785,4 +844,472 @@ describe('linearOutputStore', () => {
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('workflow switching during generation', () => {
|
||||
async function setup() {
|
||||
vi.useFakeTimers()
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
return { store, nextTick }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves images and latent previews across tab switch', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job, produce 1 image + 1 latent
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
store.onLatentPreview('job-a', 'blob:node2-latent', '2')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
const imagesBefore = store.inProgressItems.filter(
|
||||
(i) => i.state === 'image'
|
||||
)
|
||||
const latentsBefore = store.inProgressItems.filter(
|
||||
(i) => i.state === 'latent'
|
||||
)
|
||||
expect(imagesBefore).toHaveLength(1)
|
||||
expect(latentsBefore).toHaveLength(1)
|
||||
|
||||
// Switch to workflow B (graph mode)
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Items still in store (not reset)
|
||||
expect(store.inProgressItems.filter((i) => i.state === 'image')).toEqual(
|
||||
imagesBefore
|
||||
)
|
||||
expect(store.inProgressItems.filter((i) => i.state === 'latent')).toEqual(
|
||||
latentsBefore
|
||||
)
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Items visible via activeWorkflowInProgressItems
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
|
||||
expect(
|
||||
store.activeWorkflowInProgressItems.some((i) => i.state === 'image')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('captures outputs produced while viewing another tab', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job, produce 1 image
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(1)
|
||||
|
||||
// Switch away
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// While away: node 2 executes (event missed — listener removed)
|
||||
// Node 3 starts sending latent previews (watcher guarded)
|
||||
previewsRef.value = {
|
||||
'job-a': { url: 'blob:node3-latent', nodeId: '3' }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Switch back
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Original image preserved + latent preview recovered
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'latent')
|
||||
).toHaveLength(1)
|
||||
expect(store.inProgressItems[0].latentPreviewUrl).toBe(
|
||||
'blob:node3-latent'
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes items to correct workflow after switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch to workflow B
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
|
||||
// Workflow A items should NOT appear for workflow B
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
|
||||
// Workflow A items should reappear
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
||||
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-a')
|
||||
expect(store.activeWorkflowInProgressItems[0].state).toBe('image')
|
||||
})
|
||||
|
||||
it('completes job A while away and starts tracking job B on return', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start and partially generate
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch to workflow B (graph mode)
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// While away: job A finishes, job B starts
|
||||
setJobWorkflowPath('job-b', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-b'
|
||||
await nextTick()
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Job A should have been completed (pending resolve)
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Job B should have been started
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles job finishing while away with no new job', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch away
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Job finishes, no new job
|
||||
activeJobIdRef.value = null
|
||||
await nextTick()
|
||||
|
||||
// Switch back
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Job A completed, images pending resolve
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
// No new skeleton should have been created (no active job)
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'skeleton')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not leak workflow A items into workflow B view', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: two images
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
store.onNodeExecuted(
|
||||
'job-a',
|
||||
makeExecutedDetail(
|
||||
'job-a',
|
||||
[{ filename: 'b.png', subfolder: '', type: 'output' }],
|
||||
'2'
|
||||
)
|
||||
)
|
||||
|
||||
// Items exist in the global list
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
|
||||
// Switch to workflow B (also app mode)
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
|
||||
// Workflow B should see nothing from job-a
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
|
||||
// Global list still has them
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cleans up stale tracked job when leaving app mode after job finishes', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Job finishes while still in app mode
|
||||
store.onJobComplete('job-a')
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Now switch away — pendingResolve items stay (still-running case
|
||||
// doesn't apply, but items are kept for history absorption)
|
||||
activeJobIdRef.value = null
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Items preserved (pendingResolve is not cleared on exit)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts prior pendingResolve entries when a new job completes', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// Job 1: produce image, complete
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(true)
|
||||
|
||||
// Job 2: produce image, complete — should evict job-1
|
||||
store.onJobStart('job-2')
|
||||
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
|
||||
store.onJobComplete('job-2')
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(false)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(false)
|
||||
// Job 2 is now pending resolve
|
||||
expect(store.pendingResolve.has('job-2')).toBe(true)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cleans up finished tracked job on exit when job ended while in app mode', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Job ends, new job starts — all while in app mode
|
||||
setJobWorkflowPath('job-b', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-b'
|
||||
await nextTick()
|
||||
|
||||
// job-a completed via activeJobId watcher, now pending resolve
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Switch away — tracked job is now job-b which is still active, so
|
||||
// the else-branch does NOT complete it (it's still running)
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// job-b items preserved (still running)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not leak items across many job cycles', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const jobId = `job-${i}`
|
||||
setJobWorkflowPath(jobId, 'workflows/test-workflow.json')
|
||||
store.onJobStart(jobId)
|
||||
store.onNodeExecuted(jobId, makeExecutedDetail(jobId))
|
||||
store.onJobComplete(jobId)
|
||||
}
|
||||
|
||||
// Only the last job should have items (pending resolve).
|
||||
// All prior jobs were evicted by subsequent onJobComplete calls.
|
||||
expect(store.pendingResolve.size).toBe(1)
|
||||
expect(store.pendingResolve.has('job-5')).toBe(true)
|
||||
expect(store.inProgressItems.every((i) => i.jobId === 'job-5')).toBe(true)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not adopt another workflow job when switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Tab A: queue "cat"
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-cat', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-cat'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted(
|
||||
'job-cat',
|
||||
makeExecutedDetail('job-cat', undefined, '1')
|
||||
)
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
||||
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-cat')
|
||||
|
||||
// Switch to tab B (different workflow, also app mode): queue "dog"
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
setJobWorkflowPath('job-dog', 'workflows/app-b.json')
|
||||
activeJobIdRef.value = 'job-dog'
|
||||
await nextTick()
|
||||
|
||||
// Tab B should see dog, not cat
|
||||
expect(
|
||||
store.activeWorkflowInProgressItems.every((i) => i.jobId === 'job-dog')
|
||||
).toBe(true)
|
||||
|
||||
// Switch back to tab A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Dog's executed events arrive while viewing tab A (listener is
|
||||
// active and activeJobId is still job-dog).
|
||||
const event = new CustomEvent('executed', {
|
||||
detail: makeExecutedDetail(
|
||||
'job-dog',
|
||||
[{ filename: 'dog.png', subfolder: '', type: 'output' }],
|
||||
'1'
|
||||
)
|
||||
})
|
||||
apiTarget.dispatchEvent(event)
|
||||
|
||||
// Dog's latent preview also arrives
|
||||
previewsRef.value = {
|
||||
'job-dog': { url: 'blob:dog-latent', nodeId: '2' }
|
||||
}
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Tab A must NOT show dog — only cat
|
||||
const tabAItems = store.activeWorkflowInProgressItems
|
||||
expect(tabAItems.every((i) => i.jobId === 'job-cat')).toBe(true)
|
||||
expect(tabAItems.some((i) => i.jobId === 'job-dog')).toBe(false)
|
||||
|
||||
// Selection must not have been yanked to a dog item
|
||||
expect(store.selectedId?.includes('job-dog') ?? false).toBe(false)
|
||||
|
||||
// Dog should still exist globally (scoped to tab B)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-dog')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not create skeleton for next job from another workflow', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Run dog on dog tab
|
||||
activeWorkflowPathRef.value = 'workflows/dog.json'
|
||||
setJobWorkflowPath('job-dog', 'workflows/dog.json')
|
||||
activeJobIdRef.value = 'job-dog'
|
||||
await nextTick()
|
||||
|
||||
// Swap to cat tab, queue cat (dog still running)
|
||||
activeWorkflowPathRef.value = 'workflows/cat.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
setJobWorkflowPath('job-cat', 'workflows/cat.json')
|
||||
|
||||
// Swap back to dog tab
|
||||
activeWorkflowPathRef.value = 'workflows/dog.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Dog finishes, cat starts (activeJobId transitions on dog tab)
|
||||
activeJobIdRef.value = 'job-cat'
|
||||
await nextTick()
|
||||
|
||||
// Dog tab must NOT show cat's skeleton
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
// No skeleton for cat should have been created at all
|
||||
expect(
|
||||
store.inProgressItems.some(
|
||||
(i) => i.jobId === 'job-cat' && i.state === 'skeleton'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('processes new executed events after switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch away and back
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Fire an executed event via the API — listener should be re-attached
|
||||
const event = new CustomEvent('executed', {
|
||||
detail: makeExecutedDetail(
|
||||
'job-a',
|
||||
[{ filename: 'c.png', subfolder: '', type: 'output' }],
|
||||
'3'
|
||||
)
|
||||
})
|
||||
apiTarget.dispatchEvent(event)
|
||||
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -167,6 +167,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
}
|
||||
|
||||
function onJobComplete(jobId: string) {
|
||||
// On any job complete, remove all pending resolve items.
|
||||
if (pendingResolve.value.size > 0) {
|
||||
for (const oldJobId of pendingResolve.value) {
|
||||
removeJobItems(oldJobId)
|
||||
}
|
||||
pendingResolve.value = new Set()
|
||||
}
|
||||
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
@@ -226,11 +234,16 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
function isJobForActiveWorkflow(jobId: string): boolean {
|
||||
return (
|
||||
executionStore.jobIdToSessionWorkflowPath.get(jobId) ===
|
||||
workflowStore.activeWorkflow?.path
|
||||
)
|
||||
}
|
||||
|
||||
function autoSelect(slotId: string, jobId: string) {
|
||||
// Only auto-select if the job belongs to the active workflow
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
|
||||
return
|
||||
if (!isJobForActiveWorkflow(jobId)) return
|
||||
|
||||
const sel = selectedId.value
|
||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
||||
@@ -249,20 +262,6 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
onNodeExecuted(jobId, detail)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
executedNodeIds.clear()
|
||||
inProgressItems.value = []
|
||||
selectedId.value = null
|
||||
isFollowing.value = true
|
||||
trackedJobId.value = null
|
||||
currentSkeletonId.value = null
|
||||
pendingResolve.value = new Set()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => executionStore.activeJobId,
|
||||
(jobId, oldJobId) => {
|
||||
@@ -270,7 +269,10 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
if (oldJobId && oldJobId !== jobId) {
|
||||
onJobComplete(oldJobId)
|
||||
}
|
||||
if (jobId) {
|
||||
// Start tracking only if the job belongs to this workflow.
|
||||
// Jobs from other workflows are picked up by reconcileOnEnter
|
||||
// when the user switches to that workflow's tab.
|
||||
if (jobId && isJobForActiveWorkflow(jobId)) {
|
||||
onJobStart(jobId)
|
||||
}
|
||||
}
|
||||
@@ -288,14 +290,66 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function reconcileOnEnter() {
|
||||
// Complete any tracked job that finished while we were away.
|
||||
// The activeJobId watcher couldn't fire onJobComplete because
|
||||
// isAppMode was false at the time.
|
||||
if (
|
||||
trackedJobId.value &&
|
||||
trackedJobId.value !== executionStore.activeJobId
|
||||
) {
|
||||
onJobComplete(trackedJobId.value)
|
||||
}
|
||||
// Start tracking the current job only if it belongs to this
|
||||
// workflow — otherwise we'd adopt another tab's job.
|
||||
if (
|
||||
executionStore.activeJobId &&
|
||||
trackedJobId.value !== executionStore.activeJobId &&
|
||||
isJobForActiveWorkflow(executionStore.activeJobId)
|
||||
) {
|
||||
onJobStart(executionStore.activeJobId)
|
||||
}
|
||||
|
||||
// Clear stale selection from another workflow's job.
|
||||
if (
|
||||
selectedId.value?.startsWith('slot:') &&
|
||||
trackedJobId.value &&
|
||||
!isJobForActiveWorkflow(trackedJobId.value)
|
||||
) {
|
||||
selectedId.value = null
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
// Re-apply the latest latent preview that may have arrived while
|
||||
// away, but only for a job belonging to the active workflow.
|
||||
const jobId = trackedJobId.value
|
||||
if (jobId && isJobForActiveWorkflow(jobId)) {
|
||||
const preview = jobPreviewStore.nodePreviewsByPromptId[jobId]
|
||||
if (preview) onLatentPreview(jobId, preview.url, preview.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOnLeave() {
|
||||
// If the tracked job already finished (no longer the active job),
|
||||
// complete it now to clean up skeletons/latents. If it's still
|
||||
// running, preserve all items for tab switching.
|
||||
if (
|
||||
trackedJobId.value &&
|
||||
trackedJobId.value !== executionStore.activeJobId
|
||||
) {
|
||||
onJobComplete(trackedJobId.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
isAppMode,
|
||||
(active, wasActive) => {
|
||||
if (active) {
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
reconcileOnEnter()
|
||||
} else if (wasActive) {
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
reset()
|
||||
cleanupOnLeave()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
const SAMPLE_URLS = [
|
||||
'https://picsum.photos/seed/preview1/800/600',
|
||||
'https://picsum.photos/seed/preview2/800/600',
|
||||
'https://picsum.photos/seed/preview3/800/600'
|
||||
]
|
||||
|
||||
const meta: Meta<typeof ImagePreview> = {
|
||||
title: 'Components/Display/ImagePreview',
|
||||
component: ImagePreview,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Node output image preview with navigation dots, keyboard controls, and hover action buttons (download, remove, edit/mask).'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="h-80 w-96 rounded-lg bg-component-node-background"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
imageUrls: [SAMPLE_URLS[0]]
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleImages: Story = {
|
||||
args: {
|
||||
imageUrls: SAMPLE_URLS
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
imageUrls: ['https://invalid.example.com/no-image.png']
|
||||
}
|
||||
}
|
||||
|
||||
export const ManyImages: Story = {
|
||||
args: {
|
||||
imageUrls: Array.from(
|
||||
{ length: 8 },
|
||||
(_, i) => `https://picsum.photos/seed/many${i}/800/600`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ describe('ImagePreview', () => {
|
||||
it('shows navigation dots for multiple images', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
expect(navigationDots).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
expect(navigationDots).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('ImagePreview', () => {
|
||||
)
|
||||
|
||||
// Click second navigation dot
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -259,22 +259,22 @@ describe('ImagePreview', () => {
|
||||
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
|
||||
})
|
||||
|
||||
it('applies correct classes to navigation dots based on current image', async () => {
|
||||
it('marks active navigation dot with aria-current', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
|
||||
// First dot should be active (has bg-white class)
|
||||
expect(navigationDots[0].classes()).toContain('bg-base-foreground')
|
||||
expect(navigationDots[1].classes()).toContain('bg-base-foreground/50')
|
||||
// First dot should be active
|
||||
expect(navigationDots[0].attributes('aria-current')).toBe('true')
|
||||
expect(navigationDots[1].attributes('aria-current')).toBeUndefined()
|
||||
|
||||
// Switch to second image
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Second dot should now be active
|
||||
expect(navigationDots[0].classes()).toContain('bg-base-foreground/50')
|
||||
expect(navigationDots[1].classes()).toContain('bg-base-foreground')
|
||||
expect(navigationDots[0].attributes('aria-current')).toBeUndefined()
|
||||
expect(navigationDots[1].attributes('aria-current')).toBe('true')
|
||||
})
|
||||
|
||||
it('loads image without errors', async () => {
|
||||
@@ -301,7 +301,7 @@ describe('ImagePreview', () => {
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
|
||||
|
||||
// Switch to second image
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('ImagePreview', () => {
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Click second navigation dot to cycle
|
||||
const dots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
const dots = wrapper.findAll('[aria-label*="View image"]')
|
||||
await dots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
ref="imageWrapperEl"
|
||||
class="relative flex min-h-0 w-full flex-1 overflow-hidden rounded-[5px] bg-transparent"
|
||||
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@@ -33,14 +33,19 @@
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="showLoader && !imageError" class="size-full">
|
||||
<Skeleton border-radius="5px" width="100%" height="100%" />
|
||||
<Skeleton class="size-full rounded-sm" />
|
||||
</div>
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="pointer-events-none absolute inset-0 block size-full object-contain"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 block size-full object-contain transition-opacity',
|
||||
(isHovered || isFocused) && 'opacity-60'
|
||||
)
|
||||
"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
@@ -48,7 +53,7 @@
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
<div
|
||||
v-if="isHovered || isFocused"
|
||||
class="actions absolute top-2 right-2 flex gap-2.5"
|
||||
class="actions absolute top-2 right-2 flex gap-1"
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
@@ -78,7 +83,7 @@
|
||||
:aria-label="$t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,15 +124,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { useToast } from 'primevue'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
@@ -141,9 +147,10 @@ const props = defineProps<ImagePreviewProps>()
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
|
||||
// Component state
|
||||
const currentIndex = ref(0)
|
||||
@@ -229,11 +236,10 @@ const handleDownload = () => {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch (error) {
|
||||
useToast().add({
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
group: 'image-preview'
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -280,13 +286,13 @@ const handleFocusOut = (event: FocusEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getNavigationDotClass = (index: number) => {
|
||||
return [
|
||||
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',
|
||||
function getNavigationDotClass(index: number) {
|
||||
return cn(
|
||||
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',
|
||||
index === currentIndex.value
|
||||
? 'bg-base-foreground'
|
||||
: 'bg-base-foreground/50 hover:bg-base-foreground/80'
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import DisplayCarousel from './DisplayCarousel.vue'
|
||||
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
|
||||
|
||||
const SAMPLE_IMAGES = [
|
||||
'https://picsum.photos/seed/comfy1/600/400',
|
||||
'https://picsum.photos/seed/comfy2/600/400',
|
||||
'https://picsum.photos/seed/comfy3/600/400',
|
||||
'https://picsum.photos/seed/comfy4/600/400',
|
||||
'https://picsum.photos/seed/comfy5/600/400'
|
||||
]
|
||||
|
||||
const SAMPLE_IMAGE_OBJECTS: GalleryImage[] = [
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj1/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj1/120/80',
|
||||
alt: 'Mountain landscape'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj2/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj2/120/80',
|
||||
alt: 'Ocean view'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj3/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj3/120/80',
|
||||
alt: 'Forest path'
|
||||
}
|
||||
]
|
||||
|
||||
const meta: Meta<typeof DisplayCarousel> = {
|
||||
title: 'Components/Display/DisplayCarousel',
|
||||
component: DisplayCarousel,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Image gallery with Single (carousel) and Grid display modes. Hover to reveal a toggle button that switches between modes. Grid mode shows images in a responsive grid; clicking an image switches back to single mode focused on that image.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-xl bg-component-node-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGES])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const SingleImage: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([SAMPLE_IMAGES[0]])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithImageObjects: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGE_OBJECTS])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const GridFewImages: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGES.slice(0, 4)])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget, displayMode: ref('grid') }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const GridManyImages: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>(
|
||||
Array.from(
|
||||
{ length: 25 },
|
||||
(_, i) => `https://picsum.photos/seed/grid${i}/200/200`
|
||||
)
|
||||
)
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import DisplayCarousel from './DisplayCarousel.vue'
|
||||
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
galleryImage: 'Gallery image',
|
||||
galleryThumbnail: 'Gallery thumbnail',
|
||||
previousImage: 'Previous image',
|
||||
nextImage: 'Next image',
|
||||
switchToGridView: 'Switch to grid view',
|
||||
switchToSingleView: 'Switch to single view',
|
||||
viewImageOfTotal: 'View image {index} of {total}',
|
||||
editOrMaskImage: 'Edit or mask image',
|
||||
downloadImage: 'Download image',
|
||||
removeImage: 'Remove image'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const TEST_IMAGES_SMALL: readonly string[] = Object.freeze([
|
||||
'https://example.com/image0.jpg',
|
||||
'https://example.com/image1.jpg',
|
||||
'https://example.com/image2.jpg'
|
||||
])
|
||||
|
||||
const TEST_IMAGES_SINGLE: readonly string[] = Object.freeze([
|
||||
'https://example.com/single.jpg'
|
||||
])
|
||||
|
||||
const TEST_IMAGE_OBJECTS: readonly GalleryImage[] = Object.freeze([
|
||||
{
|
||||
itemImageSrc: 'https://example.com/image0.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/thumb0.jpg',
|
||||
alt: 'Test image 0'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://example.com/image1.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/thumb1.jpg',
|
||||
alt: 'Test image 1'
|
||||
}
|
||||
])
|
||||
|
||||
function createGalleriaWidget(
|
||||
value: GalleryValue = [],
|
||||
options: Record<string, unknown> = {}
|
||||
) {
|
||||
return createMockWidget<GalleryValue>({
|
||||
value,
|
||||
name: 'test_galleria',
|
||||
type: 'array',
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<GalleryValue>,
|
||||
modelValue: GalleryValue
|
||||
) {
|
||||
return mount(DisplayCarousel, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n]
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createImageStrings(count: number): string[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `https://example.com/image${i}.jpg`
|
||||
)
|
||||
}
|
||||
|
||||
function createGalleriaWrapper(
|
||||
images: GalleryValue,
|
||||
options: Record<string, unknown> = {}
|
||||
) {
|
||||
const widget = createGalleriaWidget(images, options)
|
||||
return mountComponent(widget, images)
|
||||
}
|
||||
|
||||
function findThumbnails(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.findAll('div').filter((div) => {
|
||||
return div.find('img').exists() && div.classes().includes('border-2')
|
||||
})
|
||||
}
|
||||
|
||||
function findImageContainer(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[tabindex="0"]')
|
||||
}
|
||||
|
||||
describe('DisplayCarousel Single Mode', () => {
|
||||
describe('Component Rendering', () => {
|
||||
it('renders main image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe(TEST_IMAGES_SMALL[0])
|
||||
})
|
||||
|
||||
it('displays empty gallery when no images provided', () => {
|
||||
const wrapper = createGalleriaWrapper([])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles null value gracefully', () => {
|
||||
const widget = createGalleriaWidget([])
|
||||
const wrapper = mountComponent(widget, null as unknown as GalleryValue)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles undefined value gracefully', () => {
|
||||
const widget = createGalleriaWidget([])
|
||||
const wrapper = mountComponent(
|
||||
widget,
|
||||
undefined as unknown as GalleryValue
|
||||
)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Array Input', () => {
|
||||
it('converts string array to image objects and displays first', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
|
||||
it('handles single string image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/single.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Object Array Input', () => {
|
||||
it('preserves image objects and displays first', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGE_OBJECTS])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
expect(img.attributes('alt')).toBe('Test image 0')
|
||||
})
|
||||
|
||||
it('handles mixed object properties with src fallback', () => {
|
||||
const images: GalleryImage[] = [
|
||||
{ src: 'https://example.com/image1.jpg', alt: 'First' }
|
||||
]
|
||||
const wrapper = createGalleriaWrapper(images)
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Thumbnail Display', () => {
|
||||
it('shows thumbnails when multiple images present', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('hides thumbnails for single image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('thumbnails are not interactive', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const thumbnails = findThumbnails(wrapper)
|
||||
expect(thumbnails[0].element.tagName).not.toBe('BUTTON')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation Buttons', () => {
|
||||
it('shows navigation buttons when multiple images present', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Next image"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides navigation buttons for single image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="Next image"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('respects widget option to hide navigation buttons', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL], {
|
||||
showItemNavigators: false
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigates to next image on next click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
|
||||
it('navigates to previous image on prev click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Go to second image first
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Go back
|
||||
await wrapper.find('[aria-label="Previous image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
|
||||
it('wraps from first to last image on previous click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await wrapper.find('[aria-label="Previous image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image2.jpg')
|
||||
})
|
||||
|
||||
it('wraps from last to first image on next click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Navigate to last image
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Next from last should wrap to first
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Accessibility', () => {
|
||||
it('shows controls on focusin for keyboard users', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides controls on focusout when focus leaves component', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Focus leaves the image container entirely
|
||||
await findImageContainer(wrapper).trigger('focusout', {
|
||||
relatedTarget: null
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Grid Mode', () => {
|
||||
it('switches to grid mode via toggle button on hover', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Trigger focus on image container to reveal toggle button
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
const toggleBtn = wrapper.find('[aria-label="Switch to grid view"]')
|
||||
expect(toggleBtn.exists()).toBe(true)
|
||||
|
||||
await toggleBtn.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Grid mode should show all images as grid items
|
||||
const gridImages = wrapper.findAll('img')
|
||||
expect(gridImages).toHaveLength(TEST_IMAGES_SMALL.length)
|
||||
})
|
||||
|
||||
it('does not show grid toggle for single image', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('switches back to single mode via toggle button', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Focus the grid container to reveal toggle
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Switch back to single
|
||||
const singleToggle = wrapper.find('[aria-label="Switch to single view"]')
|
||||
expect(singleToggle.exists()).toBe(true)
|
||||
|
||||
await singleToggle.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should be back in single mode with main image
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clicking grid image switches to single mode focused on that image', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Click second grid image
|
||||
const gridButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((btn) => btn.find('img').exists())
|
||||
await gridButtons[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should be in single mode showing the second image
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
|
||||
it('reverts to single mode when images reduce to one', async () => {
|
||||
const images = ref<GalleryValue>([...TEST_IMAGES_SMALL])
|
||||
const widget = createGalleriaWidget([...TEST_IMAGES_SMALL])
|
||||
const wrapper = mount(DisplayCarousel, {
|
||||
global: { plugins: [createTestingPinia({ createSpy: vi.fn }), i18n] },
|
||||
props: { widget, modelValue: images.value }
|
||||
})
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Reduce to single image
|
||||
await wrapper.setProps({ modelValue: [TEST_IMAGES_SMALL[0]] })
|
||||
await nextTick()
|
||||
|
||||
// Should revert to single mode (no grid toggle visible)
|
||||
expect(wrapper.find('[aria-label="Switch to single view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Edge Cases', () => {
|
||||
it('handles empty array gracefully', () => {
|
||||
const wrapper = createGalleriaWrapper([])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(findThumbnails(wrapper)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('filters out malformed image objects without valid src', () => {
|
||||
const malformedImages = [{}, { randomProp: 'value' }, null, undefined]
|
||||
const wrapper = createGalleriaWrapper(malformedImages as string[])
|
||||
|
||||
// All filtered out: null/undefined removed, then objects without src filtered
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles very large image arrays', () => {
|
||||
const largeImageArray = createImageStrings(100)
|
||||
const wrapper = createGalleriaWrapper(largeImageArray)
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('handles mixed string and object arrays gracefully', () => {
|
||||
const mixedArray = [
|
||||
'https://example.com/string.jpg',
|
||||
{ itemImageSrc: 'https://example.com/object.jpg' },
|
||||
'https://example.com/another-string.jpg'
|
||||
]
|
||||
expect(() => createGalleriaWrapper(mixedArray as string[])).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles invalid URL strings without crashing', () => {
|
||||
const invalidUrls = ['not-a-url', 'http://', 'ftp://invalid']
|
||||
const wrapper = createGalleriaWrapper(invalidUrls)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('filters out empty string URLs', () => {
|
||||
const wrapper = createGalleriaWrapper([''])
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex max-w-full flex-col rounded-lg bg-component-node-widget-background"
|
||||
>
|
||||
<!-- Single Mode -->
|
||||
<template v-if="displayMode === 'single'">
|
||||
<div class="flex flex-col gap-1 p-4">
|
||||
<!-- Main Image Container -->
|
||||
<div
|
||||
ref="imageContainerEl"
|
||||
class="relative flex cursor-pointer items-center justify-center"
|
||||
tabindex="0"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@focusin="isFocused = true"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<img
|
||||
v-if="activeItem"
|
||||
:src="getItemSrc(activeItem)"
|
||||
:alt="getItemAlt(activeItem, activeIndex)"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full rounded-sm object-contain transition-opacity',
|
||||
showControls && 'opacity-50'
|
||||
)
|
||||
"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
<!-- Toggle to Grid (hover, top-left) -->
|
||||
<button
|
||||
v-if="showControls && galleryImages.length > 1"
|
||||
:class="toggleButtonClass"
|
||||
class="absolute top-2 left-2"
|
||||
:aria-label="t('g.switchToGridView')"
|
||||
@click="switchToGrid"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Action Buttons (hover, top-right) -->
|
||||
<div
|
||||
v-if="showControls && activeItem"
|
||||
class="absolute top-2 right-2 flex gap-1"
|
||||
>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.editOrMaskImage')"
|
||||
@click="handleEditMask"
|
||||
>
|
||||
<i-comfy:mask class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.downloadImage')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-down-to-line] size-4" />
|
||||
</button>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'text-center text-xs text-component-node-foreground-secondary',
|
||||
!imageDimensions && 'invisible'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ imageDimensions || '\u00A0' }}
|
||||
</p>
|
||||
|
||||
<!-- Thumbnail Strip with Navigation -->
|
||||
<div
|
||||
v-if="showMultipleImages || showNavButtons"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<!-- Previous Button -->
|
||||
<button
|
||||
v-if="showNavButtons"
|
||||
:class="navButtonClass"
|
||||
:aria-label="t('g.previousImage')"
|
||||
@click="goToPrevious"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left] size-3.5" />
|
||||
</button>
|
||||
|
||||
<!-- Thumbnails -->
|
||||
<div
|
||||
v-if="showMultipleImages"
|
||||
class="flex min-w-0 flex-1 items-center gap-1 overflow-x-hidden scroll-smooth py-1"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in galleryImages"
|
||||
:key="getItemSrc(item)"
|
||||
:ref="(el) => setThumbnailRef(el as HTMLElement | null, index)"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 overflow-hidden rounded-lg p-1 transition-colors',
|
||||
index === activeIndex
|
||||
? 'border-2 border-base-foreground'
|
||||
: 'border-2 border-transparent'
|
||||
)
|
||||
"
|
||||
:aria-label="getItemAlt(item, index)"
|
||||
>
|
||||
<img
|
||||
:src="getItemThumbnail(item)"
|
||||
:alt="getItemAlt(item, index)"
|
||||
class="size-10 rounded-sm object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button
|
||||
v-if="showNavButtons"
|
||||
:class="navButtonClass"
|
||||
:aria-label="t('g.nextImage')"
|
||||
@click="goToNext"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Grid Mode -->
|
||||
<template v-else>
|
||||
<div class="p-4">
|
||||
<div
|
||||
ref="gridContainerEl"
|
||||
class="relative h-72 overflow-x-hidden overflow-y-auto rounded-sm bg-component-node-background"
|
||||
tabindex="0"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@focusin="isFocused = true"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<!-- Toggle to Single (hover, top-left) -->
|
||||
<button
|
||||
v-if="showControls"
|
||||
:class="toggleButtonClass"
|
||||
class="absolute top-2 left-2 z-10"
|
||||
:aria-label="t('g.switchToSingleView')"
|
||||
@click="switchToSingle"
|
||||
>
|
||||
<i class="icon-[lucide--square] size-4" />
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap content-start gap-1">
|
||||
<button
|
||||
v-for="(item, index) in galleryImages"
|
||||
:key="getItemSrc(item)"
|
||||
class="size-14 shrink-0 cursor-pointer overflow-hidden border-0 p-0"
|
||||
:aria-label="getItemAlt(item, index)"
|
||||
@mouseenter="hoveredGridIndex = index"
|
||||
@mouseleave="hoveredGridIndex = -1"
|
||||
@click="selectFromGrid(index)"
|
||||
>
|
||||
<img
|
||||
:src="getItemThumbnail(item)"
|
||||
:alt="getItemAlt(item, index)"
|
||||
:class="
|
||||
cn(
|
||||
'size-full object-cover transition-opacity',
|
||||
hoveredGridIndex === index && 'opacity-50'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface GalleryImage {
|
||||
itemImageSrc?: string
|
||||
thumbnailImageSrc?: string
|
||||
src?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export type GalleryValue = string[] | GalleryImage[]
|
||||
|
||||
type DisplayMode = 'single' | 'grid'
|
||||
|
||||
interface GalleryOptions {
|
||||
showItemNavigators?: boolean
|
||||
}
|
||||
|
||||
const value = defineModel<GalleryValue>({ required: true })
|
||||
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: SimplifiedWidget<GalleryValue>
|
||||
nodeId?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
const displayMode = ref<DisplayMode>('single')
|
||||
const isHovered = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const hoveredGridIndex = ref(-1)
|
||||
const imageDimensions = ref<string | null>(null)
|
||||
const thumbnailRefs = ref<(HTMLElement | null)[]>([])
|
||||
const imageContainerEl = ref<HTMLDivElement>()
|
||||
const gridContainerEl = ref<HTMLDivElement>()
|
||||
|
||||
const showControls = computed(() => isHovered.value || isFocused.value)
|
||||
|
||||
const options = computed<GalleryOptions>(() => widget.options ?? {})
|
||||
|
||||
const galleryImages = computed<GalleryImage[]>(() => {
|
||||
if (!value.value || !Array.isArray(value.value)) return []
|
||||
|
||||
return value.value.flatMap((item) => {
|
||||
if (item === null || item === undefined) return []
|
||||
const image =
|
||||
typeof item === 'string'
|
||||
? { itemImageSrc: item, thumbnailImageSrc: item }
|
||||
: item
|
||||
return image.itemImageSrc || image.src ? [image] : []
|
||||
})
|
||||
})
|
||||
|
||||
const activeItem = computed(() => galleryImages.value[activeIndex.value])
|
||||
|
||||
const showMultipleImages = computed(() => galleryImages.value.length > 1)
|
||||
|
||||
const showNavButtons = computed(
|
||||
() => options.value.showItemNavigators !== false && showMultipleImages.value
|
||||
)
|
||||
|
||||
const actionButtonClass =
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
|
||||
|
||||
const toggleButtonClass = actionButtonClass
|
||||
|
||||
const navButtonClass =
|
||||
'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-0 bg-secondary-background text-component-node-foreground-secondary transition-colors'
|
||||
|
||||
watch(galleryImages, (images) => {
|
||||
thumbnailRefs.value = thumbnailRefs.value.slice(0, images.length)
|
||||
imageDimensions.value = null
|
||||
if (images.length === 0) {
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (activeIndex.value >= images.length) {
|
||||
activeIndex.value = images.length - 1
|
||||
}
|
||||
if (images.length <= 1) {
|
||||
displayMode.value = 'single'
|
||||
}
|
||||
})
|
||||
|
||||
function getItemSrc(item: GalleryImage): string {
|
||||
return item.itemImageSrc || item.src || ''
|
||||
}
|
||||
|
||||
function getItemThumbnail(item: GalleryImage): string {
|
||||
return item.thumbnailImageSrc || item.itemImageSrc || item.src || ''
|
||||
}
|
||||
|
||||
function getItemAlt(item: GalleryImage, index: number): string {
|
||||
return (
|
||||
item.alt ||
|
||||
t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: galleryImages.value.length
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function handleFocusOut(event: FocusEvent) {
|
||||
const container =
|
||||
displayMode.value === 'single'
|
||||
? imageContainerEl.value
|
||||
: gridContainerEl.value
|
||||
if (!container?.contains(event.relatedTarget as Node)) {
|
||||
isFocused.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
if (!(event.target instanceof HTMLImageElement)) return
|
||||
const { naturalWidth, naturalHeight } = event.target
|
||||
if (naturalWidth && naturalHeight) {
|
||||
imageDimensions.value = `${naturalWidth} x ${naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
function setThumbnailRef(el: HTMLElement | null, index: number) {
|
||||
thumbnailRefs.value[index] = el
|
||||
}
|
||||
|
||||
function scrollToActive() {
|
||||
void nextTick(() => {
|
||||
const el = thumbnailRefs.value[activeIndex.value]
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
activeIndex.value =
|
||||
activeIndex.value > 0
|
||||
? activeIndex.value - 1
|
||||
: galleryImages.value.length - 1
|
||||
imageDimensions.value = null
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
activeIndex.value =
|
||||
activeIndex.value < galleryImages.value.length - 1
|
||||
? activeIndex.value + 1
|
||||
: 0
|
||||
imageDimensions.value = null
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function switchToGrid() {
|
||||
isHovered.value = false
|
||||
displayMode.value = 'grid'
|
||||
}
|
||||
|
||||
function switchToSingle() {
|
||||
isHovered.value = false
|
||||
displayMode.value = 'single'
|
||||
}
|
||||
|
||||
function selectFromGrid(index: number) {
|
||||
activeIndex.value = index
|
||||
imageDimensions.value = null
|
||||
isHovered.value = false
|
||||
displayMode.value = 'single'
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function handleEditMask() {
|
||||
if (!nodeId) return
|
||||
const node = app.rootGraph?.getNodeById(Number(nodeId))
|
||||
if (!node) return
|
||||
maskEditor.openMaskEditor(node)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const src = activeItem.value ? getItemSrc(activeItem.value) : ''
|
||||
if (!src) return
|
||||
try {
|
||||
downloadFile(src)
|
||||
} catch {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
if (!nodeId) return
|
||||
const node = app.rootGraph?.getNodeById(Number(nodeId))
|
||||
nodeOutputStore.removeNodeOutputs(nodeId)
|
||||
if (node) {
|
||||
node.imgs = undefined
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
imageWidget.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -51,6 +51,31 @@ describe('defaultSearcher', () => {
|
||||
const result = await defaultSearcher('xyz', items)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('matches against label when provided', async () => {
|
||||
const itemsWithLabels = [
|
||||
createItem('model_v1.safetensors', 'My Cool Model'),
|
||||
createItem('lora_v2.safetensors', 'Style Transfer LoRA'),
|
||||
createItem('checkpoint.ckpt', 'Realistic Vision')
|
||||
]
|
||||
const result = await defaultSearcher('cool', itemsWithLabels)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('model_v1.safetensors')
|
||||
})
|
||||
|
||||
it('matches by label case-insensitively', async () => {
|
||||
const itemsWithLabels = [createItem('file.safetensors', 'My Cool Model')]
|
||||
const result = await defaultSearcher('MY COOL', itemsWithLabels)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('matches when word is in name or label', async () => {
|
||||
const itemsWithLabels = [
|
||||
createItem('sd_v15.safetensors', 'Stable Diffusion 1.5')
|
||||
]
|
||||
const result = await defaultSearcher('stable', itemsWithLabels)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultSortOptions', () => {
|
||||
|
||||
@@ -12,7 +12,8 @@ export async function defaultSearcher(
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
return items.filter((item) => {
|
||||
const name = item.name.toLowerCase()
|
||||
return words.every((word) => name.includes(word))
|
||||
const label = item.label?.toLowerCase() ?? ''
|
||||
return words.every((word) => name.includes(word) || label.includes(word))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,110 @@ describe('nodeOutputStore restoreOutputs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeOutputStore input preview preservation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
})
|
||||
|
||||
it('should preserve input preview when execution sends empty output', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
|
||||
const emptyExecutionOutput = createMockOutputs()
|
||||
store.setNodeOutputsByExecutionId(executionId, emptyExecutionOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
||||
'example.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve input preview when execution sends output with empty images array', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
||||
|
||||
const emptyImagesOutput = createMockOutputs([])
|
||||
store.setNodeOutputsByExecutionId(executionId, emptyImagesOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images?.[0].type).toBe('input')
|
||||
})
|
||||
|
||||
it('should allow execution output with images to overwrite input preview', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '3'
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
||||
|
||||
const executionOutput = createMockOutputs([
|
||||
{ filename: 'output.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, executionOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
||||
'output.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not preserve non-input outputs from being overwritten', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '4'
|
||||
|
||||
const tempOutput = createMockOutputs([
|
||||
{ filename: 'temp.png', subfolder: '', type: 'temp' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, tempOutput)
|
||||
|
||||
const emptyOutput = createMockOutputs()
|
||||
store.setNodeOutputsByExecutionId(executionId, emptyOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should pass through non-image fields while preserving input preview images', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '5'
|
||||
|
||||
const inputPreview = createMockOutputs([
|
||||
{ filename: 'example.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
||||
|
||||
const videoOutput: ExecutedWsMessage['output'] = {
|
||||
video: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
|
||||
}
|
||||
store.setNodeOutputsByExecutionId(executionId, videoOutput)
|
||||
|
||||
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
||||
'example.png'
|
||||
)
|
||||
expect(store.nodeOutputs[executionId]?.video).toHaveLength(1)
|
||||
expect(store.nodeOutputs[executionId]?.video?.[0].filename).toBe(
|
||||
'output.mp4'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeOutputStore getPreviewParam', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -126,6 +126,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an output contains input-type preview images (from upload widgets).
|
||||
* These are synthetic previews set by LoadImage/LoadVideo widgets, not
|
||||
* execution results from the backend.
|
||||
*/
|
||||
function isInputPreviewOutput(
|
||||
output: ExecutedWsMessage['output'] | ResultItem | undefined
|
||||
): boolean {
|
||||
const images = (output as ExecutedWsMessage['output'] | undefined)?.images
|
||||
return (
|
||||
Array.isArray(images) &&
|
||||
images.length > 0 &&
|
||||
images.every((i) => i?.type === 'input')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to set outputs by NodeLocatorId.
|
||||
* Handles the merge logic when needed.
|
||||
@@ -140,6 +156,26 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
// (e.g., two LoadImage nodes selecting the same image)
|
||||
if (outputs == null) return
|
||||
|
||||
// Preserve input preview images (from upload widgets) when execution
|
||||
// sends outputs with no images. Without this guard, execution results
|
||||
// overwrite the upload widget's preview, causing LoadImage/LoadVideo
|
||||
// nodes to lose their preview after execution + tab switch.
|
||||
// Note: intentional preview clears go through setNodeOutputs (widget
|
||||
// path), not setNodeOutputsByExecutionId, so this guard does not
|
||||
// interfere with user-initiated clears.
|
||||
const incomingImages = (outputs as ExecutedWsMessage['output']).images
|
||||
const hasIncomingImages =
|
||||
Array.isArray(incomingImages) && incomingImages.length > 0
|
||||
if (
|
||||
!hasIncomingImages &&
|
||||
isInputPreviewOutput(app.nodeOutputs[nodeLocatorId])
|
||||
) {
|
||||
outputs = {
|
||||
...outputs,
|
||||
images: app.nodeOutputs[nodeLocatorId].images
|
||||
}
|
||||
}
|
||||
|
||||
if (options.merge) {
|
||||
const existingOutput = app.nodeOutputs[nodeLocatorId]
|
||||
if (existingOutput && outputs) {
|
||||
|
||||
Reference in New Issue
Block a user